geido commented on code in PR #44144:
URL: https://github.com/apache/superset/pull/44144#discussion_r4025524993


##########
superset/utils/webdriver.py:
##########
@@ -255,35 +269,77 @@ def _get_screenshot(
             return element.screenshot(**timeout_kwargs)
 
     @staticmethod
-    def _get_validated_screenshot(
+    def _get_validated_screenshot(  # noqa: C901
         page: Page,
         element: Locator,
         element_name: str,
         log_context: str | None,
         report_execution_context: ReportExecutionContext | None,
+        *,
+        validate_rendered_content: bool = False,
+        require_complete_capture: bool = False,
+        load_wait_seconds: float = 60.0,
     ) -> bytes:
-        """Capture a standard screenshot and reject blank report output."""
+        """Capture a standard screenshot and reject incomplete rendered 
output."""
 
         context_suffix = f" [{log_context}]" if log_context else ""
-        for attempt in range(1, TILED_SCREENSHOT_MAX_CAPTURE_ATTEMPTS + 1):
-            if report_execution_context:
-                stable_timeout = 
report_execution_context.deadline.timeout_seconds(
-                    "capture_readiness_stability",
-                    reserve_seconds=(
-                        report_execution_context.readiness_reserve_seconds
-                    ),
+        api_capture_deadline = (
+            time.monotonic() + load_wait_seconds

Review Comment:
   Fixed in 4be12fc356. Strict API retries still share one monotonic deadline, 
but it now includes the configured load wait plus one Playwright 
capture-operation window, so readiness can consume its allowance without 
leaving zero time to capture. Tests cover slow stability with the reserved 
capture window and deadline exhaustion.



##########
superset/dashboards/api.py:
##########
@@ -1965,44 +1983,190 @@ def cache_dashboard_screenshot(self, pk: int, 
**kwargs: Any) -> WerkzeugResponse
 
         dashboard_url = get_url_path("Superset.dashboard_permalink", 
key=permalink_key)
         screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest)
-        cache_key = screenshot_obj.get_cache_key(window_size, thumb_size, 
permalink_key)
-        image_url = get_url_path(
-            "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key
-        )
-        cache_payload = (
-            screenshot_obj.get_from_cache_key(cache_key) or 
ScreenshotCachePayload()
+        cache_scope = f"dashboard:{dashboard.id}"
+        request_cache_key = screenshot_obj.get_api_request_cache_key(
+            window_size,
+            thumb_size,
+            permalink_key,
+            cache_scope,
         )
 
-        def build_response(status_code: int) -> WerkzeugResponse:
+        def build_response(
+            status_code: int,
+            cache_key: str,
+            cache_payload: ScreenshotCachePayload,
+        ) -> WerkzeugResponse:
             return self.response(
                 status_code,
                 cache_key=cache_key,
                 dashboard_url=dashboard_url,
-                image_url=image_url,
+                image_url=get_url_path(
+                    "DashboardRestApi.screenshot",
+                    pk=dashboard.id,
+                    digest=cache_key,
+                ),
                 task_updated_at=cache_payload.get_timestamp(),
                 task_status=cache_payload.get_status(),
             )
 
-        if cache_payload.should_trigger_task(
-            force, expected_scope=f"dashboard:{dashboard.id}"
-        ):
-            logger.info("Triggering screenshot ASYNC")
-            cache_dashboard_screenshot.delay(
-                username=get_current_user(),
-                guest_token=(
-                    g.user.guest_token
-                    if get_current_user() and isinstance(g.user, GuestUser)
+        def get_current_generation() -> tuple[
+            str | None, ScreenshotCachePayload | None
+        ]:
+            cache_key = screenshot_obj.get_current_api_generation_cache_key(
+                request_cache_key,
+                cache_scope,
+            )
+            return (
+                cache_key,
+                (
+                    screenshot_obj.get_from_cache_key(
+                        cache_key,
+                        raise_on_error=True,
+                    )
+                    if cache_key
                     else None
                 ),
-                dashboard_id=dashboard.id,
-                dashboard_url=dashboard_url,
-                thumb_size=thumb_size,
-                window_size=window_size,
-                cache_key=cache_key,
-                force=force,
             )
-            return build_response(202)
-        return build_response(200)
+
+        try:
+            observed_cache_key, _ = get_current_generation()
+        except ScreenshotCacheError:
+            logger.exception("Screenshot cache read failed: %s", 
request_cache_key)
+            return self.response(
+                503,
+                message=gettext("Screenshot cache is unavailable"),
+            )
+
+        lock_deadline = time.monotonic() + SCREENSHOT_API_LOCK_WAIT_SECONDS
+        while True:
+            try:
+                with DistributedLock(
+                    namespace=SCREENSHOT_API_LOCK_NAMESPACE,
+                    request_cache_key=request_cache_key,
+                ):
+                    try:
+                        cache_key, cached_payload = get_current_generation()
+                    except ScreenshotCacheError:
+                        logger.exception(
+                            "Screenshot cache read failed: %s",
+                            request_cache_key,
+                        )
+                        return self.response(
+                            503,
+                            message=gettext("Screenshot cache is unavailable"),
+                        )
+
+                    cache_payload = cached_payload or ScreenshotCachePayload(
+                        scope=cache_scope
+                    )
+                    if cached_payload is not None and (
+                        cache_key != observed_cache_key
+                        or not cache_payload.should_enqueue_task(
+                            force,
+                            expected_scope=cache_scope,
+                        )
+                    ):
+                        assert cache_key is not None
+                        return build_response(200, cache_key, cache_payload)
+
+                    logger.info("Triggering screenshot ASYNC")
+                    next_cache_key = 
screenshot_obj.get_next_api_generation_cache_key(
+                        request_cache_key,
+                        cache_key,
+                    )
+                    cache_payload = ScreenshotCachePayload(scope=cache_scope)
+                    cache_payload.pending()
+                    try:
+                        screenshot_obj.store_cache_payload(
+                            next_cache_key,
+                            cache_payload,
+                        )
+                        screenshot_obj.set_current_api_generation_cache_key(
+                            request_cache_key,
+                            next_cache_key,
+                            cache_scope,
+                        )
+                    except ScreenshotCacheError:
+                        logger.exception(
+                            "Screenshot task preparation failed: %s",
+                            next_cache_key,
+                        )
+                        return self.response(
+                            503,
+                            message=gettext("Screenshot cache is unavailable"),
+                        )
+
+                    try:
+                        cache_dashboard_screenshot.delay(
+                            username=get_current_user(),
+                            guest_token=(
+                                g.user.guest_token
+                                if get_current_user() and isinstance(g.user, 
GuestUser)
+                                else None
+                            ),
+                            dashboard_id=dashboard.id,
+                            dashboard_url=dashboard_url,
+                            thumb_size=thumb_size,
+                            window_size=window_size,
+                            cache_key=next_cache_key,
+                            # The API has already selected and published a 
fresh
+                            # generation. Duplicate deliveries should never 
force a
+                            # completed result to recompute.
+                            force=False,
+                        )
+                    except Exception:  # pylint: disable=broad-except
+                        screenshot_obj.mark_cache_error_if_incomplete(
+                            next_cache_key,
+                            cache_scope,
+                        )
+                        raise
+                    return build_response(202, next_cache_key, cache_payload)

Review Comment:
   Fixed in 4be12fc356. The API now advertises an additive wait budget covering 
the Pending and Computing leases, and the UI derives its GET retry cap from it 
with a six-minute fallback for older servers. The unit test completes after 93 
seconds; the real browser flow stayed alive through 40 404 polls and downloaded 
a valid PNG after 126.97 seconds.



##########
superset/utils/screenshots.py:
##########
@@ -321,13 +407,69 @@ def get_from_cache_key(cls, cache_key: str) -> 
ScreenshotCachePayload | None:
         logger.info("Failed at getting from cache: %s", cache_key)
         return None
 
-    def compute_and_cache(  # pylint: disable=too-many-arguments
+    @classmethod
+    def store_cache_payload(
+        cls,
+        cache_key: str,
+        cache_payload: ScreenshotCachePayload,
+    ) -> None:
+        """Persist screenshot state or raise when the backend rejects it."""
+
+        try:
+            stored = cls.cache.set(cache_key, cache_payload.to_dict())
+        except Exception as ex:  # pylint: disable=broad-except
+            raise ScreenshotCacheError(
+                f"Could not persist screenshot cache key {cache_key}"
+            ) from ex
+        # Flask-Caching permits custom backends whose successful ``set``
+        # returns None, so only an explicit False is a failed write.
+        if stored is False:
+            raise ScreenshotCacheError(
+                f"Could not persist screenshot cache key {cache_key}"
+            )
+
+    @classmethod
+    def mark_cache_error_if_incomplete(cls, cache_key: str, scope: str) -> 
None:
+        """Mark an accepted generation failed without clobbering another 
worker."""
+
+        try:
+            with DistributedLock(
+                namespace="thumbnail",
+                key=cache_key,
+                ttl_seconds=app.config["THUMBNAIL_COMPUTING_CACHE_TTL"],
+            ):
+                cache_payload = cls.get_from_cache_key(cache_key)

Review Comment:
   Fixed in 4be12fc356. Both strict worker reads now propagate cache GET 
failures: cleanup logs and performs no SET, and strict compute aborts before 
capture or write. Legacy thumbnail reads retain their historical miss-tolerant 
behavior. Both no-write regressions pass.



##########
superset/dashboards/api.py:
##########
@@ -1965,44 +1983,190 @@ def cache_dashboard_screenshot(self, pk: int, 
**kwargs: Any) -> WerkzeugResponse
 
         dashboard_url = get_url_path("Superset.dashboard_permalink", 
key=permalink_key)
         screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest)
-        cache_key = screenshot_obj.get_cache_key(window_size, thumb_size, 
permalink_key)
-        image_url = get_url_path(
-            "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key
-        )
-        cache_payload = (
-            screenshot_obj.get_from_cache_key(cache_key) or 
ScreenshotCachePayload()
+        cache_scope = f"dashboard:{dashboard.id}"
+        request_cache_key = screenshot_obj.get_api_request_cache_key(
+            window_size,
+            thumb_size,
+            permalink_key,
+            cache_scope,
         )
 
-        def build_response(status_code: int) -> WerkzeugResponse:
+        def build_response(
+            status_code: int,
+            cache_key: str,
+            cache_payload: ScreenshotCachePayload,
+        ) -> WerkzeugResponse:
             return self.response(
                 status_code,
                 cache_key=cache_key,
                 dashboard_url=dashboard_url,
-                image_url=image_url,
+                image_url=get_url_path(
+                    "DashboardRestApi.screenshot",
+                    pk=dashboard.id,
+                    digest=cache_key,
+                ),
                 task_updated_at=cache_payload.get_timestamp(),
                 task_status=cache_payload.get_status(),
             )
 
-        if cache_payload.should_trigger_task(
-            force, expected_scope=f"dashboard:{dashboard.id}"
-        ):
-            logger.info("Triggering screenshot ASYNC")
-            cache_dashboard_screenshot.delay(
-                username=get_current_user(),
-                guest_token=(
-                    g.user.guest_token
-                    if get_current_user() and isinstance(g.user, GuestUser)
+        def get_current_generation() -> tuple[
+            str | None, ScreenshotCachePayload | None
+        ]:
+            cache_key = screenshot_obj.get_current_api_generation_cache_key(
+                request_cache_key,
+                cache_scope,
+            )
+            return (
+                cache_key,
+                (
+                    screenshot_obj.get_from_cache_key(
+                        cache_key,
+                        raise_on_error=True,
+                    )
+                    if cache_key
                     else None
                 ),
-                dashboard_id=dashboard.id,
-                dashboard_url=dashboard_url,
-                thumb_size=thumb_size,
-                window_size=window_size,
-                cache_key=cache_key,
-                force=force,
             )
-            return build_response(202)
-        return build_response(200)
+
+        try:
+            observed_cache_key, _ = get_current_generation()
+        except ScreenshotCacheError:
+            logger.exception("Screenshot cache read failed: %s", 
request_cache_key)
+            return self.response(
+                503,
+                message=gettext("Screenshot cache is unavailable"),
+            )
+
+        lock_deadline = time.monotonic() + SCREENSHOT_API_LOCK_WAIT_SECONDS
+        while True:
+            try:
+                with DistributedLock(
+                    namespace=SCREENSHOT_API_LOCK_NAMESPACE,
+                    request_cache_key=request_cache_key,
+                ):
+                    try:
+                        cache_key, cached_payload = get_current_generation()
+                    except ScreenshotCacheError:
+                        logger.exception(
+                            "Screenshot cache read failed: %s",
+                            request_cache_key,
+                        )
+                        return self.response(
+                            503,
+                            message=gettext("Screenshot cache is unavailable"),
+                        )
+
+                    cache_payload = cached_payload or ScreenshotCachePayload(
+                        scope=cache_scope
+                    )
+                    if cached_payload is not None and (
+                        cache_key != observed_cache_key
+                        or not cache_payload.should_enqueue_task(
+                            force,
+                            expected_scope=cache_scope,
+                        )
+                    ):
+                        assert cache_key is not None

Review Comment:
   Fixed in 4be12fc356. The request-path assert is now an explicit missing-key 
guard that logs the invariant failure and returns 503, so behavior is unchanged 
under python -O.



##########
superset/dashboards/api.py:
##########
@@ -1965,44 +1983,190 @@ def cache_dashboard_screenshot(self, pk: int, 
**kwargs: Any) -> WerkzeugResponse
 
         dashboard_url = get_url_path("Superset.dashboard_permalink", 
key=permalink_key)
         screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest)
-        cache_key = screenshot_obj.get_cache_key(window_size, thumb_size, 
permalink_key)
-        image_url = get_url_path(
-            "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key
-        )
-        cache_payload = (
-            screenshot_obj.get_from_cache_key(cache_key) or 
ScreenshotCachePayload()
+        cache_scope = f"dashboard:{dashboard.id}"
+        request_cache_key = screenshot_obj.get_api_request_cache_key(
+            window_size,
+            thumb_size,
+            permalink_key,
+            cache_scope,
         )
 
-        def build_response(status_code: int) -> WerkzeugResponse:
+        def build_response(
+            status_code: int,
+            cache_key: str,
+            cache_payload: ScreenshotCachePayload,
+        ) -> WerkzeugResponse:
             return self.response(
                 status_code,
                 cache_key=cache_key,
                 dashboard_url=dashboard_url,
-                image_url=image_url,
+                image_url=get_url_path(
+                    "DashboardRestApi.screenshot",
+                    pk=dashboard.id,
+                    digest=cache_key,
+                ),
                 task_updated_at=cache_payload.get_timestamp(),
                 task_status=cache_payload.get_status(),
             )
 
-        if cache_payload.should_trigger_task(
-            force, expected_scope=f"dashboard:{dashboard.id}"
-        ):
-            logger.info("Triggering screenshot ASYNC")
-            cache_dashboard_screenshot.delay(
-                username=get_current_user(),
-                guest_token=(
-                    g.user.guest_token
-                    if get_current_user() and isinstance(g.user, GuestUser)
+        def get_current_generation() -> tuple[
+            str | None, ScreenshotCachePayload | None
+        ]:
+            cache_key = screenshot_obj.get_current_api_generation_cache_key(
+                request_cache_key,
+                cache_scope,
+            )
+            return (
+                cache_key,
+                (
+                    screenshot_obj.get_from_cache_key(
+                        cache_key,
+                        raise_on_error=True,
+                    )
+                    if cache_key
                     else None
                 ),
-                dashboard_id=dashboard.id,
-                dashboard_url=dashboard_url,
-                thumb_size=thumb_size,
-                window_size=window_size,
-                cache_key=cache_key,
-                force=force,
             )
-            return build_response(202)
-        return build_response(200)
+
+        try:
+            observed_cache_key, _ = get_current_generation()
+        except ScreenshotCacheError:
+            logger.exception("Screenshot cache read failed: %s", 
request_cache_key)
+            return self.response(
+                503,
+                message=gettext("Screenshot cache is unavailable"),
+            )
+
+        lock_deadline = time.monotonic() + SCREENSHOT_API_LOCK_WAIT_SECONDS
+        while True:
+            try:
+                with DistributedLock(

Review Comment:
   Fixed in 4be12fc356. The handler retains the completed response before 
leaving the producer lock, so a release failure after persistence/enqueue logs 
a warning and returns that same response instead of turning accepted work into 
a 500. The regression asserts 202, Pending, and exactly one enqueue.



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