blob: 8d0946abdf9e2fc9902556009b5ea2cf7bb25190 [file] [log] [blame]
# 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.
"""
Tests for screenshot cache bug fixes:
1. Cache only saved when image generation succeeds
2. Recompute stale COMPUTING tasks and UPDATED without image
"""
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from pytest_mock import MockerFixture
from superset.utils.screenshots import (
BaseScreenshot,
ScreenshotCachePayload,
StatusValues,
)
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
class MockCache:
"""A class to manage screenshot cache for testing."""
def __init__(self):
self._cache = {}
def set(self, key, value):
"""Set the cache with a new value."""
self._cache[key] = value
def get(self, key):
"""Get the cached value."""
return self._cache.get(key)
def clear(self):
"""Clear all cached values."""
self._cache.clear()
@pytest.fixture
def mock_user():
"""Fixture to create a mock user."""
user = MagicMock()
user.id = 1
return user
@pytest.fixture
def screenshot_obj():
"""Fixture to create a BaseScreenshot object."""
url = "http://example.com"
digest = "sample_digest"
return BaseScreenshot(url, digest)
class TestCacheOnlyOnSuccess:
"""Test that cache is only saved when image generation succeeds."""
def _setup_mocks(self, mocker: MockerFixture, screenshot_obj):
"""Helper method to set up common mocks."""
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
get_screenshot = mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"image_data"
)
# Mock resize_image to avoid PIL errors with fake image data
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
)
BaseScreenshot.cache = MockCache()
return get_screenshot
def test_cache_error_status_when_screenshot_fails(
self, mocker: MockerFixture, screenshot_obj, mock_user
):
"""Test that error status is cached when screenshot generation fails."""
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
get_screenshot = mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
side_effect=Exception("Screenshot failed"),
)
BaseScreenshot.cache = MockCache()
# Execute compute_and_cache
screenshot_obj.compute_and_cache(user=mock_user, force=True)
# Verify get_screenshot was called
get_screenshot.assert_called_once()
# Cache should be set with ERROR status (to prevent immediate retries)
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
def test_cache_error_status_when_resize_fails(
self, mocker: MockerFixture, screenshot_obj, mock_user
):
"""Test that error status is cached when image resize fails."""
self._setup_mocks(mocker, screenshot_obj)
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image",
side_effect=Exception("Resize failed"),
)
# Use different window and thumb sizes to trigger resize
screenshot_obj.compute_and_cache(
user=mock_user, force=True, window_size=(800, 600), thumb_size=(400, 300)
)
# Cache should be set with ERROR status (to prevent immediate retries)
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
def test_cache_saved_only_when_image_generated(
self, mocker: MockerFixture, screenshot_obj, mock_user
):
"""Test that cache is only saved when image is successfully generated."""
self._setup_mocks(mocker, screenshot_obj)
# Execute compute_and_cache
screenshot_obj.compute_and_cache(user=mock_user, force=True)
# Cache should be set with UPDATED status and image
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Updated"
assert cached_value["image"] is not None
def test_no_intermediate_cache_during_computing(
self, mocker: MockerFixture, screenshot_obj, mock_user
):
"""Test that cache is not saved during COMPUTING state."""
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
BaseScreenshot.cache = MockCache()
# Mock get_screenshot to check cache state during execution
def check_cache_during_screenshot(*args, **kwargs):
# At this point, we're in COMPUTING state
# Cache should NOT be set yet
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
# Cache should be empty during screenshot generation
assert cached_value is None, (
"Cache should not be saved during COMPUTING state"
)
return b"image_data"
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
side_effect=check_cache_during_screenshot,
)
# Mock resize to avoid PIL errors with fake image data
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
)
# Execute compute_and_cache
screenshot_obj.compute_and_cache(user=mock_user, force=True)
# After completion, cache should be set with UPDATED status
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Updated"
class TestShouldTriggerTask:
"""Test the should_trigger_task method improvements."""
@patch("superset.utils.screenshots.app")
def test_trigger_on_stale_computing_status(self, mock_app):
"""Test that stale COMPUTING status triggers recomputation."""
# Set TTL to 300 seconds
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Create payload with COMPUTING status from 400 seconds ago (stale)
old_timestamp = (datetime.now() - timedelta(seconds=400)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=old_timestamp
)
# Should trigger task because COMPUTING is stale
assert payload.should_trigger_task(force=False) is True
@patch("superset.utils.screenshots.app")
def test_no_trigger_on_fresh_computing_status(self, mock_app):
"""Test that fresh COMPUTING status does not trigger recomputation."""
# Set TTL to 300 seconds
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Create payload with COMPUTING status from 100 seconds ago (fresh)
fresh_timestamp = (datetime.now() - timedelta(seconds=100)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=fresh_timestamp
)
# Should NOT trigger task because COMPUTING is still fresh
assert payload.should_trigger_task(force=False) is False
def test_trigger_on_updated_without_image(self):
"""Test that UPDATED status without image triggers recomputation."""
# Create payload with UPDATED status but no image
# This simulates the bug where cache was saved without an image
payload = ScreenshotCachePayload(image=None, status=StatusValues.UPDATED)
# Should trigger task because UPDATED but has no image
assert payload.should_trigger_task(force=False) is True
def test_no_trigger_on_updated_with_image(self):
"""Test that UPDATED status with image does not trigger recomputation."""
# Create payload with UPDATED status and valid image
payload = ScreenshotCachePayload(image=b"valid_image_data")
# Should NOT trigger task because UPDATED with valid image
assert payload.should_trigger_task(force=False) is False
def test_trigger_on_pending_status(self):
"""Test that PENDING status triggers task."""
payload = ScreenshotCachePayload(status=StatusValues.PENDING)
assert payload.should_trigger_task(force=False) is True
@patch("superset.utils.screenshots.app")
def test_trigger_on_expired_error(self, mock_app):
"""Test that expired ERROR status triggers task."""
# Set TTL to 300 seconds
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Create payload with ERROR status from 400 seconds ago (expired)
old_timestamp = (datetime.now() - timedelta(seconds=400)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.ERROR, timestamp=old_timestamp
)
assert payload.should_trigger_task(force=False) is True
@patch("superset.utils.screenshots.app")
def test_no_trigger_on_fresh_error(self, mock_app):
"""Test that fresh ERROR status does not trigger task."""
# Set TTL to 300 seconds
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Create payload with ERROR status from 100 seconds ago (fresh)
fresh_timestamp = (datetime.now() - timedelta(seconds=100)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.ERROR, timestamp=fresh_timestamp
)
assert payload.should_trigger_task(force=False) is False
def test_force_always_triggers(self):
"""Test that force=True always triggers task regardless of status."""
# Test with UPDATED + image (normally wouldn't trigger)
payload_updated = ScreenshotCachePayload(image=b"image_data")
assert payload_updated.should_trigger_task(force=True) is True
# Test with fresh COMPUTING (normally wouldn't trigger)
payload_computing = ScreenshotCachePayload(status=StatusValues.COMPUTING)
assert payload_computing.should_trigger_task(force=True) is True
class TestIsComputingStale:
"""Test the is_computing_stale method."""
@patch("superset.utils.screenshots.app")
def test_computing_is_stale(self, mock_app):
"""Test that old COMPUTING status is detected as stale."""
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Timestamp from 400 seconds ago
old_timestamp = (datetime.now() - timedelta(seconds=400)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=old_timestamp
)
assert payload.is_computing_stale() is True
@patch("superset.utils.screenshots.app")
def test_computing_is_not_stale(self, mock_app):
"""Test that fresh COMPUTING status is not stale."""
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Timestamp from 100 seconds ago
fresh_timestamp = (datetime.now() - timedelta(seconds=100)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=fresh_timestamp
)
assert payload.is_computing_stale() is False
@patch("superset.utils.screenshots.app")
def test_computing_exactly_at_ttl(self, mock_app):
"""Test boundary condition at exactly TTL."""
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Timestamp from exactly 300 seconds ago
exact_timestamp = (datetime.now() - timedelta(seconds=300)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=exact_timestamp
)
# At exactly TTL, should be stale (>= TTL)
assert payload.is_computing_stale() is True
@patch("superset.utils.screenshots.app")
def test_computing_just_past_ttl(self, mock_app):
"""Test boundary condition just past TTL."""
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
# Timestamp from 301 seconds ago (just past TTL)
past_ttl_timestamp = (datetime.now() - timedelta(seconds=301)).isoformat()
payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=past_ttl_timestamp
)
# Just past TTL should be stale
assert payload.is_computing_stale() is True
class TestIntegrationCacheBugFix:
"""Integration tests combining both fixes."""
def test_failed_screenshot_does_not_pollute_cache(
self, mocker: MockerFixture, screenshot_obj, mock_user
):
"""
Integration test: Failed screenshot should cache error status
to prevent immediate retries, not leave corrupted cache with image=None.
"""
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
side_effect=Exception("Network error"),
)
BaseScreenshot.cache = MockCache()
# First attempt fails
screenshot_obj.compute_and_cache(user=mock_user, force=True)
# Verify cache contains ERROR status (prevents immediate retry)
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
# Cache entry should not trigger task immediately (error is fresh)
cached_payload = screenshot_obj.get_from_cache_key(cache_key)
assert cached_payload is not None
assert cached_payload.should_trigger_task(force=False) is False
@patch("superset.utils.screenshots.app")
def test_stale_computing_triggers_retry(
self, mock_app, mocker: MockerFixture, screenshot_obj, mock_user
):
"""
Integration test: Stale COMPUTING status should trigger retry
to recover from stuck tasks.
"""
mock_app.config = {"THUMBNAIL_ERROR_CACHE_TTL": 300}
BaseScreenshot.cache = MockCache()
# Create stale COMPUTING entry and seed it in the cache
old_timestamp = (datetime.now() - timedelta(seconds=400)).isoformat()
stale_payload = ScreenshotCachePayload(
status=StatusValues.COMPUTING, timestamp=old_timestamp
)
cache_key = screenshot_obj.get_cache_key()
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image"
)
# Mock resize to avoid PIL errors
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image"
)
# Should trigger task because COMPUTING is stale
assert stale_payload.should_trigger_task() is True
# Retry should succeed and update cache
screenshot_obj.compute_and_cache(user=mock_user, force=False)
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Updated"
assert cached_value["image"] is not None