aminghadersohi commented on code in PR #43784:
URL: https://github.com/apache/superset/pull/43784#discussion_r3920525955
##########
superset/utils/screenshot_utils.py:
##########
@@ -805,28 +889,127 @@ def _raise_if_budget_exhausted() -> None:
"height": clip_height,
}
+ try:
+ contentful_chart_holders = page.evaluate(
+ CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS,
+ {"top": clip_y, "bottom": clip_y + clip_height},
+ )
+ if not isinstance(contentful_chart_holders, int):
+ contentful_chart_holders = 0
+ except Exception: # noqa: BLE001
+ logger.warning(
+ "Unable to count chart holders intersecting tile %s/%s%s",
+ i + 1,
+ num_tiles,
+ context_suffix,
+ exc_info=True,
+ )
+ contentful_chart_holders = 0
Review Comment:
**`0` means both "no charts here" and "the check broke", and one of those
silently disables the fix.**
`is_blank = is_uniform and contentful_chart_holders > 0`, so
`contentful_chart_holders = 0` makes a uniform tile unconditionally acceptable.
Two ways to reach `0` that are not "this tile legitimately has no charts":
1. **This `except`** — `evaluate` raised. Logged, at least.
2. **`CHART_HOLDER_SELECTOR` stops matching** — a renamed dashboard class, a
DOM restructure. The count is a legitimate `0`, no exception, no warning. The
only trace is `contentful_chart_holders=0` in a **DEBUG** log that is off in
production.
In case 2 every uniform tile is accepted and the blank-tile detection is
silently a no-op, which is the failure this PR exists to prevent. The readiness
gate still stands in front of it, so this degrades to pre-PR behaviour rather
than something worse — but it degrades *invisibly*, and a defence that can
silently switch itself off is hard to trust later.
#43348 handled the identical ambiguity deliberately, writing `holders.length
> 0 && unready.length === 0` in the report readiness predicates so an empty
holder set could never read as ready. Worth carrying that reasoning here — e.g.
have the JS return `{total, contentful}` and treat `total === 0` across *every*
tile of a dashboard that `expected_chart_count` says has charts as a WARNING,
distinct from a per-tile `contentful === 0`.
At minimum, make the `except` path fail *closed* (treat as contentful)
rather than open — a wasted retry is much cheaper than a blank report.
##########
superset/utils/screenshot_utils.py:
##########
@@ -805,28 +889,127 @@ def _raise_if_budget_exhausted() -> None:
"height": clip_height,
}
+ try:
+ contentful_chart_holders = page.evaluate(
+ CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS,
+ {"top": clip_y, "bottom": clip_y + clip_height},
+ )
+ if not isinstance(contentful_chart_holders, int):
+ contentful_chart_holders = 0
+ except Exception: # noqa: BLE001
+ logger.warning(
+ "Unable to count chart holders intersecting tile %s/%s%s",
+ i + 1,
+ num_tiles,
+ context_suffix,
+ exc_info=True,
+ )
+ contentful_chart_holders = 0
+
# Take screenshot with clipping to capture only this tile's content
- capture_timeout = (
- _timeout_seconds(
- "screenshot_capture",
- reserve_seconds=(
- report_execution_context.post_capture_reserve_seconds
- if report_execution_context
- else 0.0
- ),
+ tile_screenshot: bytes | None = None
+ for capture_attempt in range(1,
TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS + 1):
+ capture_timeout = (
+ _timeout_seconds(
+ "screenshot_capture",
+
requested_seconds=TILED_SCREENSHOT_CAPTURE_TIMEOUT_SECONDS,
+ reserve_seconds=(
+
report_execution_context.post_capture_reserve_seconds
+ if report_execution_context
+ else 0.0
+ ),
+ )
+ if report_execution_context or task_budget is not None
+ else None
)
- if report_execution_context or task_budget is not None
- else None
- )
- tile_screenshot = page.screenshot(
- type="png",
- clip=clip,
- **(
- {"timeout": capture_timeout * 1000}
- if capture_timeout is not None
- else {}
- ),
- )
+ capture_started_at = time.monotonic()
+ try:
+ candidate = page.screenshot(
+ type="png",
+ clip=clip,
+ **(
+ {"timeout": capture_timeout * 1000}
+ if capture_timeout is not None
+ else {}
+ ),
+ )
+ except PlaywrightTimeout as ex:
+ capture_elapsed = time.monotonic() - capture_started_at
+ logger.warning(
+ "report_capture_tile_timeout tile=%s/%s attempt=%s/%s "
+ "capture_elapsed_seconds=%.2f%s",
+ i + 1,
+ num_tiles,
+ capture_attempt,
+ TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
+ capture_elapsed,
+ context_suffix,
+ )
+ if capture_attempt ==
TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS:
+ raise ScreenshotCaptureTimeoutError(
+ f"Chromium timed out capturing tile {i +
1}/{num_tiles} "
+ f"after {capture_attempt} attempts"
+ ) from ex
+ else:
+ capture_elapsed = time.monotonic() - capture_started_at
+ is_uniform, dominant_ratio =
is_screenshot_nearly_uniform(candidate)
+ is_blank = is_uniform and contentful_chart_holders > 0
+ logger.debug(
+ "Captured tile %s/%s attempt %s/%s in %.2fs "
+ "(contentful_chart_holders=%s
dominant_pixel_ratio=%.5f)%s",
+ i + 1,
+ num_tiles,
+ capture_attempt,
+ TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
+ capture_elapsed,
+ contentful_chart_holders,
+ dominant_ratio,
+ context_suffix,
+ )
+ if not is_blank:
+ tile_screenshot = candidate
+ break
+ blank_tile_retries += 1
+ logger.warning(
+ "report_capture_blank_tile tile=%s/%s attempt=%s/%s "
+ "capture_elapsed_seconds=%.2f
contentful_chart_holders=%s "
+ "dominant_pixel_ratio=%.5f%s",
+ i + 1,
+ num_tiles,
+ capture_attempt,
+ TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS,
+ capture_elapsed,
+ contentful_chart_holders,
+ dominant_ratio,
+ context_suffix,
+ )
+ if capture_attempt ==
TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS:
+ tile_screenshot = candidate
+ logger.warning(
+ "report_capture_uniform_tile_retained tile=%s/%s "
+ "attempts=%s contentful_chart_holders=%s "
+ "dominant_pixel_ratio=%.5f%s",
+ i + 1,
+ num_tiles,
+ capture_attempt,
+ contentful_chart_holders,
+ dominant_ratio,
+ context_suffix,
+ )
+ break
+
+ _raise_if_budget_exhausted()
+ page.bring_to_front()
+ page.evaluate(
Review Comment:
**The bounded capture is followed by an unbounded recovery.**
`TILED_SCREENSHOT_CAPTURE_TIMEOUT_SECONDS` exists, per its own comment, to
*"keep each CDP capture bounded so a wedged compositor cannot consume the
report deadline."* This `evaluate` then reopens that hole.
`page.evaluate()` in the Playwright sync API takes `(expression, arg)` —
**there is no `timeout` parameter**, and Playwright's default timeout does not
apply to it. The expression returns a Promise that only settles inside a nested
`requestAnimationFrame`. If rAF is not being serviced, the promise never
resolves and this call blocks until the Celery hard time limit kills the worker
— past the report deadline and past the `_raise_if_budget_exhausted()` on the
line above, which has already run by then.
The condition under which rAF stops being serviced is a stalled compositor,
which is the literal premise of this retry path. `page.bring_to_front()` covers
the occluded-tab case, but not a genuinely wedged one.
This is also the only promise-returning `evaluate` in the module. Every
other wait here uses `page.wait_for_function(..., timeout=...)` (lines 611,
741). Same thing would work:
```python
page.evaluate("() => { window.scrollBy(0, 1); window.scrollBy(0, -1); "
"window.__supersetRepaint = false; "
"requestAnimationFrame(() => requestAnimationFrame("
"() => { window.__supersetRepaint = true; })); }")
page.wait_for_function("() => window.__supersetRepaint === true",
timeout=min(5, remaining) * 1000)
```
with the timeout swallowed — failing to repaint should cost one more capture
attempt, not the whole report.
##########
UPDATING.md:
##########
@@ -230,8 +230,10 @@ Behavior changes to be aware of:
fail fast at the first phase check rather than erroring at setup.
- Dashboard reports whose charts have not mounted are no longer captured
blank: readiness is polled until the deadline, and the report fails loudly
- if charts never mount. Thumbnails and non-report screenshots keep their
- previous behavior.
+ if charts never mount. Large tiled reports also retry Chromium screenshot
+ stalls and suspicious uniform tiles, while persistent screenshot timeouts
+ fail loudly. Thumbnails and non-report screenshots keep their previous
Review Comment:
*"Thumbnails and non-report screenshots keep their previous behavior"* is no
longer accurate for this PR.
Neither the retry loop nor `ScreenshotCaptureTimeoutError` is gated on
`report_execution_context`, and `take_tiled_screenshot` is reached for any
dashboard over the tiling threshold, report context or not
(`webdriver.py:793`). So for a large-dashboard thumbnail:
- a uniform tile with visible chart holders now costs up to 3 capture
attempts instead of 1;
- a persistent capture timeout now raises `ScreenshotCaptureTimeoutError`.
The second one is the substantive change. Previously that path returned
`None`, and `webdriver.py:820` converted it into `PlaywrightTimeout` — which
the comment there describes as deliberate, *"for thumbnails too, since the
caller treats the raise as a clean cache-ERROR."*
`ScreenshotCaptureTimeoutError` is a bare `RuntimeError`, so it escapes both
handlers at `webdriver.py:978-980` (`PlaywrightTimeout` re-raise,
`PlaywrightError` log-and-continue) and never reaches the `if not img:`
conversion.
Confirmed by reverting the production hunks:
`test_capture_timeout_retries_with_remaining_budget` fails with `assert None ==
b'combined'`, i.e. the old path returned `None`.
Two options: gate the raise on `report_execution_context` so thumbnails keep
returning `None`, or subclass `PlaywrightTimeout` instead of `RuntimeError` so
the existing contract holds. Either way this line needs updating — the rest of
the note matches the diff accurately.
##########
superset/utils/screenshot_utils.py:
##########
@@ -136,8 +169,18 @@ class
TiledScreenshotBudgetExceededError(ScreenshotTaskBudgetExceededError):
SLICE_CONTAINER_SELECTOR = r".slice_container"
LOADING_SELECTOR = r".loading"
ALERT_SELECTOR = r'[role="alert"]'
-EMPTY_SELECTOR = r".ant-empty"
+EMPTY_SELECTOR = r".ant-empty, .ag-overlay-no-rows-wrapper:not(.ag-hidden)"
MISSING_CHART_SELECTOR = r".missing-chart-container"
+CONTENTFUL_CHART_HOLDERS_IN_CLIP_JS = f"""clip => Array.from(
+ document.querySelectorAll('{CHART_HOLDER_SELECTOR}')
+).filter(holder => {{
+ const rect = holder.getBoundingClientRect();
+ const intersectsClip = rect.bottom > clip.top && rect.top < clip.bottom;
Review Comment:
**Any overlap counts, including 1px** — `rect.bottom > clip.top && rect.top
< clip.bottom`.
A chart holder that crosses a tile boundary by a single pixel marks the
*neighbouring* tile as contentful. If that neighbour is genuinely near-uniform
whitespace — a tall holder with a small painted region, a Big Number, a sparse
table — it is treated as a blank capture and burns the full 3 attempts plus 2
forced repaints, every run, deterministically.
That is the concrete answer to "can a healthy dashboard now time out":
nothing here is unbounded on its own (`_raise_if_budget_exhausted()` runs
between attempts), but the added cost is `2 × (screenshot + bring_to_front +
repaint)` per affected tile, and it lands on dashboards with many tiles —
exactly the ones already closest to the budget. A worst case of `3 × 120s` per
tile of capture alone is a large amount of newly reachable headroom consumption
for a dashboard that was previously capturing fine on one attempt.
Requiring a meaningful overlap would remove most of the false positives
cheaply:
```js
const overlap = Math.min(rect.bottom, clip.bottom) - Math.max(rect.top,
clip.top);
const intersectsClip = overlap > Math.min(rect.height, clip.bottom -
clip.top) * 0.1;
```
Worth logging `blank_tile_retries` at INFO rather than only folding it into
the readiness line, so this is measurable in the field before it shows up as a
budget-exceeded report.
--
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]