sadpandajoe commented on code in PR #43523:
URL: https://github.com/apache/superset/pull/43523#discussion_r4021929182


##########
superset/utils/screenshots.py:
##########
@@ -111,20 +111,28 @@ def __init__(
         scope: str | None = None,
     ):
         self._image = image
-        self._timestamp = timestamp or datetime.now().isoformat()
+        self._timestamp = timestamp or datetime.now(timezone.utc).isoformat()

Review Comment:
   Writing offset-aware timestamps here breaks mixed-version reads: pre-change 
pods subtract naive `datetime.now()` from `datetime.fromisoformat(...)`, so a 
new worker’s `COMPUTING` or `ERROR` entry raises `TypeError` on old web pods; a 
failed first render or rollback can leave S3-backed thumbnails stuck at 500. 
Could the serialized format remain backward-compatible for one release, or be 
versioned before requiring aware timestamps?



##########
superset/charts/api.py:
##########
@@ -1276,16 +1279,22 @@ def screenshot(self, pk: int, digest: str) -> 
WerkzeugResponse:
             # serve its image under a different, merely-accessible `pk`.
             if cache_payload.get_scope() != f"chart:{chart.id}":
                 return self.response_404()
-            if cache_payload.status == StatusValues.UPDATED:
-                try:
-                    image = cache_payload.get_image()
-                except ScreenshotImageNotAvailableException:
-                    return self.response_404()
-                return Response(
-                    FileWrapper(image),
-                    mimetype="image/png",
-                    direct_passthrough=True,
-                )
+            # Serve whenever a valid image is present instead of gating on
+            # status == UPDATED. A failed forced refresh leaves the entry in an
+            # ERROR/COMPUTING backoff while still carrying the retained 
last-good
+            # image; requiring UPDATED here would 404 that image for up to a 
day.
+            # get_from_cache_key already rejects an invalid UPDATED image, and 
a

Review Comment:
   `get_from_cache_key()` only validates images whose status is `UPDATED`; 
after the status-preservation change, retained `ERROR` or `COMPUTING` images 
skip the check, so a pre-validation or corrupted cache entry can be served as 
`image/png` instead of becoming a miss. Could we validate every retained image 
regardless of status and add a non-`UPDATED` invalid-image case?



##########
tests/integration_tests/dashboards/api_tests.py:
##########
@@ -4259,6 +4259,92 @@ def 
test_cache_dashboard_screenshot_dashboard_not_found(self):
         response = self._cache_screenshot(non_existent_id)
         assert response.status_code == 404
 
+    @with_feature_flags(THUMBNAILS=True, 
ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True)
+    @with_config({"THUMBNAIL_UPDATED_CACHE_TTL": 300})
+    @pytest.mark.usefixtures("create_dashboard_with_tag")
+    @patch("superset.dashboards.api.cache_dashboard_screenshot")
+    @patch("superset.dashboards.api.DashboardScreenshot.get_from_cache_key")
+    def test_cache_dashboard_screenshot_recomputes_stale_updated(
+        self, mock_get_from_cache_key, mock_cache_task
+    ):
+        """A force-less request whose cached UPDATED entry is older than
+        THUMBNAIL_UPDATED_CACHE_TTL -- but still valid and correctly scoped --
+        must reschedule the Celery task. This exercises the endpoint's
+        ``check_updated_staleness=screenshot_obj.supports_updated_staleness``
+        wiring (True only for dashboards); dropping that argument makes the
+        endpoint serve the stale entry (200) instead, failing this test."""
+        from datetime import datetime, timedelta
+
+        self.login(ADMIN_USERNAME)
+
+        dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "dash with tag")
+            .first()
+        )
+        # A valid, correctly-scoped UPDATED entry, but 400s old against a 300s 
TTL.
+        stale_timestamp = (datetime.now() - timedelta(seconds=400)).isoformat()

Review Comment:
   This “400s old” fixture is interpreted as UTC by `_age_seconds()`, so on a 
UTC-ahead developer machine it becomes hours in the future and the test gets 
200 with no queued task instead of 202. Could this use 
`datetime.now(timezone.utc)` like the adjacent fixtures?



##########
superset/utils/screenshots.py:
##########
@@ -183,21 +191,66 @@ def get_invalid_image_reason(self) -> str | None:
             return None
         return validate_screenshot_image(self._image)
 
+    def _age_seconds(self) -> float | None:
+        """Seconds since this entry's timestamp, or None if the stored
+        timestamp is unusable -- a corrupt string (ValueError) or a legacy
+        tz-aware value that cannot be subtracted from naive now() (TypeError).
+        Callers treat None as 'past any TTL' so the entry self-heals."""
+        try:
+            return (
+                datetime.now() - datetime.fromisoformat(self.get_timestamp())
+            ).total_seconds()
+        except (ValueError, TypeError):
+            logger.warning(
+                "Unusable screenshot cache timestamp %r; "
+                "treating entry as expired/stale",
+                self.get_timestamp(),
+            )
+            return None
+
     def is_error_cache_ttl_expired(self) -> bool:
-        error_cache_ttl = app.config["THUMBNAIL_ERROR_CACHE_TTL"]
+        # strict '>' (an entry exactly at the TTL is still fresh). An unusable
+        # timestamp (age is None) is treated as expired so the entry 
self-heals.
+        age_seconds = self._age_seconds()
         return (
-            datetime.now() - datetime.fromisoformat(self.get_timestamp())
-        ).total_seconds() > error_cache_ttl
+            age_seconds is None or age_seconds > 
app.config["THUMBNAIL_ERROR_CACHE_TTL"]
+        )
 
     def is_computing_stale(self) -> bool:
         """Check if a COMPUTING status is stale (task likely failed or 
stuck)."""
-        computing_ttl = app.config["THUMBNAIL_COMPUTING_CACHE_TTL"]
+        # '>=' (unlike the strict '>' of the ERROR/UPDATED helpers). An 
unusable
+        # timestamp (age is None) is treated as stale so the entry self-heals.
+        age_seconds = self._age_seconds()
         return (
-            datetime.now() - datetime.fromisoformat(self.get_timestamp())
-        ).total_seconds() >= computing_ttl
+            age_seconds is None
+            or age_seconds >= app.config["THUMBNAIL_COMPUTING_CACHE_TTL"]
+        )
+
+    def is_updated_stale(self) -> bool:
+        """Whether a successfully-rendered (UPDATED) entry is old enough to be
+        recomputed. Returns False when the TTL is unset/0 (no-op unless an 
operator
+        opts in). A timestamp we cannot use -- a corrupt string (ValueError) 
or a
+        legacy tz-aware string that parses but cannot be subtracted from naive
+        now() (TypeError) -- is logged and treated as stale so it self-heals 
rather
+        than being served forever."""
+        # `.get` (not `[]` like the sibling ERROR/COMPUTING helpers) on 
purpose:
+        # a deployment whose config predates this key should silently disable 
the
+        # feature, not raise KeyError. Checked first so a disabled feature 
never
+        # parses/logs an unusable timestamp.
+        updated_ttl = app.config.get("THUMBNAIL_UPDATED_CACHE_TTL")
+        if not updated_ttl:  # None or 0 => disabled
+            return False
+        # strict '>' (an image exactly at the TTL is still fresh), matching
+        # is_error_cache_ttl_expired -- not the '>=' of is_computing_stale. An
+        # unusable timestamp (age is None) is treated as stale so it 
self-heals.
+        age_seconds = self._age_seconds()
+        return age_seconds is None or age_seconds > updated_ttl

Review Comment:
   Assuming every legacy naive timestamp is UTC breaks upgrades on UTC-ahead 
hosts: a recent `COMPUTING` entry becomes several hours in the future, so the 
360s lease remains fresh and thumbnails can 404 for the timezone offset. Could 
we preserve the old local-time interpretation for naive entries, or bound the 
negative-age tolerance?



##########
superset/utils/screenshots.py:
##########
@@ -117,14 +117,22 @@ def __init__(
 
     @classmethod
     def from_dict(cls, payload: ScreenshotCachePayloadType) -> 
ScreenshotCachePayload:
-        return cls(
+        instance = cls(
             image=base64.b64decode(payload["image"]) if payload["image"] else 
None,
             status=StatusValues(payload["status"]),
             timestamp=payload["timestamp"],
             # `.get` rather than `payload["scope"]`: entries cached before this
             # field existed won't have the key.
             scope=payload.get("scope"),
         )
+        # `__init__` infers UPDATED whenever an image is present -- convenient 
for
+        # the `ScreenshotCachePayload(image=bytes)` and legacy 
bytes-reconstruction
+        # paths, but wrong when rehydrating a persisted entry: an ERROR or 
COMPUTING
+        # entry keeps its previous image, and re-inferring UPDATED here would 
mask it
+        # as fresh and bypass the shorter ERROR/COMPUTING recovery TTLs. 
Restore the
+        # persisted status explicitly.
+        instance.status = StatusValues(payload["status"])

Review Comment:
   Serving the retained image covers the fresh ERROR backoff, but once the TTL 
expires this path pre-writes an empty PENDING payload before retrying; if the 
render fails again, the last-good image is gone and `image_url` stays 404. 
Could we preserve the retained payload across the queued retry and cover the 
force-fail → retry-fail sequence?



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