From a6541d725e5c61d66bd317b1e1be47e8ab547f10 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 9 Feb 2026 10:43:29 +0800 Subject: [PATCH] Update cron job overview to opt to skip JOIN depending on query cost (#42492) ## Context The Integrations -> Cron overview page renders a list of cron jobs by doing a join on both `cron.jobs` and `cron.job_run_details` in order to pull the data for "Latest run" and "Status" ("Next run" is just derived from "Latest Run" and the job's schedule) In the past, we've had users run into issues where this query would time out, likely due to the join on `job_run_details` especially if that table is really big (and the columns on that table aren't indexed), and our solution was to inform users to clear out that table + schedule a job to clean the `job_run_details` table on a certain cadence. This CTA however blocks the UI from rendering the cron jobs overview entirely image ## Changes involved Am opting to use the new preflight check introduced [here](https://github.com/supabase/supabase/pulls?q=is%3Apr+author%3Ajoshenlim+is%3Aclosed) - Attempt to fetch the data for the cron jobs overview normally - If preflight check rejects (due to high cost), then opt to fetch the data while omitting the join - Show a banner instead to inform the user accordingly - So that way users can still view their cron jobs (the latest run column is a nice to have convenience really) image - Clicking learn more will then open a dialog to explain what this is all about - The steps to clear the `job_run_details` table + schedule a clean up job still remains image Screenshot 2026-02-05 at 12 56 39 Screenshot 2026-02-05 at 13 01 37 - Non-UI related changes include - Automatically refresh cron jobs after scheduling a job, remove manual CTA to refresh the jobs after scheduling the clean up job ## Summary by CodeRabbit * **New Features** * Previous Runs tab on cron job pages * Interactive "Learn more" cleanup dialog with guided delete and schedule steps * Close-confirmation modal when editing cron jobs * High-cost banner that enables a lightweight "minimal" list mode * **Bug Fixes** * Clearer placeholder (minus icon/message) when a job has no runs * **Performance** * Safer, more efficient fetching and pagination for large cron job lists; reduced retries on high-cost queries * **Tests** * Expanded end-to-end tests covering high-cost flows and the cleanup dialog --- .../Integrations/CronJobs/CronJobPage.tsx | 15 +- .../CronJobs/CronJobTableCell.tsx | 7 +- .../CronJobs/CronJobsTab.CleanupNotice.tsx | 290 ++++++++++-------- .../CronJobs/CronJobsTab.DataGrid.tsx | 10 +- .../Integrations/CronJobs/CronJobsTab.tsx | 97 +----- .../CronJobs/CronJobsTab.useCleanupActions.ts | 55 ++-- .../CronJobs/CronJobsTab.useCronJobsData.ts | 216 +++---------- ...ase-cron-job-run-details-estimate-query.ts | 67 ---- .../database-cron-jobs-infinite-query.ts | 59 +--- ...tabase-cron-jobs-minimal-infinite-query.ts | 67 ++++ apps/studio/data/database-cron-jobs/keys.ts | 2 + .../schedule-clean-up-mutation.ts | 66 ++++ apps/studio/data/sql/queries/get-cron-jobs.ts | 72 +++++ .../hooks/misc/useCronJobsEstimatePrefetch.ts | 29 -- .../[id]/[pageId]/[childId]/index.tsx | 8 +- .../integrations/[id]/[pageId]/index.tsx | 10 +- e2e/studio/features/cron-jobs.spec.ts | 185 +++++++++-- 17 files changed, 644 insertions(+), 611 deletions(-) delete mode 100644 apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts create mode 100644 apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts create mode 100644 apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts create mode 100644 apps/studio/data/sql/queries/get-cron-jobs.ts delete mode 100644 apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx index 20bd50c3d0f..11f05811907 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx @@ -1,14 +1,13 @@ +import { useParams } from 'common' import { toString as CronToString } from 'cronstrue' +import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { type ConfirmOnCloseModalProps, useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Edit3, List } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' - -import { useParams } from 'common' -import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' -import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { BreadcrumbItem_Shadcn_ as BreadcrumbItem, BreadcrumbLink_Shadcn_ as BreadcrumbLink, @@ -16,13 +15,13 @@ import { BreadcrumbPage_Shadcn_ as BreadcrumbPage, BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator, Button, - cn, CodeBlock, Sheet, SheetContent, Tooltip, TooltipContent, TooltipTrigger, + cn, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { @@ -35,6 +34,7 @@ import { PageHeaderTitle, } from 'ui-patterns/PageHeader' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { CreateCronJobSheet } from './CreateCronJobSheet/CreateCronJobSheet' import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils' import { PreviousRunsTab } from './PreviousRunsTab' @@ -189,6 +189,7 @@ export const CronJobPage = () => { {secondaryActions.length > 0 && {secondaryActions}} + diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx index 8883ad7c2db..77b488e4a19 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -1,6 +1,6 @@ import parser from 'cron-parser' import dayjs from 'dayjs' -import { Copy, Edit, MoreVertical, Play, Trash } from 'lucide-react' +import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' import { useState } from 'react' import { toast } from 'sonner' @@ -90,6 +90,7 @@ export const CronJobTableCell = ({ const [showToggleModal, setShowToggleModal] = useState(false) const value = row?.[col.id] + const hasValue = col.id in row const { jobid, schedule, latest_run, status, active, jobname } = row const formattedValue = @@ -241,7 +242,9 @@ export const CronJobTableCell = ({
{['latest_run', 'next_run'].includes(col.id) ? ( - col.id === 'latest_run' && formattedValue === null ? ( + !hasValue ? ( + + ) : col.id === 'latest_run' && formattedValue === null ? (

Job has not been run yet

) : col.id === 'next_run' && !formattedValue ? (

Unable to parse next run for job

diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx index 5f2f8efdf77..cb31fc6d2e0 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx @@ -1,8 +1,15 @@ -import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react' - +import { getScheduleDeleteCronJobRunDetailsSql } from 'data/sql/queries/delete-cron-job-run-details' +import { CheckCircle2, XCircle } from 'lucide-react' import { Button, CodeBlock, + Dialog, + DialogContent, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, Progress, Select_Shadcn_, SelectContent_Shadcn_, @@ -12,138 +19,178 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' -import { getScheduleDeleteCronJobRunDetailsSql } from 'data/sql/queries/delete-cron-job-run-details' import { CLEANUP_INTERVALS } from './CronJobsTab.constants' -import type { BatchDeletionProgress, CleanupState } from './CronJobsTab.useCleanupActions' +import { + useCronJobsCleanupActions, + type BatchDeletionProgress, +} from './CronJobsTab.useCleanupActions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -export interface CronJobRunDetailsOverflowNoticeProps { - estimatedRows?: number - mode: 'confirmed' | 'suspected' - cleanupState: CleanupState - selectedInterval: string - onIntervalChange: (interval: string) => void - onRunDeleteSql: () => void - onRunScheduleSql: () => void - onCancelDeletion: () => void - onRetryDeletion: () => void - onRefresh: () => void +interface CronJobRunDetailsOverflowNoticeV2Props { + refetchJobs: () => void } -export const CronJobRunDetailsOverflowNotice = ({ - estimatedRows, - mode, - cleanupState, - selectedInterval, - onIntervalChange, - onRunDeleteSql, - onRunScheduleSql, - onCancelDeletion, - onRetryDeletion, - onRefresh, -}: CronJobRunDetailsOverflowNoticeProps) => { - const formattedRowEstimate = - typeof estimatedRows === 'number' ? estimatedRows.toLocaleString() : 'unknown' - const noticeTitle = - mode === 'confirmed' - ? 'cron.job_run_details is too large to load' - : 'Cron job overview timed out' - const noticeDescription = - mode === 'confirmed' - ? `We detected approximately ${formattedRowEstimate} rows in cron.job_run_details, which prevents the overview from running.` - : `Loading the cron job overview timed out. The issue might be caused by your cron.job_run_details table having too many rows.` +export const CronJobRunDetailsOverflowNoticeV2 = ( + props: CronJobRunDetailsOverflowNoticeV2Props +) => { + return ( + } + > +

Last run for each cron job omitted due to high query cost

+
+ ) +} + +const CronJobRunDetailsOverflowDialog = ({ + refetchJobs, +}: CronJobRunDetailsOverflowNoticeV2Props) => { + const { data: project } = useSelectedProjectQuery() + + const { + cleanupInterval, + cleanupState, + isScheduling, + isScheduleSuccess, + setCleanupInterval, + runBatchedDeletion, + scheduleCleanup, + cancelDeletion, + } = useCronJobsCleanupActions({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const isDeleting = cleanupState.status === 'deleting' - const isScheduling = cleanupState.status === 'scheduling' const isDeleteSuccess = cleanupState.status === 'delete-success' const isDeleteError = cleanupState.status === 'delete-error' - const isScheduleSuccess = cleanupState.status === 'schedule-success' const isBusy = isDeleting || isScheduling - const canSchedule = isDeleteSuccess || isScheduleSuccess return ( - -
-

- Remove old run history now, then schedule a cron job that keeps trimming{' '} - cron.job_run_details automatically so the overview remains responsive. -

+ + + + + + + Last run for cron jobs omitted for overview + + + +

+ The dashboard fetches data for the cron jobs overview by running a join between the{' '} + cron.job and{' '} + cron.job_run_details tables to + show each cron job's latest run. +

- {/* Step 1: Delete older entries */} -
-

Step 1: Delete older entries

+

+ However, the join was skipped as the estimated query cost exceeds safety thresholds, + likely due to the size of{' '} + cron.job_run_details table. +

+ - {isDeleting ? ( - - ) : isDeleteSuccess ? ( - - ) : isDeleteError ? ( - - ) : ( -
-
- - - - - - {CLEANUP_INTERVALS.map((option) => ( - - {option.label} - - ))} - - -
- -
- )} -
+ - {/* Step 2: Schedule automated cleanup (only available after successful deletion) */} -
-

Step 2: Schedule an automated cleanup

+ +

+ Suggested steps +

- {!canSchedule ? ( -

- Complete step 1 to enable scheduling a daily cleanup job. -

- ) : isScheduleSuccess ? ( - - ) : ( - <> - + We recommend removing the old run history now, then scheduling a cron job that keeps + trimming the cron.job_run_details table + automatically. This also prevents unnecessary bloat on the database. +

+ +
+

Step 1: Delete older entries

+ + {isDeleting ? ( + + ) : isDeleteSuccess ? ( + + ) : isDeleteError ? ( + runBatchedDeletion(cleanupInterval)} /> - - - )} -
-
- + ) : ( +
+
+ + + + + + {CLEANUP_INTERVALS.map((option) => ( + + {option.label} + + ))} + + +
+ +
+ )} +
+ +
+

Step 2: Schedule an automated cleanup

+ + {!canSchedule ? ( +

+ Complete step 1 to enable scheduling a daily cleanup job. +

+ ) : isScheduleSuccess ? ( + + ) : ( + <> + + + + )} +
+ + + ) } @@ -205,11 +252,7 @@ const DeletionError = ({ error, onRetry }: DeletionErrorProps) => (
) -interface ScheduleSuccessProps { - onRefresh: () => void -} - -const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => ( +const ScheduleSuccess = () => (
@@ -217,9 +260,8 @@ const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (

- Refresh to reload the cron jobs and view the new cleanup job. + New cleanup job should now be visible in the cron jobs overview.

-
) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx index b28921c4dfa..2d5063c263d 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx @@ -1,4 +1,4 @@ -import { type MouseEvent, type ReactNode, type UIEvent } from 'react' +import { type MouseEvent, type UIEvent } from 'react' import DataGrid, { type Column, Row } from 'react-data-grid' import AlertError from 'components/ui/AlertError' @@ -15,7 +15,6 @@ interface CronJobsTabDataGridProps { searchQuery?: string | null onScroll: (event: UIEvent) => void onRowClick: (row: CronJob, event: MouseEvent) => void - overlay?: ReactNode } export const CronJobsTabDataGrid = ({ @@ -26,16 +25,13 @@ export const CronJobsTabDataGrid = ({ searchQuery, onScroll, onRowClick, - overlay, }: CronJobsTabDataGridProps) => { - const fallbackContent = overlay ? ( -
{overlay}
- ) : isLoading ? ( + const fallbackContent = isLoading ? (
) : error ? ( -
+
) : ( diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 9ce5ae58730..f893e73db28 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -1,9 +1,3 @@ -import { Loader2 } from 'lucide-react' -import { useRouter } from 'next/router' -import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' -import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet' import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' @@ -13,17 +7,21 @@ import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQuer import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { createNavigationHandler } from 'lib/navigation' import { isGreaterThanOrEqual } from 'lib/semver' -import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' +import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' import { LoadingLine, Sheet, SheetContent } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' + import { formatCronJobColumns } from './CronJobs.utils' -import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice' +import { CronJobRunDetailsOverflowNoticeV2 } from './CronJobsTab.CleanupNotice' import { CronJobsTabDataGrid } from './CronJobsTab.DataGrid' -import { CronJobRunDetailsEstimateErrorNotice } from './CronJobsTab.EstimateErrorNotice' import { CronJobsTabHeader } from './CronJobsTab.Header' -import { useCronJobsCleanupActions } from './CronJobsTab.useCleanupActions' import { useCronJobsData } from './CronJobsTab.useCronJobsData' import { DeleteCronJob } from './DeleteCronJob' @@ -47,83 +45,12 @@ export const CronjobsTab = () => { setSearchQuery(null) } - const { dataStatus, isQueryEnabled, grid, count } = useCronJobsData({ + const { grid, count } = useCronJobsData({ projectRef: project?.ref, connectionString: project?.connectionString, searchQuery, }) - const { - cleanupInterval, - setCleanupInterval, - cleanupState, - runBatchedDeletion, - scheduleCleanup, - cancelDeletion, - } = useCronJobsCleanupActions({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const gridOverlay = useMemo(() => { - switch (dataStatus.status) { - case 'overflow-confirmed': - return ( - runBatchedDeletion(cleanupInterval)} - onRunScheduleSql={() => scheduleCleanup(cleanupInterval)} - onCancelDeletion={cancelDeletion} - onRetryDeletion={() => runBatchedDeletion(cleanupInterval)} - onRefresh={grid.refetch} - /> - ) - - case 'overflow-suspected': - return ( - runBatchedDeletion(cleanupInterval)} - onRunScheduleSql={() => scheduleCleanup(cleanupInterval)} - onCancelDeletion={cancelDeletion} - onRetryDeletion={() => runBatchedDeletion(cleanupInterval)} - onRefresh={grid.refetch} - /> - ) - - case 'estimate-error': - return ( - - ) - - case 'loading': - case 'ready': - default: - return undefined - } - }, [ - dataStatus, - cleanupInterval, - setCleanupInterval, - cleanupState, - runBatchedDeletion, - scheduleCleanup, - cancelDeletion, - grid.refetch, - ]) - const deletingCronJobIdRef = useRef(null) const { setValue: setCronJobForEditing, value: cronJobForEditing } = useQueryStateWithSelect({ @@ -184,8 +111,6 @@ export const CronjobsTab = () => { const xScroll = useRef(0) const handleScroll = (event: UIEvent) => { - if (!isQueryEnabled) return - const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -259,6 +184,7 @@ export const CronjobsTab = () => { onCreateJob={onOpenCreateJobSheet} /> + {grid.isMinimal && } { searchQuery={searchQuery} onScroll={handleScroll} onRowClick={handleRowClick} - overlay={gridOverlay} />
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts index 16ea05d0ada..9800370904a 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts @@ -1,7 +1,3 @@ -import { useCallback, useRef, useState } from 'react' -import { toast } from 'sonner' - -import type { ConnectionVars } from '@/data/common.types' import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { CTID_BATCH_PAGE_SIZE, @@ -9,10 +5,13 @@ import { getDeleteOldCronJobRunDetailsByCtidSql, getJobRunDetailsPageCountKey, getJobRunDetailsPageCountSql, - getScheduleDeleteCronJobRunDetailsKey, - getScheduleDeleteCronJobRunDetailsSql, } from 'data/sql/queries/delete-cron-job-run-details' +import { useCallback, useRef, useState } from 'react' +import { toast } from 'sonner' + import { CLEANUP_INTERVALS } from './CronJobsTab.constants' +import type { ConnectionVars } from '@/data/common.types' +import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation' // Delay between batches to allow other queries to proceed (in milliseconds) const BATCH_DELAY_MS = 100 @@ -30,9 +29,6 @@ export type CleanupState = | { status: 'deleting'; progress: BatchDeletionProgress } | { status: 'delete-success'; totalRowsDeleted: number } | { status: 'delete-error'; error: string } - | { status: 'scheduling' } - | { status: 'schedule-success' } - | { status: 'schedule-error'; error: string } export const useCronJobsCleanupActions = ({ projectRef, @@ -48,6 +44,12 @@ export const useCronJobsCleanupActions = ({ onError: () => {}, // Error handled inline }) + const { + mutate: scheduleCronJobCleanup, + isPending: isScheduling, + isSuccess: isScheduleSuccess, + } = useScheduleCronJobRunDetailsCleanupMutation() + /** * Run batched deletion using ctid ranges. * This approach scans the table in page chunks to avoid: @@ -157,33 +159,24 @@ export const useCronJobsCleanupActions = ({ * This should only be called after a successful initial deletion. */ const scheduleCleanup = useCallback( - async (interval: string) => { + async ({ interval, onSuccess }: { interval: string; onSuccess?: () => void }) => { if (!projectRef) { console.error('[CronJobsTab > schedule cleanup] Project reference is required') toast.error('There was an error scheduling the cleanup. Please try again.') return } - try { - setCleanupState({ status: 'scheduling' }) - - await executeSql({ - projectRef, - connectionString, - sql: getScheduleDeleteCronJobRunDetailsSql(interval), - queryKey: getScheduleDeleteCronJobRunDetailsKey(projectRef, interval), - }) - - setCleanupState({ status: 'schedule-success' }) - toast.success('Scheduled daily cleanup job.') - } catch (error) { - console.error('[CronJobs] Failed to schedule cleanup with error: %O', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - setCleanupState({ status: 'schedule-error', error: errorMessage }) - toast.error('Scheduling the cleanup job failed. Please try again.') - } + scheduleCronJobCleanup( + { projectRef, connectionString, interval }, + { + onSuccess: () => { + toast.success('Scheduled daily cleanup job.') + onSuccess?.() + }, + } + ) }, - [projectRef, connectionString, executeSql] + [connectionString, projectRef, scheduleCronJobCleanup] ) /** @@ -196,8 +189,10 @@ export const useCronJobsCleanupActions = ({ return { cleanupInterval, - setCleanupInterval, cleanupState, + isScheduling, + isScheduleSuccess, + setCleanupInterval, runBatchedDeletion, scheduleCleanup, cancelDeletion, diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts index b982135674f..1939b8895be 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts @@ -1,16 +1,17 @@ import { keepPreviousData } from '@tanstack/react-query' -import { useMemo } from 'react' - -import type { ConnectionVars } from '@/data/common.types' -import { useCronJobRunDetailsEstimateQuery } from 'data/database-cron-jobs/database-cron-job-run-details-estimate-query' import { useCronJobsCountEstimateQuery } from 'data/database-cron-jobs/database-cron-jobs-count-estimate-query' import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query' import { CronJob, useCronJobsInfiniteQuery, } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' +import { useMemo } from 'react' import type { ResponseError } from 'types' -import { CRON_JOBS_THRESHOLD, JOB_RUN_DETAILS_THRESHOLD } from './CronJobsTab.constants' + +import { CRON_JOBS_THRESHOLD } from './CronJobsTab.constants' +import type { ConnectionVars } from '@/data/common.types' +import { useCronJobsMinimalInfiniteQuery } from '@/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query' +import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query' // ============================================================================= // Input types @@ -31,6 +32,7 @@ interface CronJobsGridState { isRefetching: boolean isFetchingNextPage: boolean hasNextPage: boolean + isMinimal: boolean refetch: () => void fetchNextPage: () => void } @@ -41,81 +43,16 @@ interface CronJobsCountState { isLoading: boolean } -// ============================================================================= -// Discriminated union for data fetching status -// ============================================================================= - -/** Still checking the size of the job_run_details table */ -interface StatusLoading { - status: 'loading' -} - -/** Failed to check the table size - show error with retry option */ -interface StatusEstimateError { - status: 'estimate-error' - error: Error - isRetrying: boolean - retry: () => void -} - -/** Table is confirmed to be too large - show cleanup notice */ -interface StatusOverflowConfirmed { - status: 'overflow-confirmed' - estimatedRows: number -} - -/** Query timed out - suspected large table, show cleanup notice */ -interface StatusOverflowSuspected { - status: 'overflow-suspected' - estimatedRows: number | undefined -} - -/** Normal state - queries can run */ -interface StatusReady { - status: 'ready' -} - -type CronJobsDataStatus = - | StatusLoading - | StatusEstimateError - | StatusOverflowConfirmed - | StatusOverflowSuspected - | StatusReady - // ============================================================================= // Result type // ============================================================================= interface UseCronJobsDataResult { - /** Discriminated union indicating the current data fetching status */ - dataStatus: CronJobsDataStatus - /** Whether queries are enabled (not gated due to large table or loading) */ - isQueryEnabled: boolean /** State for the cron jobs grid */ grid: CronJobsGridState /** State for the cron jobs count (exact or estimate) */ count: CronJobsCountState } - -// ============================================================================= -// Hook implementation -// ============================================================================= - -/** - * Custom hook that encapsulates all data fetching logic for the CronJobsTab. - * - * This hook manages the complex query dependencies: - * 1. First, estimate the size of the job_run_details table - * 2. If the table is small enough, fetch the actual cron jobs - * 3. If the table is too large, show estimates instead and display a cleanup notice - * - * The `dataStatus` discriminated union ensures type-safe handling of all states: - * - 'loading': Still checking table size - * - 'estimate-error': Failed to check, show retry option - * - 'overflow-confirmed': Table too large (from estimate) - * - 'overflow-suspected': Query timed out - * - 'ready': Normal operation - */ export function useCronJobsData({ projectRef, connectionString, @@ -123,37 +60,10 @@ export function useCronJobsData({ }: UseCronJobsDataParams): UseCronJobsDataResult { const isProjectReady = !!projectRef - const { - data: runDetailsEstimateValue, - error: runDetailsEstimateError, - isError: isRunDetailsEstimateError, - isPending: isRunDetailsEstimatePending, - isRefetching: isRefetchingRunDetailsEstimate, - refetch: refetchRunDetailsEstimate, - } = useCronJobRunDetailsEstimateQuery( - { projectRef, connectionString }, - { enabled: isProjectReady } - ) - - const hasLargeRunDetailsTable = - typeof runDetailsEstimateValue === 'number' && - runDetailsEstimateValue > JOB_RUN_DETAILS_THRESHOLD - - // Queries are enabled only when: - // - Project is ready - // - Estimate query has completed successfully - // - Table is not too large - const isQueryEnabled = - isProjectReady && - !isRunDetailsEstimatePending && - !isRunDetailsEstimateError && - !hasLargeRunDetailsTable - const { data: cronJobsData, error: cronJobsError, isLoading: isCronJobsLoading, - isError: isCronJobsError, isRefetching: isCronJobsRefetching, isFetchingNextPage, hasNextPage = false, @@ -164,14 +74,35 @@ export function useCronJobsData({ { placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, staleTime: Infinity, - enabled: isQueryEnabled, + } + ) + const useMinimalQuery = cronJobsError?.message === COST_THRESHOLD_ERROR + + const { + data: cronJobsMinimalData, + error: cronJobsMinimalError, + isLoading: isCronJobsMinimalLoading, + isRefetching: isCronJobsMinimalRefetching, + isFetchingNextPage: isFetchingNextPageMinimal, + hasNextPage: hasNextPageMinimal = false, + refetch: refetchCronJobsMinimal, + fetchNextPage: fetchNextPageMinimal, + } = useCronJobsMinimalInfiniteQuery( + { projectRef, connectionString, searchTerm: searchQuery ?? undefined }, + { + placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, + staleTime: Infinity, + enabled: useMinimalQuery, } ) - const cronJobs = useMemo( - () => cronJobsData?.pages.flatMap((page) => page) ?? [], - [cronJobsData?.pages] - ) + const cronJobs = useMemo(() => { + if (useMinimalQuery) { + return cronJobsMinimalData?.pages.flatMap((page) => page) ?? [] + } else { + return cronJobsData?.pages.flatMap((page) => page) ?? [] + } + }, [useMinimalQuery, cronJobsData?.pages, cronJobsMinimalData?.pages]) // Fetch count - gated on cron.job table size // Always fetch the estimate first (it's fast since it uses pg_stat) @@ -193,77 +124,18 @@ export function useCronJobsData({ { enabled: isCountQueryEnabled } ) - // Determine if we should show a timeout error as a suspected overflow - const isTimeoutError = - isCronJobsError && - typeof cronJobsError?.message === 'string' && - cronJobsError.message.toLowerCase().includes('timeout') - - // Compute the discriminated status - const dataStatus: CronJobsDataStatus = useMemo(() => { - if (!isProjectReady || isRunDetailsEstimatePending) { - return { status: 'loading' } - } - - if (isRunDetailsEstimateError) { - return { - status: 'estimate-error', - error: runDetailsEstimateError, - isRetrying: isRefetchingRunDetailsEstimate, - retry: refetchRunDetailsEstimate, - } - } - - if (hasLargeRunDetailsTable) { - return { - status: 'overflow-confirmed', - estimatedRows: runDetailsEstimateValue!, - } - } - - if (isTimeoutError) { - return { - status: 'overflow-suspected', - estimatedRows: runDetailsEstimateValue, - } - } - - return { status: 'ready' } - }, [ - isProjectReady, - isRunDetailsEstimatePending, - isRunDetailsEstimateError, - runDetailsEstimateError, - isRefetchingRunDetailsEstimate, - refetchRunDetailsEstimate, - hasLargeRunDetailsTable, - runDetailsEstimateValue, - isTimeoutError, - ]) - - // Compute derived grid state - const isGridLoading = - dataStatus.status === 'loading' || (dataStatus.status === 'ready' && isCronJobsLoading) - const gridError = - dataStatus.status === 'ready' && isCronJobsError && !isTimeoutError - ? cronJobsError ?? null - : null - return { - dataStatus, - isQueryEnabled, - grid: { rows: cronJobs, - isLoading: isGridLoading, - error: gridError, - isRefetching: isCronJobsRefetching, - isFetchingNextPage, - hasNextPage, - refetch: refetchCronJobs, - fetchNextPage, + error: useMinimalQuery ? cronJobsMinimalError : cronJobsError, + isLoading: useMinimalQuery ? isCronJobsMinimalLoading : isCronJobsLoading, + isRefetching: useMinimalQuery ? isCronJobsMinimalRefetching : isCronJobsRefetching, + isFetchingNextPage: useMinimalQuery ? isFetchingNextPageMinimal : isFetchingNextPage, + hasNextPage: useMinimalQuery ? hasNextPageMinimal : hasNextPage, + isMinimal: useMinimalQuery, + refetch: useMinimalQuery ? refetchCronJobsMinimal : refetchCronJobs, + fetchNextPage: useMinimalQuery ? fetchNextPageMinimal : fetchNextPage, }, - count: { value: isCountQueryEnabled ? exactCount : estimatedCount, isEstimate: !isCountQueryEnabled, @@ -271,9 +143,3 @@ export function useCronJobsData({ }, } } - -// ============================================================================= -// Type exports for consumers -// ============================================================================= - -export type { CronJobsCountState, CronJobsDataStatus, CronJobsGridState } diff --git a/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts b/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts deleted file mode 100644 index 89b40815049..00000000000 --- a/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { QueryClient, useQuery } from '@tanstack/react-query' - -import type { ConnectionVars } from 'data/common.types' -import { executeSql } from 'data/sql/execute-sql-query' -import { - getLiveTupleEstimate, - getLiveTupleEstimateKey, -} from 'data/sql/queries/get-live-tuple-stats' -import type { UseCustomQueryOptions } from 'types' - -type DatabaseCronJobRunDetailsEstimateVariables = ConnectionVars - -const cronJobRunDetailsEstimateSql = getLiveTupleEstimate('job_run_details', 'cron') -const cronJobRunDetailsEstimateKey = (projectRef: string | undefined) => - getLiveTupleEstimateKey(projectRef, 'job_run_details', 'cron') - -export async function getCronJobRunDetailsEstimate({ - projectRef, - connectionString, -}: DatabaseCronJobRunDetailsEstimateVariables) { - if (!projectRef) throw new Error('Project ref is required') - - const { result } = await executeSql<{ live_tuple_estimate: number }[]>({ - projectRef, - connectionString, - sql: cronJobRunDetailsEstimateSql, - queryKey: cronJobRunDetailsEstimateKey(projectRef), - }) - - return result?.[0]?.live_tuple_estimate -} - -export type DatabaseCronJobRunDetailsEstimateData = Awaited< - ReturnType -> -export type DatabaseCronJobRunDetailsEstimateError = Error - -export const useCronJobRunDetailsEstimateQuery = ( - { projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions< - DatabaseCronJobRunDetailsEstimateData, - DatabaseCronJobRunDetailsEstimateError, - TData - > = {} -) => - useQuery({ - queryKey: cronJobRunDetailsEstimateKey(projectRef), - queryFn: () => getCronJobRunDetailsEstimate({ projectRef, connectionString }), - enabled: enabled && projectRef !== undefined, - staleTime: 5 * 60 * 1000, - ...options, - }) - -export function prefetchCronJobRunDetailsEstimate( - client: QueryClient, - { projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables -) { - return client.fetchQuery({ - // Does not change if connection string changes - // eslint-disable-next-line @tanstack/query/exhaustive-deps - queryKey: cronJobRunDetailsEstimateKey(projectRef), - queryFn: () => getCronJobRunDetailsEstimate({ projectRef, connectionString }), - }) -} diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts index fb8d737f4bf..84cd0ee9c33 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts @@ -1,12 +1,13 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' - -import { executeSql } from 'data/sql/execute-sql-query' +import { COST_THRESHOLD_ERROR, executeSql } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types' + +import { getCronJobsSql } from '../sql/queries/get-cron-jobs' import { databaseCronJobsKeys } from './keys' -const CRON_JOBS_PAGE_LIMIT = 20 +export const CRON_JOBS_PAGE_LIMIT = 20 -type DatabaseCronJobRunsVariables = { +export type DatabaseCronJobRunsVariables = { projectRef?: string connectionString?: string | null searchTerm?: string @@ -17,50 +18,11 @@ export type CronJob = { jobname: string | null active: boolean command: string - latest_run: string schedule: string - status: string + latest_run?: string + status?: string } -// [Joshen] Just to call out that I had AI help me with this, so please let me know if this can be optimized -const getCronJobSql = ({ searchTerm, page }: { searchTerm?: string; page: number }) => - ` -WITH latest_runs AS ( - SELECT - jobid, - status, - MAX(start_time) AS latest_run - FROM cron.job_run_details - GROUP BY jobid, status -), most_recent_runs AS ( - SELECT - jobid, - status, - latest_run - FROM latest_runs lr1 - WHERE latest_run = ( - SELECT MAX(latest_run) - FROM latest_runs lr2 - WHERE lr2.jobid = lr1.jobid - ) -) -SELECT - job.jobid, - job.jobname, - job.schedule, - job.command, - job.active, - mr.latest_run, - mr.status -FROM - cron.job job -LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid -${!!searchTerm ? `WHERE job.jobname ILIKE '%${searchTerm}%'` : ''} -ORDER BY job.jobid -LIMIT ${CRON_JOBS_PAGE_LIMIT} -OFFSET ${page * CRON_JOBS_PAGE_LIMIT}; -`.trim() - export async function getDatabaseCronJobs({ projectRef, connectionString, @@ -72,8 +34,9 @@ export async function getDatabaseCronJobs({ const { result } = await executeSql({ projectRef, connectionString, - sql: getCronJobSql({ searchTerm, page }), + sql: getCronJobsSql({ searchTerm, page, limit: CRON_JOBS_PAGE_LIMIT }), queryKey: ['cron-jobs'], + preflightCheck: true, }) return result @@ -114,5 +77,9 @@ export const useCronJobsInfiniteQuery = ( if (!hasNextPage) return undefined return page }, + retry: (failureCount, error) => { + if (error.message === COST_THRESHOLD_ERROR) return false + return failureCount < 3 + }, ...options, }) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts new file mode 100644 index 00000000000..26106ae581a --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts @@ -0,0 +1,67 @@ +import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types' + +import { getCronJobsMinimalSql } from '../sql/queries/get-cron-jobs' +import { + CRON_JOBS_PAGE_LIMIT, + CronJob, + DatabaseCronJobRunsVariables, +} from './database-cron-jobs-infinite-query' +import { databaseCronJobsKeys } from './keys' + +export async function getDatabaseCronJobsMinimal({ + projectRef, + connectionString, + searchTerm, + page = 0, +}: DatabaseCronJobRunsVariables & { page: number }) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: getCronJobsMinimalSql({ searchTerm, page, limit: CRON_JOBS_PAGE_LIMIT }), + queryKey: ['cron-jobs-minimal'], + }) + + return result +} + +type DatabaseCronJobsMinimalInfiniteData = CronJob[] +type DatabaseCronJobsMinimalInfiniteError = ResponseError + +export const useCronJobsMinimalInfiniteQuery = ( + { projectRef, connectionString, searchTerm }: DatabaseCronJobRunsVariables, + { + enabled = true, + ...options + }: UseCustomInfiniteQueryOptions< + DatabaseCronJobsMinimalInfiniteData, + DatabaseCronJobsMinimalInfiniteError, + InfiniteData, + readonly unknown[], + number + > = {} +) => + useInfiniteQuery({ + queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm), + queryFn: ({ pageParam }) => { + return getDatabaseCronJobsMinimal({ + projectRef, + connectionString, + searchTerm, + page: pageParam, + }) + }, + staleTime: 0, + enabled: enabled && typeof projectRef !== 'undefined', + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + const page = pages.length + const hasNextPage = lastPage.length >= CRON_JOBS_PAGE_LIMIT + if (!hasNextPage) return undefined + return page + }, + ...options, + }) diff --git a/apps/studio/data/database-cron-jobs/keys.ts b/apps/studio/data/database-cron-jobs/keys.ts index 6a6505fb085..964542f2c35 100644 --- a/apps/studio/data/database-cron-jobs/keys.ts +++ b/apps/studio/data/database-cron-jobs/keys.ts @@ -6,6 +6,8 @@ export const databaseCronJobsKeys = { ['projects', projectRef, 'cron-jobs', identifier] as const, listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) => ['projects', projectRef, 'cron-jobs', { searchTerm }] as const, + listInfiniteMinimal: (projectRef: string | undefined, searchTerm: string | undefined) => + ['projects', projectRef, 'cron-jobs-mininal', { searchTerm }] as const, count: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs', 'count'] as const, run: (projectRef: string | undefined, jobId: number) => [ diff --git a/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts new file mode 100644 index 00000000000..7923c247524 --- /dev/null +++ b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { toast } from 'sonner' +import type { ResponseError, UseCustomMutationOptions } from 'types' + +import { + getScheduleDeleteCronJobRunDetailsKey, + getScheduleDeleteCronJobRunDetailsSql, +} from '../sql/queries/delete-cron-job-run-details' + +export type ScheduleCronJobRunDetailsCleanupVariables = { + projectRef: string + connectionString?: string | null + interval: string +} + +export async function scheduleCronJobRunDetailsCleanup({ + projectRef, + connectionString, + interval, +}: ScheduleCronJobRunDetailsCleanupVariables) { + const { result } = await executeSql({ + projectRef, + connectionString, + sql: getScheduleDeleteCronJobRunDetailsSql(interval), + queryKey: getScheduleDeleteCronJobRunDetailsKey(projectRef, interval), + }) + + return result +} + +type ScheduleCronJobRunDetailsCleanupData = Awaited< + ReturnType +> + +export const useScheduleCronJobRunDetailsCleanupMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + ScheduleCronJobRunDetailsCleanupData, + ResponseError, + ScheduleCronJobRunDetailsCleanupVariables + >, + 'mutationFn' +> = {}) => { + return useMutation< + ScheduleCronJobRunDetailsCleanupData, + ResponseError, + ScheduleCronJobRunDetailsCleanupVariables + >({ + mutationFn: (vars) => scheduleCronJobRunDetailsCleanup(vars), + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to schedule clean up job: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/sql/queries/get-cron-jobs.ts b/apps/studio/data/sql/queries/get-cron-jobs.ts new file mode 100644 index 00000000000..aa930819ca7 --- /dev/null +++ b/apps/studio/data/sql/queries/get-cron-jobs.ts @@ -0,0 +1,72 @@ +import { literal } from '@supabase/pg-meta/src/pg-format' + +// [Joshen] Just omits the LEFT JOIN as that's the heavy part +export const getCronJobsMinimalSql = ({ + searchTerm, + page, + limit, +}: { + searchTerm?: string + page: number + limit: number +}) => + ` +SELECT + job.jobid, + job.jobname, + job.schedule, + job.command, + job.active +FROM + cron.job job +${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''} +ORDER BY job.jobid +LIMIT ${limit} +OFFSET ${page * limit}; +`.trim() + +export const getCronJobsSql = ({ + searchTerm, + page, + limit, +}: { + searchTerm?: string + page: number + limit: number +}) => + ` +WITH latest_runs AS ( + SELECT + jobid, + status, + MAX(start_time) AS latest_run + FROM cron.job_run_details + GROUP BY jobid, status +), most_recent_runs AS ( + SELECT + jobid, + status, + latest_run + FROM latest_runs lr1 + WHERE latest_run = ( + SELECT MAX(latest_run) + FROM latest_runs lr2 + WHERE lr2.jobid = lr1.jobid + ) +) +SELECT + job.jobid, + job.jobname, + job.schedule, + job.command, + job.active, + mr.latest_run, + mr.status +FROM + cron.job job +LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid +${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''} +ORDER BY job.jobid +LIMIT ${limit} +OFFSET ${page * limit}; +`.trim() diff --git a/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts b/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts deleted file mode 100644 index dc894ff1f92..00000000000 --- a/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' - -import { prefetchCronJobRunDetailsEstimate } from 'data/database-cron-jobs/database-cron-job-run-details-estimate-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useStaticEffectEvent } from 'hooks/useStaticEffectEvent' - -/** - * Prefetches the cron job run details estimate when on a Cron integration page. - * This avoids a query waterfall when navigating to the Jobs tab, since the - * Jobs tab gates loading cron jobs on this estimate query completing first. - */ -export function useCronJobsEstimatePrefetch(integrationId: string | undefined) { - const queryClient = useQueryClient() - const { data: project } = useSelectedProjectQuery() - - const prefetch = useStaticEffectEvent(() => { - prefetchCronJobRunDetailsEstimate(queryClient, { - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - }) - - useEffect(() => { - if (integrationId === 'cron' && project?.ref) { - prefetch() - } - }, [integrationId, project?.ref, prefetch]) -} diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx index 0e83542b46e..4388c94e708 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -1,12 +1,10 @@ -import { useRouter } from 'next/router' -import { useEffect, useMemo } from 'react' - import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DefaultLayout } from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' -import { useCronJobsEstimatePrefetch } from 'hooks/misc/useCronJobsEstimatePrefetch' +import { useRouter } from 'next/router' +import { useEffect, useMemo } from 'react' import type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' @@ -24,8 +22,6 @@ const IntegrationPage: NextPageWithLayout = () => { const router = useRouter() const { ref, id, pageId, childId } = useParams() - useCronJobsEstimatePrefetch(id) - const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = useInstalledIntegrations() diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx index 421bb715e9b..ee4fdac3905 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -1,15 +1,13 @@ -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect, useMemo } from 'react' - import { useFlag, useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DefaultLayout } from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useCronJobsEstimatePrefetch } from 'hooks/misc/useCronJobsEstimatePrefetch' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useMemo } from 'react' import type { NextPageWithLayout } from 'types' import { BreadcrumbItem_Shadcn_ as BreadcrumbItem, @@ -44,8 +42,6 @@ const IntegrationPage: NextPageWithLayout = () => { const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) const stripeSyncEnabled = useFlag('enableStripeSyncEngineIntegration') - useCronJobsEstimatePrefetch(id) - const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = useInstalledIntegrations() diff --git a/e2e/studio/features/cron-jobs.spec.ts b/e2e/studio/features/cron-jobs.spec.ts index 6876549ba12..4dac263157f 100644 --- a/e2e/studio/features/cron-jobs.spec.ts +++ b/e2e/studio/features/cron-jobs.spec.ts @@ -87,10 +87,10 @@ const deleteCronJob = async (page: Page, jobName: string) => { await expect(jobRow).not.toBeVisible({ timeout: 10000 }) } -// Run all cron tests serially since they share database state (pg_cron extension) -test.describe.configure({ mode: 'serial' }) - test.describe('Cron Jobs Integration', () => { + // Run CRUD tests serially since they share database state (pg_cron extension) + test.describe.configure({ mode: 'serial' }) + test.describe('Cron Jobs CRUD Operations', () => { let page: Page @@ -266,56 +266,194 @@ test.describe('Cron Jobs Integration', () => { }) }) -test.describe('Large Table Overflow Notice', () => { - const cleanupJobName = 'delete-job-run-details' +test.describe('High Query Cost Banner', () => { + const testJobName = 'pw_high_cost_test_job' - test('shows cleanup notice and allows cleanup workflow', async ({ page, ref }) => { - // First ensure pg_cron is enabled (without any mocks) + test.describe.configure({ mode: 'serial' }) + + test('shows banner and still displays cron jobs when query cost exceeds threshold', async ({ + page, + ref, + }) => { + // First ensure pg_cron is enabled await navigateToCronOverviewAndEnable(page, ref) + await navigateToCronJobsPage(page, ref) - // Now set up the mock for the row count estimate + // Check if test job already exists (from previous test run) + const existingTestJob = page.getByRole('row', { name: new RegExp(testJobName) }) + const jobExists = (await existingTestJob.count()) > 0 + + // Create a test cron job only if it doesn't exist + if (!jobExists) { + await page.getByRole('button', { name: 'Create job' }).click() + await expect(page.getByRole('heading', { name: 'Create a new cron job' })).toBeVisible() + await page.locator('input[name="name"]').fill(testJobName) + await page.getByRole('button', { name: 'Every minute' }).click() + await page.getByRole('code').click() + await page.getByRole('textbox', { name: /Editor content/ }).fill("SELECT 'high_cost_test';") + await page.getByRole('button', { name: 'Create cron job' }).click() + await expect(page.getByText(/Successfully created cron job/)).toBeVisible({ timeout: 10000 }) + } + + // Now set up the mock for the EXPLAIN query (preflight check) to return high cost + // This simulates the scenario where cron.job_run_details is too large await page.route('**/pg-meta/*/query**', async (route) => { const request = route.request() const postData = request.postDataJSON() - // Only intercept the live tuple estimate query for job_run_details - if (postData?.query?.includes('n_live_tup') && postData?.query?.includes('job_run_details')) { + // Intercept EXPLAIN queries for the cron jobs query (the preflight check) + if ( + postData?.query?.toLowerCase().startsWith('explain') && + postData?.query?.includes('cron.job') + ) { + // Return a mock EXPLAIN result with very high cost (over 100,000 threshold) await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify([{ live_tuple_estimate: 500000 }]), + body: JSON.stringify([ + { 'QUERY PLAN': 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)' }, + ]), }) } else { await route.continue() } }) - // Navigate to the cron jobs page (this will trigger the mocked estimate query) + // Navigate to the cron jobs page - this will trigger the mocked preflight check await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`)) - // Should show the overflow notice with the warning title - await expect(page.getByText('cron.job_run_details is too large to load')).toBeVisible({ + // Wait for the grid to load - this verifies cron jobs are still visible in minimal mode + await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 }) + + // Should show the overflow banner (minimal mode indicator) + await expect( + page.getByText('Last run for each cron job omitted due to high query cost') + ).toBeVisible({ timeout: 15000, }) - // Should show the estimated row count in the description - await expect(page.getByText(/approximately 500,000 rows/)).toBeVisible() + // The test job should still be visible in the grid (minimal mode shows jobs without last run) + await expect(page.getByRole('row', { name: new RegExp(testJobName) })).toBeVisible() + + // The "Learn more" button should be visible + await expect(page.getByRole('button', { name: 'Learn more' })).toBeVisible() + + // Remove the route mock for subsequent tests + await page.unroute('**/pg-meta/*/query**') + }) + + test('Learn more dialog shows cleanup options', async ({ page, ref }) => { + // Set up the mock again for this test + await page.route('**/pg-meta/*/query**', async (route) => { + const request = route.request() + const postData = request.postDataJSON() + + if ( + postData?.query?.toLowerCase().startsWith('explain') && + postData?.query?.includes('cron.job') + ) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { 'QUERY PLAN': 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)' }, + ]), + }) + } else { + await route.continue() + } + }) + + await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`)) + + // Wait for the banner to appear + await expect( + page.getByText('Last run for each cron job omitted due to high query cost') + ).toBeVisible({ + timeout: 15000, + }) + + // Click the "Learn more" button to open the dialog + await page.getByRole('button', { name: 'Learn more' }).click() + + // The dialog should open with the explanation + await expect( + page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' }) + ).toBeVisible() + + // Should explain the issue + await expect( + page.getByText(/the estimated query cost exceeds safety thresholds/) + ).toBeVisible() // Should show Step 1 with cleanup options await expect(page.getByText('Step 1: Delete older entries')).toBeVisible() await expect(page.getByRole('button', { name: 'Delete rows now' })).toBeVisible() + // Should show the interval selector + await expect(page.getByRole('combobox')).toBeVisible() + // Should show Step 2 (disabled until step 1 is complete) await expect(page.getByText('Step 2: Schedule an automated cleanup')).toBeVisible() - await expect(page.getByText('Complete step 1 to enable scheduling')).toBeVisible() + await expect( + page.getByText('Complete step 1 to enable scheduling a daily cleanup job') + ).toBeVisible() + + // Remove the route mock + await page.unroute('**/pg-meta/*/query**') + }) + + test('cleanup workflow: delete rows and schedule cleanup job', async ({ page, ref }) => { + // Set up the mock for the high cost scenario + await page.route('**/pg-meta/*/query**', async (route) => { + const request = route.request() + const postData = request.postDataJSON() + + if ( + postData?.query?.toLowerCase().startsWith('explain') && + postData?.query?.includes('cron.job') + ) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { 'QUERY PLAN': 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)' }, + ]), + }) + } else { + await route.continue() + } + }) + + await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`)) + + // Wait for the banner and click Learn more + await expect( + page.getByText('Last run for each cron job omitted due to high query cost') + ).toBeVisible({ + timeout: 15000, + }) + await page.getByRole('button', { name: 'Learn more' }).click() + + // Wait for dialog to open + await expect( + page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' }) + ).toBeVisible() // Step 1: Click "Delete rows now" to run the cleanup await page.getByRole('button', { name: 'Delete rows now' }).click() // Wait for Step 1 to complete - should show success message + // Note: In a fresh DB, there might be 0 rows deleted, which is still success await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({ timeout: 30000 }) - // Step 2 should now be enabled - click "Schedule cleanup job" + // Step 2 should now be enabled - the "Schedule cleanup job" button should be visible + await expect(page.getByRole('button', { name: 'Schedule cleanup job' })).toBeVisible() + + // Should show the SQL preview for the cleanup job + await expect(page.getByText(/cron\.schedule/)).toBeVisible() + + // Click "Schedule cleanup job" await page.getByRole('button', { name: 'Schedule cleanup job' }).click() // Wait for Step 2 to complete - should show success message @@ -323,14 +461,11 @@ test.describe('Large Table Overflow Notice', () => { timeout: 15000, }) - // Clean up: remove the route mock and delete the scheduled cleanup job + // Clean up: remove the route mock await page.unroute('**/pg-meta/*/query**') - // Click refresh to reload the page with real data - await page.getByRole('button', { name: 'Refresh' }).last().click() - - // Wait for the grid to appear and verify the cleanup job was created, then delete it - await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 }) - await expect(page.getByRole('row', { name: new RegExp(cleanupJobName) })).toBeVisible() + // Note: Test jobs (pw_high_cost_test_job, delete-job-run-details) will be cleaned up + // in the next test run by the CRUD tests' beforeAll cleanup, or manually. + // Attempting to delete here causes pointer events issues with Radix dialogs. }) })