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. }) })