eschutho commented on code in PR #42624:
URL: https://github.com/apache/superset/pull/42624#discussion_r3694422960


##########
superset/utils/webdriver.py:
##########
@@ -380,11 +440,15 @@ def _wait_for_charts_ready(
             elapsed,
             context_suffix,
         )
-        readiness_predicate = (
-            CHART_CONTAINER_READY_JS
-            if element_name == "chart-container"
-            else CHART_HOLDERS_READY_JS
-        )
+        if element_name == "chart-container":
+            readiness_predicate = CHART_CONTAINER_READY_JS
+        elif report_execution_context:
+            readiness_predicate = REPORT_CHART_HOLDERS_READY_JS

Review Comment:
   Real trade-off, deliberately carried over rather than introduced here — the 
viewport-scoped predicate came from #42153/#42253's production-safe readiness 
work, because requiring below-the-fold holders deadlocks on virtualized 
dashboards (off-screen holders never render by design). Full-dashboard 
readiness is exactly what the tiled path provides: it scrolls tile by tile and 
validates each viewport before capture, and the thresholds 
(`SCREENSHOT_TILED_CHART_THRESHOLD`, `SCREENSHOT_TILED_HEIGHT_THRESHOLD`) route 
chart-heavy/tall dashboards there. The residual window is a dashboard taller 
than the browser window but under both tiling thresholds; operators can close 
it by lowering `SCREENSHOT_TILED_HEIGHT_THRESHOLD` toward the viewport height. 
Auto-tiling whenever element height exceeds the viewport is a reasonable future 
tightening, but it changes capture behavior for a class of currently-working 
dashboards, so it's out of scope for this PR.



##########
superset/commands/report/execute.py:
##########
@@ -123,6 +131,137 @@ def resolve_executor_user(model: ReportSchedule) -> 
tuple["User", str]:
     return user, username
 
 
+def log_report_delivery_phase(
+    report_context: ReportExecutionContext | None,
+    recipient_type: ReportRecipientType | None,
+    phase: str,
+    *,
+    enforce_budget: bool,
+) -> None:
+    """Enforce and log a notification phase when executing a report."""
+
+    if report_context is None:
+        return
+    deadline = report_context.deadline
+    if enforce_budget:
+        deadline.timeout_seconds(
+            "notification_delivery",
+            reserve_seconds=report_context.cleanup_reserve_seconds,
+        )
+    logger.info(
+        "report_delivery_%s %s recipient_type=%s elapsed_seconds=%.2f "
+        "remaining_seconds=%.2f",
+        phase,
+        report_context.log_context,
+        recipient_type,
+        deadline.elapsed_seconds,
+        deadline.remaining_seconds,
+    )
+
+
+def persist_owned_report_execution_terminal_error(
+    report_schedule_id: int,
+    execution_id: UUID,
+    error_message: str,
+    terminal_reason: str,
+    report_context: ReportExecutionContext | None = None,
+) -> bool:
+    """
+    Terminalize this command's WORKING row from its application-owned boundary.
+
+    Report states normally persist their terminal result before re-raising. If
+    that first write loses its transaction or database connection, the command
+    boundary is the last safe in-process retry: it still has Flask application
+    context and knows the execution UUID it owns. A compare against the latest
+    active WORKING row prevents an old worker from changing the schedule state
+    after a newer execution has started.
+    """
+
+    try:
+        # The state-machine transaction has already rolled back on its way to
+        # this boundary. Roll back again so a failed terminal flush cannot 
leave
+        # the scoped session unusable for the retry.
+        db.session.rollback()  # pylint: disable=consider-using-transaction
+        working_log = (
+            db.session.query(ReportExecutionLog)
+            .filter(
+                ReportExecutionLog.report_schedule_id == report_schedule_id,
+                ReportExecutionLog.uuid == execution_id,
+                ReportExecutionLog.state == ReportState.WORKING,
+                ReportExecutionLog.error_message.is_(None),
+            )
+            .first()
+        )
+        if working_log is None:
+            return False
+
+        latest_working_log = (
+            db.session.query(ReportExecutionLog)
+            .filter(
+                ReportExecutionLog.report_schedule_id == report_schedule_id,
+                ReportExecutionLog.state == ReportState.WORKING,
+                ReportExecutionLog.error_message.is_(None),
+            )
+            .order_by(ReportExecutionLog.end_dttm.desc())
+            .first()
+        )
+        report_schedule = working_log.report_schedule
+        owns_schedule_state = (
+            report_schedule.last_state == ReportState.WORKING
+            and latest_working_log is not None
+            and latest_working_log.uuid == execution_id
+        )
+        ended_at = datetime.now(timezone.utc).replace(tzinfo=None)
+        working_log.state = ReportState.ERROR
+        working_log.error_message = error_message
+        working_log.end_dttm = ended_at
+        if owns_schedule_state:
+            report_schedule.last_state = ReportState.ERROR
+            report_schedule.last_eval_dttm = ended_at
+
+        db.session.commit()  # pylint: disable=consider-using-transaction

Review Comment:
   Same finding as the thread on line 213 (and items 1–2 of the human review 
above): agreed the compare-then-commit isn't atomic, and a real fix needs a 
unique constraint on `ReportExecutionLog.uuid` plus a guarded/locked write — 
schema work that's scoped as a tracked follow-up. The compare here is strictly 
stronger than pre-PR master, which wrote terminal state with no ownership check 
at all.



##########
superset/commands/report/execute.py:
##########
@@ -1472,11 +1808,62 @@ def __init__(self, task_id: str, model_id: int, 
scheduled_dttm: datetime):
         self._execution_id = UUID(task_id)
 
     def run(self) -> None:
+        monotonic_started_at = time.monotonic()
+        report_execution_context: ReportExecutionContext | None = None
+        owns_report_working_state = False
         try:
             self.validate()
             if not self._model:
                 raise ReportScheduleExecuteUnexpectedError()
 
+            if self._model.type == ReportScheduleType.REPORT:
+                # An invocation that enters on WORKING is a duplicate or stale
+                # recovery, not the owner that created the active row. Its 
state
+                # handler may terminalize a stale execution, but the command
+                # boundary must never infer ownership from a replayed UUID.
+                owns_report_working_state = (
+                    self._model.last_state != ReportState.WORKING
+                )
+                total_seconds = resolve_report_execution_budget_seconds(
+                    app.config,
+                    working_timeout=self._model.working_timeout,
+                )

Review Comment:
   Same as the resolver thread in report_execution.py (a review instruction was 
saved there): the floor is intentional and documented in UPDATING.md, with a 
runtime warning when it engages. Strictly honoring a sub-viable working_timeout 
(one below capture+delivery+cleanup reserves) means that schedule fails 
deterministically on every run — pre-PR it was killed mid-capture and never 
delivered anything. The floor converts a permanently-broken configuration into 
a working one; rejecting such configs at save time is a fair alternative but is 
an API/validation change beyond this PR.



##########
superset/tasks/scheduler.py:
##########
@@ -98,19 +100,14 @@ def scheduler(self: Task) -> None:  # pylint: 
disable=unused-argument
             triggered_at, active_schedule.crontab, active_schedule.timezone
         ):
             logger.info("Scheduling alert %s eta: %s", active_schedule.name, 
schedule)
-            async_options = {"eta": schedule}
-            if (
-                active_schedule.working_timeout is not None
-                and current_app.config["ALERT_REPORTS_WORKING_TIME_OUT_KILL"]
-            ):
-                async_options["time_limit"] = (
-                    active_schedule.working_timeout
-                    + current_app.config["ALERT_REPORTS_WORKING_TIME_OUT_LAG"]
-                )
-                async_options["soft_time_limit"] = (
-                    active_schedule.working_timeout
-                    + 
current_app.config["ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG"]
-                )
+            async_options = {
+                "eta": schedule,
+                **get_report_task_timeout_options(
+                    is_report=active_schedule.type == 
ReportScheduleType.REPORT,
+                    working_timeout=active_schedule.working_timeout,
+                    config=current_app.config,
+                ),
+            }
             execute.apply_async((active_schedule.id,), **async_options)

Review Comment:
   Duplicate of the resolver-floor finding (see the report_execution.py thread) 
— same intentional behavior, same UPDATING.md documentation, evaluated once in 
`resolve_report_execution_budget_seconds` and consumed consistently by both the 
Celery limit derivation here and the in-process deadline, so the two never 
disagree.



##########
superset/utils/screenshot_utils.py:
##########
@@ -406,16 +515,12 @@ def _raise_if_budget_exhausted(elapsed: float, 
remaining_budget: float) -> None:
             # Wait for scroll to settle and content to load
             page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
 
-            # Recompute the remaining budget after the scroll-settle sleep --
-            # which itself consumes real wall-clock time -- rather than
-            # reusing the value from before it, so the readiness-check
-            # timeout below is capped against a fresh number instead of a
-            # stale one that would let each tile overrun the budget by up
-            # to one settle interval.
-            tile_wait_start = time.monotonic()
-            elapsed = tile_wait_start - start_time
-            remaining_budget = wait_budget_seconds - elapsed
-            _raise_if_budget_exhausted(elapsed, remaining_budget)
+            # Re-check after the scroll-settle sleep -- which itself consumes
+            # real wall-clock time -- so the readiness-check timeout below is
+            # derived from a fresh remaining value instead of a stale one
+            # that would let each tile overrun the budget by up to one settle
+            # interval (_timeout_seconds also recomputes at call time).
+            _raise_if_budget_exhausted()

Review Comment:
   The premise isn't quite right: there's a budget check immediately *before* 
the scroll (`_raise_if_budget_exhausted()` above the `scrollTo`), and a second 
check immediately *after* the settle sleep — added precisely so the per-tile 
readiness timeout derives from a fresh remaining value (see the comment above 
that second check). The worst case is one settle interval (1s) of overshoot 
past the deadline before the re-check raises, which is absorbed by the 30s 
cleanup reserve plus the 30s Celery hard grace. Gating the sleep itself on 
remaining time would save at most that 1s in an execution that is already 
failing.



##########
superset/commands/report/execute.py:
##########
@@ -123,6 +131,137 @@ def resolve_executor_user(model: ReportSchedule) -> 
tuple["User", str]:
     return user, username
 
 
+def log_report_delivery_phase(
+    report_context: ReportExecutionContext | None,
+    recipient_type: ReportRecipientType | None,
+    phase: str,
+    *,
+    enforce_budget: bool,
+) -> None:
+    """Enforce and log a notification phase when executing a report."""
+
+    if report_context is None:
+        return
+    deadline = report_context.deadline
+    if enforce_budget:
+        deadline.timeout_seconds(
+            "notification_delivery",
+            reserve_seconds=report_context.cleanup_reserve_seconds,
+        )
+    logger.info(
+        "report_delivery_%s %s recipient_type=%s elapsed_seconds=%.2f "
+        "remaining_seconds=%.2f",
+        phase,
+        report_context.log_context,
+        recipient_type,
+        deadline.elapsed_seconds,
+        deadline.remaining_seconds,
+    )
+
+

Review Comment:
   Added in `7e2010fe0d`: three unit tests covering the no-context no-op, the 
enforce_budget=True raise on an exhausted budget, and the enforce_budget=False 
post-send logging path (which must record the phase rather than raise 
mid-notification).



##########
superset/commands/report/execute.py:
##########
@@ -123,6 +131,137 @@ def resolve_executor_user(model: ReportSchedule) -> 
tuple["User", str]:
     return user, username
 
 
+def log_report_delivery_phase(
+    report_context: ReportExecutionContext | None,
+    recipient_type: ReportRecipientType | None,
+    phase: str,
+    *,
+    enforce_budget: bool,
+) -> None:
+    """Enforce and log a notification phase when executing a report."""
+
+    if report_context is None:
+        return
+    deadline = report_context.deadline
+    if enforce_budget:
+        deadline.timeout_seconds(
+            "notification_delivery",
+            reserve_seconds=report_context.cleanup_reserve_seconds,
+        )
+    logger.info(
+        "report_delivery_%s %s recipient_type=%s elapsed_seconds=%.2f "
+        "remaining_seconds=%.2f",
+        phase,
+        report_context.log_context,
+        recipient_type,
+        deadline.elapsed_seconds,
+        deadline.remaining_seconds,
+    )
+
+
+def persist_owned_report_execution_terminal_error(
+    report_schedule_id: int,
+    execution_id: UUID,
+    error_message: str,
+    terminal_reason: str,
+    report_context: ReportExecutionContext | None = None,
+) -> bool:
+    """
+    Terminalize this command's WORKING row from its application-owned boundary.
+
+    Report states normally persist their terminal result before re-raising. If
+    that first write loses its transaction or database connection, the command
+    boundary is the last safe in-process retry: it still has Flask application
+    context and knows the execution UUID it owns. A compare against the latest
+    active WORKING row prevents an old worker from changing the schedule state
+    after a newer execution has started.
+    """
+
+    try:
+        # The state-machine transaction has already rolled back on its way to
+        # this boundary. Roll back again so a failed terminal flush cannot 
leave
+        # the scoped session unusable for the retry.
+        db.session.rollback()  # pylint: disable=consider-using-transaction
+        working_log = (
+            db.session.query(ReportExecutionLog)
+            .filter(
+                ReportExecutionLog.report_schedule_id == report_schedule_id,
+                ReportExecutionLog.uuid == execution_id,
+                ReportExecutionLog.state == ReportState.WORKING,
+                ReportExecutionLog.error_message.is_(None),
+            )
+            .first()
+        )
+        if working_log is None:
+            return False
+
+        latest_working_log = (
+            db.session.query(ReportExecutionLog)
+            .filter(
+                ReportExecutionLog.report_schedule_id == report_schedule_id,
+                ReportExecutionLog.state == ReportState.WORKING,
+                ReportExecutionLog.error_message.is_(None),
+            )
+            .order_by(ReportExecutionLog.end_dttm.desc())
+            .first()
+        )
+        report_schedule = working_log.report_schedule
+        owns_schedule_state = (
+            report_schedule.last_state == ReportState.WORKING
+            and latest_working_log is not None
+            and latest_working_log.uuid == execution_id
+        )
+        ended_at = datetime.now(timezone.utc).replace(tzinfo=None)
+        working_log.state = ReportState.ERROR
+        working_log.error_message = error_message
+        working_log.end_dttm = ended_at
+        if owns_schedule_state:
+            report_schedule.last_state = ReportState.ERROR
+            report_schedule.last_eval_dttm = ended_at
+
+        db.session.commit()  # pylint: disable=consider-using-transaction
+        log_context = (
+            report_context.log_context
+            if report_context is not None
+            else (
+                f"capture_kind=report execution_id={execution_id} "
+                f"report_schedule_id={report_schedule_id} "
+                f"dashboard_id={report_schedule.dashboard_id} "
+                f"chart_id={report_schedule.chart_id}"
+            )
+        )
+        elapsed_seconds = (
+            f"{report_context.deadline.elapsed_seconds:.2f}"
+            if report_context is not None
+            else "unknown"
+        )
+        remaining_seconds = (
+            f"{report_context.deadline.remaining_seconds:.2f}"
+            if report_context is not None
+            else "unknown"
+        )
+        logger.info(
+            "report_execution_terminal %s state=%s terminal_reason=%s "
+            "elapsed_seconds=%s remaining_seconds=%s",
+            log_context,
+            ReportState.ERROR.value,
+            terminal_reason,
+            elapsed_seconds,
+            remaining_seconds,
+        )
+        return True
+    except Exception:  # noqa: BLE001  # never mask the report's original 
exception
+        db.session.rollback()  # pylint: disable=consider-using-transaction
+        logger.exception(
+            "Failed terminal persistence retry for report execution "
+            "capture_kind=report execution_id=%s report_schedule_id=%s "
+            "terminal_reason=terminal_persistence_retry_failed",
+            execution_id,
+            report_schedule_id,
+        )
+        return False

Review Comment:
   Added in `7e2010fe0d`: 
`test_terminal_persistence_retry_survives_database_failure` makes the session 
query raise, and asserts the double rollback (entry + handler), the 
`terminal_persistence_retry_failed` exception log, no commit, and the `False` 
return that keeps the report's original exception unmasked.



##########
superset/utils/webdriver.py:
##########
@@ -982,57 +1247,193 @@ def get_screenshot(  # noqa: C901
                 logger.debug(
                     "Wait for the presence of %s at url: %s", element_name, url
                 )
-                element = WebDriverWait(driver, 
self._screenshot_locate_wait).until(
-                    EC.presence_of_element_located((By.CLASS_NAME, 
element_name))
-                )
+                element = WebDriverWait(
+                    driver,
+                    phase_timeout(
+                        "dashboard_mount",
+                        self._screenshot_locate_wait,
+                        (
+                            report_execution_context.readiness_reserve_seconds
+                            if report_execution_context
+                            else 0.0
+                        ),
+                    ),
+                ).until(EC.presence_of_element_located((By.CLASS_NAME, 
element_name)))
             except TimeoutException:
                 logger.warning(
                     "Selenium timed out requesting url %s", url, exc_info=True
                 )
                 raise
 
-            try:
-                # chart containers didn't render
-                logger.debug("Wait for chart containers to draw at url: %s", 
url)
-                WebDriverWait(driver, self._screenshot_locate_wait).until(
-                    EC.visibility_of_all_elements_located(
-                        (By.CLASS_NAME, "chart-container")
-                    )
+            if report_execution_context and element_name in {
+                "standalone",
+                "chart-container",
+            }:
+                readiness_predicate = (
+                    REPORT_CHART_HOLDERS_READY_JS
+                    if element_name == "standalone"
+                    else CHART_CONTAINER_READY_JS
+                )
+                readiness_timeout = phase_timeout(
+                    "chart_readiness",
+                    None,
+                    report_execution_context.readiness_reserve_seconds,
                 )
-            except TimeoutException:
-                logger.info("Timeout Exception caught")
-                # Fallback to allow a screenshot of an empty dashboard
                 try:
-                    WebDriverWait(driver, 0).until(
+                    WebDriverWait(driver, readiness_timeout).until(
+                        lambda webdriver: webdriver.execute_script(
+                            f"return ({readiness_predicate})()"
+                        )
+                    )
+                    holder_states = (
+                        driver.execute_script(
+                            f"return ({FIND_CHART_HOLDER_STATES_JS})()"
+                        )
+                        if element_name == "standalone"
+                        else [
+                            {
+                                "chartId": report_execution_context.chart_id,
+                                "state": "rendered",
+                            }
+                        ]
+                    )
+                    ready_states = {"rendered", "empty", "error", 
"virtualized"}
+                    deadline = report_execution_context.deadline
+                    logger.info(
+                        "report_readiness_ready url=%s expected_holders=%s "
+                        "mounted_holders=%s ready_holders=%s 
elapsed_seconds=%s "
+                        "remaining_seconds=%s%s",
+                        url,
+                        report_execution_context.expected_chart_count,
+                        len(holder_states),
+                        sum(
+                            holder.get("state") in ready_states
+                            for holder in holder_states
+                        ),
+                        (f"{deadline.elapsed_seconds:.2f}" if deadline else 
None),

Review Comment:
   Fixed in `7e2010fe0d` — both this and the second occurrence at line 1345 
removed; `deadline` is assigned unconditionally there and 
`ReportExecutionDeadline` is always truthy.



##########
superset/utils/webdriver.py:
##########
@@ -982,57 +1247,193 @@ def get_screenshot(  # noqa: C901
                 logger.debug(
                     "Wait for the presence of %s at url: %s", element_name, url
                 )
-                element = WebDriverWait(driver, 
self._screenshot_locate_wait).until(
-                    EC.presence_of_element_located((By.CLASS_NAME, 
element_name))
-                )
+                element = WebDriverWait(
+                    driver,
+                    phase_timeout(
+                        "dashboard_mount",
+                        self._screenshot_locate_wait,
+                        (
+                            report_execution_context.readiness_reserve_seconds
+                            if report_execution_context
+                            else 0.0
+                        ),
+                    ),
+                ).until(EC.presence_of_element_located((By.CLASS_NAME, 
element_name)))
             except TimeoutException:
                 logger.warning(
                     "Selenium timed out requesting url %s", url, exc_info=True
                 )
                 raise
 
-            try:
-                # chart containers didn't render
-                logger.debug("Wait for chart containers to draw at url: %s", 
url)
-                WebDriverWait(driver, self._screenshot_locate_wait).until(
-                    EC.visibility_of_all_elements_located(
-                        (By.CLASS_NAME, "chart-container")
-                    )
+            if report_execution_context and element_name in {
+                "standalone",
+                "chart-container",
+            }:
+                readiness_predicate = (
+                    REPORT_CHART_HOLDERS_READY_JS
+                    if element_name == "standalone"
+                    else CHART_CONTAINER_READY_JS
+                )
+                readiness_timeout = phase_timeout(
+                    "chart_readiness",
+                    None,
+                    report_execution_context.readiness_reserve_seconds,
                 )
-            except TimeoutException:
-                logger.info("Timeout Exception caught")
-                # Fallback to allow a screenshot of an empty dashboard
                 try:
-                    WebDriverWait(driver, 0).until(
+                    WebDriverWait(driver, readiness_timeout).until(
+                        lambda webdriver: webdriver.execute_script(
+                            f"return ({readiness_predicate})()"
+                        )
+                    )
+                    holder_states = (
+                        driver.execute_script(
+                            f"return ({FIND_CHART_HOLDER_STATES_JS})()"
+                        )
+                        if element_name == "standalone"
+                        else [
+                            {
+                                "chartId": report_execution_context.chart_id,
+                                "state": "rendered",
+                            }
+                        ]
+                    )
+                    ready_states = {"rendered", "empty", "error", 
"virtualized"}
+                    deadline = report_execution_context.deadline
+                    logger.info(
+                        "report_readiness_ready url=%s expected_holders=%s "
+                        "mounted_holders=%s ready_holders=%s 
elapsed_seconds=%s "
+                        "remaining_seconds=%s%s",
+                        url,
+                        report_execution_context.expected_chart_count,
+                        len(holder_states),
+                        sum(
+                            holder.get("state") in ready_states
+                            for holder in holder_states
+                        ),
+                        (f"{deadline.elapsed_seconds:.2f}" if deadline else 
None),
+                        (f"{deadline.remaining_seconds:.2f}" if deadline else 
None),
+                        f" [{log_context}]" if log_context else "",
+                    )
+                except TimeoutException:
+                    holder_states = (
+                        driver.execute_script(
+                            f"return ({FIND_CHART_HOLDER_STATES_JS})()"
+                        )
+                        if element_name == "standalone"
+                        else [
+                            {
+                                "chartId": report_execution_context.chart_id,
+                                "state": "not_ready",
+                            }
+                        ]
+                    )
+                    ready_states = {"rendered", "empty", "error", 
"virtualized"}
+                    ready_holders = sum(
+                        holder.get("state") in ready_states for holder in 
holder_states
+                    )
+                    deadline = report_execution_context.deadline
+                    logger.warning(
+                        "report_readiness_terminal url=%s expected_holders=%s "
+                        "mounted_holders=%s ready_holders=%s 
elapsed_seconds=%s "
+                        "remaining_seconds=%s effective_wait_seconds=%.2f%s "
+                        "terminal_reason=readiness_timeout states=%s; "
+                        "aborting before capture or delivery",
+                        url,
+                        report_execution_context.expected_chart_count,
+                        len(holder_states),
+                        ready_holders,
+                        (f"{deadline.elapsed_seconds:.2f}" if deadline else 
None),

Review Comment:
   Fixed in `7e2010fe0d` (same commit as the sibling at line 1313).



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