From d34cc65e30a1f5eb870417eb03fdcbb069e3c617 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:17:49 -0500 Subject: [PATCH] fix(studio): handle large cron.job_run_details table gracefully (#41992) * fix(studio): check job_run_details size in cron display When cron.job_run_details grows too large (200k+ rows), loading the cron jobs overview can timeout and affect other queries by pulling excessive data into shared buffers. This change: - Estimates table size using pg_stat before fetching cron jobs data - Shows a cleanup notice when the table exceeds the threshold - Provides batched deletion using ctid ranges to avoid buffer pollution - Allows scheduling an automated daily cleanup cron job - Handles timeout errors gracefully with a "suspected overflow" state The useCronJobsData hook now returns a discriminated union status that tracks loading, estimate-error, overflow-confirmed, overflow-suspected, and ready states, allowing the UI to respond appropriately to each case. * fix(studio): use index when querying cron.job_run_details cron.job_run_details is only indexed by runid, not by start_time. Change the query to use the runid index (which gives the same result, since runid is auto-incrementing). --- .../Integrations/CronJobs/CronJobs.utils.tsx | 4 +- .../CronJobs/CronJobsTab.CleanupNotice.tsx | 225 ++++++++++ .../CronJobs/CronJobsTab.DataGrid.tsx | 88 ++++ .../CronJobsTab.EstimateErrorNotice.tsx | 42 ++ .../CronJobs/CronJobsTab.Header.tsx | 64 +++ .../CronJobs/CronJobsTab.constants.ts | 9 + .../Integrations/CronJobs/CronJobsTab.tsx | 396 +++++++++--------- .../CronJobs/CronJobsTab.useCleanupActions.ts | 205 +++++++++ .../CronJobs/CronJobsTab.useCronJobsData.ts | 279 ++++++++++++ apps/studio/data/common.types.ts | 2 +- ...ase-cron-job-run-details-estimate-query.ts | 67 +++ ...database-cron-jobs-count-estimate-query.ts | 53 +++ .../database-cron-jobs-runs-infinite-query.ts | 21 +- .../queries/delete-cron-job-run-details.ts | 91 ++++ .../hooks/misc/useCronJobsEstimatePrefetch.ts | 29 ++ apps/studio/lib/semver.test.ts | 58 ++- apps/studio/lib/semver.ts | 25 +- .../[id]/[pageId]/[childId]/index.tsx | 3 + .../integrations/[id]/[pageId]/index.tsx | 3 + e2e/studio/features/cron-jobs.spec.ts | 336 +++++++++++++++ 20 files changed, 1769 insertions(+), 231 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.constants.ts create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts create 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-count-estimate-query.ts create mode 100644 apps/studio/data/sql/queries/delete-cron-job-run-details.ts create mode 100644 apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts create mode 100644 e2e/studio/features/cron-jobs.spec.ts diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 926d9990349..2f726f8050a 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -228,9 +228,9 @@ export const formatCronJobColumns = ({ }: { onSelectEdit: (job: CronJob) => void onSelectDelete: (job: CronJob) => void -}) => { +}): Array> => { return CRON_TABLE_COLUMNS.map((col) => { - const res: Column = { + const res: Column = { key: col.id, name: col.name, minWidth: col.minWidth ?? 100, diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx new file mode 100644 index 00000000000..5f2f8efdf77 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx @@ -0,0 +1,225 @@ +import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react' + +import { + Button, + CodeBlock, + Progress, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} 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' + +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 +} + +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.` + + 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. +

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

Step 1: Delete older entries

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

+ + {!canSchedule ? ( +

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

+ ) : isScheduleSuccess ? ( + + ) : ( + <> + + + + )} +
+
+
+ ) +} + +interface DeletionProgressProps { + progress: BatchDeletionProgress + onCancel: () => void +} + +const DeletionProgress = ({ progress, onCancel }: DeletionProgressProps) => { + const { currentBatch, totalBatches, totalRowsDeleted } = progress + const percentComplete = + totalBatches > 0 ? Math.min(Math.round((currentBatch / totalBatches) * 100), 100) : 0 + + return ( +
+
+ + + {percentComplete}% ({currentBatch}/{totalBatches} batches) + +
+
+ + Deleted {totalRowsDeleted.toLocaleString()} rows so far... + + +
+
+ ) +} + +interface DeletionSuccessProps { + totalRowsDeleted: number +} + +const DeletionSuccess = ({ totalRowsDeleted }: DeletionSuccessProps) => ( +
+ + Successfully deleted {totalRowsDeleted.toLocaleString()} rows. +
+) + +interface DeletionErrorProps { + error: string + onRetry: () => void +} + +const DeletionError = ({ error, onRetry }: DeletionErrorProps) => ( +
+
+ + Deletion failed: {error} +
+ +
+) + +interface ScheduleSuccessProps { + onRefresh: () => void +} + +const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => ( +
+
+ + Daily cleanup job scheduled successfully. +
+
+

+ Refresh to reload the cron jobs and view the new cleanup job. +

+
+
+) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx new file mode 100644 index 00000000000..b28921c4dfa --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx @@ -0,0 +1,88 @@ +import { type MouseEvent, type ReactNode, type UIEvent } from 'react' +import DataGrid, { type Column, Row } from 'react-data-grid' + +import AlertError from 'components/ui/AlertError' +import type { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' +import type { ResponseError } from 'types' +import { cn } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +interface CronJobsTabDataGridProps { + columns: readonly Column[] + rows: CronJob[] + isLoading: boolean + error: ResponseError | Error | null + searchQuery?: string | null + onScroll: (event: UIEvent) => void + onRowClick: (row: CronJob, event: MouseEvent) => void + overlay?: ReactNode +} + +export const CronJobsTabDataGrid = ({ + columns, + rows, + isLoading, + error, + searchQuery, + onScroll, + onRowClick, + overlay, +}: CronJobsTabDataGridProps) => { + const fallbackContent = overlay ? ( +
{overlay}
+ ) : isLoading ? ( +
+ +
+ ) : error ? ( +
+ +
+ ) : ( +
+
+

+ {!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'} +

+

+ {!!searchQuery + ? 'There are currently no cron jobs based on the search applied' + : 'There are currently no cron jobs created yet in your project'} +

+
+
+ ) + + return ( + row.jobid} + rowClass={() => { + return cn( + 'cursor-pointer', + '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', + '[&>.rdg-cell:first-child>div]:ml-8' + ) + }} + renderers={{ + renderRow(_, props) { + return ( + { + onRowClick(props.row, event) + }} + /> + ) + }, + noRowsFallback: fallbackContent, + }} + /> + ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx new file mode 100644 index 00000000000..73f1f2ffb5e --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EstimateErrorNotice.tsx @@ -0,0 +1,42 @@ +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +interface CronJobRunDetailsEstimateErrorNoticeProps { + error?: Error | null + isRetrying?: boolean + onRetry?: () => void +} + +export const CronJobRunDetailsEstimateErrorNotice = ({ + error, + isRetrying, + onRetry, +}: CronJobRunDetailsEstimateErrorNoticeProps) => { + return ( + +
+ {error?.message && ( +

+ Error message: {error.message} +

+ )} + {onRetry && ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx new file mode 100644 index 00000000000..dc0a5e61b2d --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx @@ -0,0 +1,64 @@ +import { RefreshCw, Search, X } from 'lucide-react' +import type { KeyboardEvent } from 'react' + +import { Button } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +interface CronJobsTabHeaderProps { + search: string + isRefreshing: boolean + onSearchChange: (value: string) => void + onSearchSubmit: () => void + onClearSearch: () => void + onRefresh: () => void + onCreateJob: () => void +} + +export const CronJobsTabHeader = ({ + search, + isRefreshing, + onSearchChange, + onSearchSubmit, + onClearSearch, + onRefresh, + onCreateJob, +}: CronJobsTabHeaderProps) => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.code === 'NumpadEnter') { + onSearchSubmit() + } + } + + return ( +
+ } + value={search} + onChange={(e) => onSearchChange(e.target.value)} + onKeyDown={handleKeyDown} + actions={[ + search && ( + + +
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.constants.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.constants.ts new file mode 100644 index 00000000000..fcc6ddec70a --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.constants.ts @@ -0,0 +1,9 @@ +export const JOB_RUN_DETAILS_THRESHOLD = 200_000 +export const CRON_JOBS_THRESHOLD = 200_000 + +export const CLEANUP_INTERVALS = [ + { label: 'Older than 1 day', value: '1 day' }, + { label: 'Older than 7 days', value: '7 days' }, + { label: 'Older than 1 month', value: '1 month' }, + { label: 'Older than 6 months', value: '6 months' }, +] diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index c674aa4e87f..9ce5ae58730 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -1,32 +1,30 @@ -import { Loader2, RefreshCw, Search, X } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { useRouter } from 'next/router' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' -import { UIEvent, useMemo, useRef, useState } from 'react' -import DataGrid, { DataGridHandle, Row } from 'react-data-grid' +import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet' -import AlertError from 'components/ui/AlertError' -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 { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' -import { BASE_PATH } from 'lib/constants' +import { createNavigationHandler } from 'lib/navigation' +import { isGreaterThanOrEqual } from 'lib/semver' import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' -import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' +import { LoadingLine, Sheet, SheetContent } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { formatCronJobColumns } from './CronJobs.utils' +import { CronJobRunDetailsOverflowNotice } 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' const EMPTY_CRON_JOB = { jobname: '', schedule: '', active: true, command: '' } @@ -37,114 +35,179 @@ export const CronjobsTab = () => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() - const xScroll = useRef(0) - const gridRef = useRef(null) - - // Track the ID being deleted to exclude it from error checking - const deletingCronJobIdRef = useRef(null) - const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) const [search, setSearch] = useState(searchQuery) - const [createCronJobSheetShown, setCreateCronJobSheetShown] = useQueryState( - 'new', - parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true }) - ) + + const handleSearchSubmit = () => { + const trimmed = search.trim() + setSearchQuery(trimmed.length > 0 ? trimmed : null) + } + const handleClearSearch = () => { + setSearch('') + setSearchQuery(null) + } + + const { dataStatus, isQueryEnabled, grid, count } = useCronJobsData({ + projectRef: project?.ref, + connectionString: project?.connectionString, + searchQuery, + }) const { - data, - error, - isLoading, - isError, - isRefetching, - isFetchingNextPage, - hasNextPage, - refetch, - fetchNextPage, - } = useCronJobsInfiniteQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - searchTerm: searchQuery, - }, - { placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, staleTime: Infinity } - ) - const cronJobs = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages]) + 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({ urlKey: 'edit', select: (jobid: string) => { if (!jobid) return undefined - const job = cronJobs?.find((j) => j.jobid.toString() === jobid) + const job = grid.rows.find((j) => j.jobid.toString() === jobid) return job ? { jobname: job.jobname, schedule: job.schedule, active: job.active, command: job.command } : undefined }, - enabled: !!cronJobs && cronJobs.length > 0 && !isLoading, + enabled: grid.rows.length > 0 && !grid.isLoading, onError: () => toast.error(`Cron job not found`), }) const { setValue: setCronJobForDeletion, value: cronJobForDeletion } = useQueryStateWithSelect({ urlKey: 'delete', select: (jobid: string) => - jobid ? cronJobs?.find((j) => j.jobid.toString() === jobid) : undefined, - enabled: !!cronJobs && cronJobs.length > 0 && !isLoading, + jobid ? grid.rows.find((j) => j.jobid.toString() === jobid) : undefined, + enabled: grid.rows.length > 0 && !grid.isLoading, onError: (_error, selectedId) => handleErrorOnDelete(deletingCronJobIdRef, selectedId, `Cron job not found`), }) - const { data: count, isPending: isLoadingCount } = useCronJobsCountQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const { data: extensions = [] } = useDatabaseExtensionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + const pgCronExtension = extensions.find((ext) => ext.name === 'pg_cron') + const supportsSeconds = pgCronExtension?.installed_version + ? isGreaterThanOrEqual(pgCronExtension.installed_version, '1.5') + : false + const { mutate: sendEvent } = useSendEventMutation() - const columns = useMemo(() => { - return formatCronJobColumns({ - onSelectEdit: (job: any) => { - sendEvent({ - action: 'cron_job_update_clicked', - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) - setCronJobForEditing(job.jobid.toString()) - }, - onSelectDelete: (job: CronJob) => { - sendEvent({ - action: 'cron_job_delete_clicked', - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) - setCronJobForDeletion(job.jobid.toString()) - }, - }) - }, [org?.slug, ref, sendEvent, setCronJobForEditing, setCronJobForDeletion]) + const columns = useMemo( + () => + formatCronJobColumns({ + onSelectEdit: (job: CronJob) => { + sendEvent({ + action: 'cron_job_update_clicked', + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + setCronJobForEditing(job.jobid.toString()) + }, + onSelectDelete: (job: CronJob) => { + sendEvent({ + action: 'cron_job_delete_clicked', + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + setCronJobForDeletion(job.jobid.toString()) + }, + }), + [org?.slug, ref, sendEvent, setCronJobForEditing, setCronJobForDeletion] + ) - // check pg_cron version to see if it supports seconds - const pgCronExtension = extensions.find((ext) => ext.name === 'pg_cron') - const installedVersion = pgCronExtension?.installed_version - const supportsSeconds = installedVersion ? parseFloat(installedVersion) >= 1.5 : false + const xScroll = useRef(0) const handleScroll = (event: UIEvent) => { + if (!isQueryEnabled) return + const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft if ( - isLoading || - isFetchingNextPage || + grid.isLoading || + grid.isFetchingNextPage || isScrollingHorizontally || !isAtBottom(event) || - !hasNextPage + !grid.hasNextPage ) { return } - fetchNextPage() + grid.fetchNextPage() } + // Create job sheet + const [createCronJobSheetShown, setCreateCronJobSheetShown] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true }) + ) + const onOpenCreateJobSheet = () => { sendEvent({ action: 'cron_job_create_clicked', @@ -153,6 +216,21 @@ export const CronjobsTab = () => { setCreateCronJobSheetShown(true) } + // Row click handler + const handleRowClick = (row: CronJob, event: MouseEvent) => { + const { jobid, jobname } = row + const url = `/project/${ref}/integrations/cron/jobs/${jobid}?child-label=${encodeURIComponent( + jobname || `Job #${jobid}` + )}` + + sendEvent({ + action: 'cron_job_history_clicked', + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + + createNavigationHandler(url, router)(event) + } + const [isDirty, setIsDirty] = useState(false) const onClose = () => { setCronJobForEditing(null) @@ -171,129 +249,27 @@ export const CronjobsTab = () => { <>
-
- } - value={search ?? ''} - onChange={(e) => setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.code === 'Enter' || e.code === 'NumpadEnter') setSearchQuery(search.trim()) - }} - actions={[ - search && ( - - -
-
- - - - row.id} - rowClass={() => { - return cn( - 'cursor-pointer', - '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', - '[&>.rdg-cell:first-child>div]:ml-8' - ) - }} - onScroll={handleScroll} - renderers={{ - renderRow(_, props) { - return ( - { - const { jobid, jobname } = props.row - const url = `/project/${ref}/integrations/cron/jobs/${jobid}?child-label=${encodeURIComponent(jobname || `Job #${jobid}`)}` - - sendEvent({ - action: 'cron_job_history_clicked', - groups: { - project: ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, - }) - - if (e.metaKey) { - window.open(`${BASE_PATH}/${url}`, '_blank') - } else { - router.push(url) - } - }} - /> - ) - }, - }} + - - {/* [Joshen] Render 0 rows state outside of the grid so that their position isn't relative to the grid scroll position */} - {cronJobs.length === 0 ? ( - isLoading ? ( -
- -
- ) : isError ? ( -
- -
- ) : ( -
-
-

- {!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'} -

-

- {!!searchQuery - ? 'There are currently no cron jobs based on the search applied' - : 'There are currently no cron jobs created yet in your project'} -

-
-
- ) - ) : null} - -
- {isLoadingCount ? ( - - Loading... - - ) : ( - `Total: ${count} jobs` - )} -
+ + +
@@ -327,6 +303,28 @@ export const CronjobsTab = () => { ) } +// Footer component for displaying job count +interface CronJobsFooterProps { + count: { + value: number | undefined + isEstimate: boolean + isLoading: boolean + } +} + +const CronJobsFooter = ({ count }: CronJobsFooterProps) => ( +
+ {count.isLoading ? ( + + Loading... + + ) : ( + `Total: ${count.value ?? 0} jobs${count.isEstimate ? ' (estimate)' : ''}` + )} +
+) + +// Confirmation modal for unsaved changes const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( { + const [cleanupInterval, setCleanupInterval] = useState(CLEANUP_INTERVALS[0].value) + const [cleanupState, setCleanupState] = useState({ status: 'idle' }) + + // Ref to track cancellation + const cancelledRef = useRef(false) + + const { mutateAsync: executeSql } = useExecuteSqlMutation({ + onError: () => {}, // Error handled inline + }) + + /** + * Run batched deletion using ctid ranges. + * This approach scans the table in page chunks to avoid: + * - Buffer cache pollution from full table scans + * - Long-running transactions that block vacuum + * - Lock accumulation from deleting millions of rows at once + */ + const runBatchedDeletion = useCallback( + async (interval: string) => { + if (!projectRef) { + console.error('[CronJobsTab > batch deletion] Project reference is required') + toast.error('There was an error running the cleanup. Please try again.') + return + } + + cancelledRef.current = false + + try { + // Step 1: Get the total number of pages in the table + setCleanupState({ + status: 'deleting', + progress: { currentBatch: 0, totalBatches: 0, totalRowsDeleted: 0 }, + }) + + const pageCountResult = await executeSql({ + projectRef, + connectionString, + sql: getJobRunDetailsPageCountSql(), + queryKey: getJobRunDetailsPageCountKey(projectRef), + }) + + const rawTotalPages = pageCountResult.result?.[0]?.num_pages ?? 0 + const totalPages = Number(rawTotalPages) + if (!Number.isFinite(totalPages) || totalPages < 0) { + throw new Error( + `[CronJobs > cleanup actions] Invalid page count returned: ${rawTotalPages}` + ) + } + + if (totalPages === 0) { + setCleanupState({ status: 'delete-success', totalRowsDeleted: 0 }) + toast.success('The job_run_details table is empty.') + return + } + + const totalBatches = Math.ceil(totalPages / CTID_BATCH_PAGE_SIZE) + let totalRowsDeleted = 0 + + // Step 2: Iterate through pages in batches + for (let batch = 0; batch < totalBatches; batch++) { + // Check for cancellation + if (cancelledRef.current) { + setCleanupState({ status: 'idle' }) + toast.info('Deletion cancelled.') + return + } + + const startPage = batch * CTID_BATCH_PAGE_SIZE + const endPage = Math.min((batch + 1) * CTID_BATCH_PAGE_SIZE, totalPages + 1) + + setCleanupState({ + status: 'deleting', + progress: { + currentBatch: batch + 1, + totalBatches, + totalRowsDeleted, + }, + }) + + const deleteResult = await executeSql({ + projectRef, + connectionString, + sql: getDeleteOldCronJobRunDetailsByCtidSql(interval, startPage, endPage), + queryKey: getDeleteOldCronJobRunDetailsByCtidKey(projectRef, interval, startPage), + }) + + const deletedCount = deleteResult.result?.[0]?.deleted_count ?? 0 + totalRowsDeleted += deletedCount + + if (cancelledRef.current) { + setCleanupState({ status: 'idle' }) + toast.info('Deletion cancelled.') + return + } + + if (batch < totalBatches - 1) { + await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS)) + } + } + + setCleanupState({ status: 'delete-success', totalRowsDeleted }) + toast.success( + `Deleted ${totalRowsDeleted.toLocaleString()} cron job runs older than ${interval}.` + ) + } catch (error) { + console.error('[CronJobs] Batch deletion failed with error: %O', error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + setCleanupState({ status: 'delete-error', error: errorMessage }) + toast.error('Running the cleanup failed. Please try again.') + } + }, + [projectRef, connectionString, executeSql] + ) + + /** + * Schedule a daily cleanup job. + * This should only be called after a successful initial deletion. + */ + const scheduleCleanup = useCallback( + async (interval: string) => { + 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.') + } + }, + [projectRef, connectionString, executeSql] + ) + + /** + * Cancel an in-progress deletion. + */ + const cancelDeletion = useCallback(() => { + cancelledRef.current = true + setCleanupState({ status: 'idle' }) + }, []) + + return { + cleanupInterval, + setCleanupInterval, + cleanupState, + 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 new file mode 100644 index 00000000000..b982135674f --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts @@ -0,0 +1,279 @@ +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 type { ResponseError } from 'types' +import { CRON_JOBS_THRESHOLD, JOB_RUN_DETAILS_THRESHOLD } from './CronJobsTab.constants' + +// ============================================================================= +// Input types +// ============================================================================= + +type UseCronJobsDataParams = ConnectionVars & { + searchQuery: string | null +} + +// ============================================================================= +// Shared state types (available in all states) +// ============================================================================= + +interface CronJobsGridState { + rows: Array + isLoading: boolean + error: ResponseError | null + isRefetching: boolean + isFetchingNextPage: boolean + hasNextPage: boolean + refetch: () => void + fetchNextPage: () => void +} + +interface CronJobsCountState { + value: number | undefined + isEstimate: boolean + 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, + searchQuery, +}: 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, + refetch: refetchCronJobs, + fetchNextPage, + } = useCronJobsInfiniteQuery( + { projectRef, connectionString, searchTerm: searchQuery ?? undefined }, + { + placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, + staleTime: Infinity, + enabled: isQueryEnabled, + } + ) + + const cronJobs = useMemo( + () => cronJobsData?.pages.flatMap((page) => page) ?? [], + [cronJobsData?.pages] + ) + + // Fetch count - gated on cron.job table size + // Always fetch the estimate first (it's fast since it uses pg_stat) + const { + data: estimatedCount, + isPending: isLoadingEstimatedCount, + isError: isEstimatedCountError, + } = useCronJobsCountEstimateQuery({ projectRef, connectionString }, { enabled: isProjectReady }) + + const hasLargeCronJobsTable = + typeof estimatedCount === 'number' && estimatedCount > CRON_JOBS_THRESHOLD + + // Exact count is enabled when the cron.job table is small enough + const isCountQueryEnabled = + isProjectReady && !isLoadingEstimatedCount && !isEstimatedCountError && !hasLargeCronJobsTable + + const { data: exactCount, isPending: isLoadingExactCount } = useCronJobsCountQuery( + { projectRef, connectionString }, + { 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, + }, + + count: { + value: isCountQueryEnabled ? exactCount : estimatedCount, + isEstimate: !isCountQueryEnabled, + isLoading: isCountQueryEnabled ? isLoadingExactCount : isLoadingEstimatedCount, + }, + } +} + +// ============================================================================= +// Type exports for consumers +// ============================================================================= + +export type { CronJobsCountState, CronJobsDataStatus, CronJobsGridState } diff --git a/apps/studio/data/common.types.ts b/apps/studio/data/common.types.ts index 5f0415c52d6..98d66a769df 100644 --- a/apps/studio/data/common.types.ts +++ b/apps/studio/data/common.types.ts @@ -1,4 +1,4 @@ export type ConnectionVars = { projectRef?: string - connectionString?: string + connectionString?: string | null } 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 new file mode 100644 index 00000000000..89b40815049 --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts @@ -0,0 +1,67 @@ +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-count-estimate-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-count-estimate-query.ts new file mode 100644 index 00000000000..cf3686d0b8d --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-count-estimate-query.ts @@ -0,0 +1,53 @@ +import { 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 DatabaseCronJobsCountEstimateVariables = ConnectionVars + +const cronJobsCountEstimateSql = getLiveTupleEstimate('job', 'cron') +const cronJobsCountEstimateKey = (projectRef: string | undefined) => + getLiveTupleEstimateKey(projectRef, 'job', 'cron') + +export async function getCronJobsCountEstimate({ + projectRef, + connectionString, +}: DatabaseCronJobsCountEstimateVariables) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql>({ + projectRef, + connectionString, + sql: cronJobsCountEstimateSql, + queryKey: cronJobsCountEstimateKey(projectRef), + }) + + return result?.[0]?.live_tuple_estimate +} + +export type DatabaseCronJobsCountEstimateData = Awaited> +export type DatabaseCronJobsCountEstimateError = Error + +export const useCronJobsCountEstimateQuery = ( + { projectRef, connectionString }: DatabaseCronJobsCountEstimateVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + DatabaseCronJobsCountEstimateData, + DatabaseCronJobsCountEstimateError, + TData + > = {} +) => + useQuery({ + queryKey: cronJobsCountEstimateKey(projectRef), + queryFn: () => getCronJobsCountEstimate({ projectRef, connectionString }), + enabled: enabled && projectRef !== undefined, + staleTime: 5 * 60 * 1000, + ...options, + }) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts index e30bc633db0..747b181ffb8 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-runs-infinite-query.ts @@ -31,16 +31,18 @@ export async function getDatabaseCronJobRuns({ projectRef, connectionString, jobId, - afterTimestamp, -}: DatabaseCronJobRunsVariables & { afterTimestamp: string | undefined }) { + afterRunId, +}: DatabaseCronJobRunsVariables & { afterRunId: number | undefined }) { if (!projectRef) throw new Error('Project ref is required') + // Use runid for ordering and pagination since it's the primary key (indexed) + // and preserves chronological order (auto-incrementing) let query = ` SELECT * FROM cron.job_run_details WHERE jobid = '${jobId}' - ${afterTimestamp ? `AND start_time < '${afterTimestamp}'` : ''} - ORDER BY start_time DESC + ${typeof afterRunId === 'number' ? `AND runid < '${afterRunId}'` : ''} + ORDER BY runid DESC LIMIT ${CRON_JOB_RUNS_PAGE_SIZE}` const { result } = await executeSql({ @@ -64,26 +66,25 @@ export const useCronJobRunsInfiniteQuery = ( DatabaseCronJobError, InfiniteData, readonly unknown[], - string | undefined + number | undefined > = {} ) => useInfiniteQuery({ - queryKey: databaseCronJobsKeys.runsInfinite(projectRef, jobId, { status }), + queryKey: databaseCronJobsKeys.runsInfinite(projectRef, jobId), queryFn: ({ pageParam }) => { return getDatabaseCronJobRuns({ projectRef, connectionString, jobId, - afterTimestamp: pageParam, + afterRunId: pageParam, }) }, staleTime: 0, enabled: enabled && typeof projectRef !== 'undefined', initialPageParam: undefined, getNextPageParam(lastPage) { - const hasNextPage = lastPage.length <= CRON_JOB_RUNS_PAGE_SIZE - if (!hasNextPage) return undefined - return last(lastPage)?.start_time + if (lastPage.length < CRON_JOB_RUNS_PAGE_SIZE) return undefined + return last(lastPage)?.runid }, ...options, }) diff --git a/apps/studio/data/sql/queries/delete-cron-job-run-details.ts b/apps/studio/data/sql/queries/delete-cron-job-run-details.ts new file mode 100644 index 00000000000..8fb28e27c59 --- /dev/null +++ b/apps/studio/data/sql/queries/delete-cron-job-run-details.ts @@ -0,0 +1,91 @@ +import { literal } from '@supabase/pg-meta/src/pg-format' + +import { sqlKeys } from '../keys' + +const CRON_CLEANUP_SCHEDULE_NAME = 'delete-job-run-details' +const CRON_CLEANUP_SCHEDULE_EXPRESSION = '0 12 * * *' + +// Number of pages to process in each batch for ctid-based deletion +// Based on default Postgres shared buffer size of 128 MB, which fits ~16k pages +export const CTID_BATCH_PAGE_SIZE = 5_000 + +/** + * Get the total number of pages in the job_run_details table. + * This is used to iterate through the table in batches using ctid ranges. + */ +export const getJobRunDetailsPageCountSql = () => + ` +SELECT pg_relation_size(oid) / current_setting('block_size')::int8 AS num_pages +FROM pg_class +WHERE relname = 'job_run_details' + AND relnamespace = 'cron'::regnamespace; +`.trim() + +export const getJobRunDetailsPageCountKey = (projectRef: string | undefined) => + sqlKeys.query(projectRef, ['cron-job-run-details', 'page-count']) + +/** + * Validates that a value is a finite non-negative integer. + */ +function validatePageNumber(value: number, name: string): void { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a finite non-negative integer, got: ${value}`) + } +} + +/** + * Delete old cron job run details using ctid range filtering. + * This approach: + * 1. Only scans a bounded range of pages (not the full table) + * 2. Avoids buffer cache pollution by processing in chunks + * 3. Allows other queries to proceed between batches + * + * @param interval - The age threshold (e.g., '7 days') + * @param startPage - The starting page number (inclusive) + * @param endPage - The ending page number (exclusive) + * @returns SQL that deletes matching rows and returns the count of deleted rows + */ +export const getDeleteOldCronJobRunDetailsByCtidSql = ( + interval: string, + startPage: number, + endPage: number +) => { + validatePageNumber(startPage, 'startPage') + validatePageNumber(endPage, 'endPage') + + // After validation, these are guaranteed to be safe integers + // Using literal() on the string representation ensures proper escaping + const safeCtidStart = literal(`(${startPage},0)`) + const safeCtidEnd = literal(`(${endPage},0)`) + + return ` +WITH deleted AS ( + DELETE FROM cron.job_run_details + WHERE ctid >= ${safeCtidStart}::tid + AND ctid < ${safeCtidEnd}::tid + AND end_time < now() - interval ${literal(interval)} + RETURNING 1 +) +SELECT count(*) as deleted_count FROM deleted; +`.trim() +} + +export const getDeleteOldCronJobRunDetailsByCtidKey = ( + projectRef: string | undefined, + interval: string, + startPage: number +) => sqlKeys.query(projectRef, ['cron-job-run-details', 'delete-batch', interval, startPage]) + +export const getScheduleDeleteCronJobRunDetailsSql = (interval: string) => + ` +SELECT cron.schedule( + ${literal(CRON_CLEANUP_SCHEDULE_NAME)}, + ${literal(CRON_CLEANUP_SCHEDULE_EXPRESSION)}, + $$DELETE FROM cron.job_run_details WHERE end_time < now() - interval ${literal(interval)}$$ +); +`.trim() + +export const getScheduleDeleteCronJobRunDetailsKey = ( + projectRef: string | undefined, + interval: string +) => sqlKeys.query(projectRef, ['cron-job-run-details', 'schedule', interval]) diff --git a/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts b/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts new file mode 100644 index 00000000000..dc894ff1f92 --- /dev/null +++ b/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts @@ -0,0 +1,29 @@ +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/lib/semver.test.ts b/apps/studio/lib/semver.test.ts index 2b134f8d5ad..73dbde89e44 100644 --- a/apps/studio/lib/semver.test.ts +++ b/apps/studio/lib/semver.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { - parseSemver, compareSemver, - isGreaterThan, - isLessThan, isEqual, + isGreaterThan, isGreaterThanOrEqual, + isLessThan, isLessThanOrEqual, isValidSemver, + parseSemver, } from './semver' describe('parseSemver', () => { @@ -17,14 +17,22 @@ describe('parseSemver', () => { expect(parseSemver('10.20.30')).toEqual({ major: 10, minor: 20, patch: 30 }) }) + it('should parse versions with fewer than 3 parts', () => { + expect(parseSemver('1')).toEqual({ major: 1, minor: 0, patch: 0 }) + expect(parseSemver('1.5')).toEqual({ major: 1, minor: 5, patch: 0 }) + }) + + it('should return null for versions with more than 3 parts', () => { + expect(parseSemver('1.2.3.4')).toBeNull() + expect(parseSemver('1.2.3.4.5')).toBeNull() + }) + it('should handle strings with extra whitespace', () => { expect(parseSemver(' 1.2.3 ')).toEqual({ major: 1, minor: 2, patch: 3 }) expect(parseSemver(' 1.2.3 ')).toEqual({ major: 1, minor: 2, patch: 3 }) }) it('should return null for invalid semver strings', () => { - expect(parseSemver('1.2')).toBeNull() - expect(parseSemver('1.2.3.4')).toBeNull() expect(parseSemver('1.2.x')).toBeNull() expect(parseSemver('a.b.c')).toBeNull() expect(parseSemver('')).toBeNull() @@ -68,7 +76,6 @@ describe('compareSemver', () => { it('should return null for invalid versions', () => { expect(compareSemver('1.2.3', 'invalid')).toBeNull() expect(compareSemver('invalid', '1.2.3')).toBeNull() - expect(compareSemver('1.2', '1.2.3')).toBeNull() }) it('should prioritize major version differences', () => { @@ -80,6 +87,21 @@ describe('compareSemver', () => { expect(compareSemver('1.3.0', '1.2.99')).toBe(1) expect(compareSemver('1.2.99', '1.3.0')).toBe(-1) }) + + it('should compare variable-length versions treating missing parts as 0', () => { + expect(compareSemver('1.5', '1.5.0')).toBe(0) + expect(compareSemver('1.5.0', '1.5')).toBe(0) + expect(compareSemver('1', '1.0.0')).toBe(0) + expect(compareSemver('1.5', '1.5.1')).toBe(-1) + expect(compareSemver('1.5.1', '1.5')).toBe(1) + expect(compareSemver('2', '1.9.9')).toBe(1) + expect(compareSemver('1.9.9', '2')).toBe(-1) + }) + + it('should return null for versions with more than 3 parts', () => { + expect(compareSemver('1.2.3.4', '1.2.3')).toBeNull() + expect(compareSemver('1.2.3', '1.2.3.4')).toBeNull() + }) }) describe('isGreaterThan', () => { @@ -135,6 +157,16 @@ describe('isGreaterThanOrEqual', () => { expect(isGreaterThanOrEqual('1.0.0', '2.0.0')).toBe(false) expect(isGreaterThanOrEqual('1.2.3', 'invalid')).toBe(false) }) + + it('should handle variable-length versions', () => { + expect(isGreaterThanOrEqual('1.5', '1.5')).toBe(true) + expect(isGreaterThanOrEqual('1.6', '1.5')).toBe(true) + expect(isGreaterThanOrEqual('1.4', '1.5')).toBe(false) + expect(isGreaterThanOrEqual('1.5.0', '1.5')).toBe(true) + expect(isGreaterThanOrEqual('1.5.1', '1.5')).toBe(true) + expect(isGreaterThanOrEqual('2', '1.5')).toBe(true) + expect(isGreaterThanOrEqual('1', '1.5')).toBe(false) + }) }) describe('isLessThanOrEqual', () => { @@ -157,10 +189,18 @@ describe('isValidSemver', () => { expect(isValidSemver('10.20.30')).toBe(true) }) - it('should return false for invalid semver strings', () => { - expect(isValidSemver('1.2')).toBe(false) + it('should return true for versions with 1-3 parts', () => { + expect(isValidSemver('1')).toBe(true) + expect(isValidSemver('1.2')).toBe(true) + }) + + it('should return false for versions with more than 3 parts', () => { expect(isValidSemver('1.2.3.4')).toBe(false) + }) + + it('should return false for invalid semver strings', () => { expect(isValidSemver('invalid')).toBe(false) expect(isValidSemver('')).toBe(false) + expect(isValidSemver('1.x.3')).toBe(false) }) }) diff --git a/apps/studio/lib/semver.ts b/apps/studio/lib/semver.ts index 969e8d83ff3..b747d593343 100644 --- a/apps/studio/lib/semver.ts +++ b/apps/studio/lib/semver.ts @@ -1,5 +1,6 @@ /** - * Semantic versioning utility for comparing version strings in the format "x.x.x" + * Semantic versioning utility for comparing version strings. + * Accepts 1-3 parts (e.g., "1", "1.5", "1.5.0"). Missing parts default to 0. */ export interface SemverVersion { @@ -9,7 +10,8 @@ export interface SemverVersion { } /** - * Parses a semver string in the format "x.x.x" into its components + * Parses a semver string into its components. + * Accepts 1-3 parts (e.g., "1", "1.5", "1.5.0"). Missing parts default to 0. * @param version - The version string to parse (e.g., "1.2.3") * @returns The parsed version components or null if invalid */ @@ -20,27 +22,30 @@ export function parseSemver(version: string): SemverVersion | null { const parts = version.trim().split('.') - if (parts.length !== 3) { + if (parts.length === 0 || parts.length > 3) { return null } - const major = parseInt(parts[0], 10) - const minor = parseInt(parts[1], 10) - const patch = parseInt(parts[2], 10) + const numbers = parts.map((p) => parseInt(p, 10)) - if (isNaN(major) || isNaN(minor) || isNaN(patch)) { + if (numbers.some(isNaN)) { return null } - if (major < 0 || minor < 0 || patch < 0) { + if (numbers.some((n) => n < 0)) { return null } - return { major, minor, patch } + return { + major: numbers[0], + minor: numbers[1] ?? 0, + patch: numbers[2] ?? 0, + } } /** - * Compares two semver version strings + * Compares two semver version strings. + * Missing parts are treated as 0 (e.g., "1.5" equals "1.5.0"). * @param a - First version string * @param b - Second version string * @returns -1 if a < b, 0 if a === b, 1 if a > b, or null if either version is invalid 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 66b2a046845..0e83542b46e 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 @@ -6,6 +6,7 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra 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 type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' @@ -23,6 +24,8 @@ 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 8ca9eb94dde..421bb715e9b 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -8,6 +8,7 @@ import { useInstalledIntegrations } from 'components/interfaces/Integrations/Lan 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 type { NextPageWithLayout } from 'types' import { @@ -43,6 +44,8 @@ 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 new file mode 100644 index 00000000000..6876549ba12 --- /dev/null +++ b/e2e/studio/features/cron-jobs.spec.ts @@ -0,0 +1,336 @@ +import { expect, Page } from '@playwright/test' +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' + +const cronJobName = 'pw_cron_test_job' + +/** + * Helper to enable the pg_cron extension if it's not already enabled. + * Must be called when on the cron overview page. + */ +const ensurePgCronEnabled = async (page: Page) => { + // Wait for the page content to load and the Required extensions section to appear + await page.waitForSelector('h1:has-text("Cron")', { timeout: 30000 }) + await expect(page.getByRole('heading', { name: 'Required extensions' })).toBeVisible({ + timeout: 30000, + }) + + // Wait for the page to finish loading the extension status + // Look for the listitem containing pg_cron - it will have either "Enable pg_cron" button or "Installed" text + const pgCronListItem = page + .getByRole('listitem') + .filter({ has: page.locator('code:has-text("pg_cron")') }) + const enableButton = pgCronListItem.getByRole('button', { name: 'Enable pg_cron' }) + const installedText = pgCronListItem.getByText('Installed') + await expect(enableButton.or(installedText)).toBeVisible({ timeout: 30000 }) + + // Check if we need to enable pg_cron + if ((await enableButton.count()) > 0) { + await enableButton.click() + + // Wait for the extension enable modal + await expect(page.getByRole('heading', { name: 'Enable pg_cron' })).toBeVisible({ + timeout: 5000, + }) + + await page.getByRole('button', { name: 'Enable extension' }).click() + + // Wait for success toast + await expect(page.getByText(/Extension.*pg_cron.*is now enabled/)).toBeVisible({ + timeout: 15000, + }) + } + + await expect(page.getByRole('link', { name: 'Jobs' })).toBeVisible({ timeout: 10000 }) +} + +/** + * Helper to navigate to the cron overview page and ensure pg_cron is enabled. + */ +const navigateToCronOverviewAndEnable = async (page: Page, ref: string) => { + await page.goto(toUrl(`/project/${ref}/integrations/cron/overview`)) + await ensurePgCronEnabled(page) +} + +/** + * Helper to navigate to the cron jobs page and wait for it to load. + * Assumes pg_cron is already enabled. + */ +const navigateToCronJobsPage = async (page: Page, ref: string) => { + await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`)) + await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 }) +} + +/** + * Helper to delete a cron job by name. + * The delete modal requires typing the job name for confirmation. + * Uses right-click context menu to access the delete option. + * Waits for the row to be removed from the grid after deletion. + */ +const deleteCronJob = async (page: Page, jobName: string) => { + // Find the row and right-click to open context menu + const jobRow = page.getByRole('row', { name: new RegExp(jobName) }) + await jobRow.click({ button: 'right' }) + + // Click "Delete job" in the context menu + await page.getByRole('menuitem', { name: 'Delete job' }).click() + + // The modal requires typing the job name - fill it in + await expect(page.getByRole('heading', { name: 'Delete this cron job' })).toBeVisible() + await page.getByPlaceholder('Type in name of cron job').fill(jobName) + + // Click the delete button + await page.getByRole('button', { name: `Delete cron job ${jobName}` }).click() + + // Wait for success toast and for the row to be removed from the grid + await expect(page.getByText(/Successfully removed cron job/)).toBeVisible({ timeout: 10000 }) + 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', () => { + test.describe('Cron Jobs CRUD Operations', () => { + let page: Page + + test.beforeAll(async ({ browser, ref }) => { + page = await browser.newPage() + + // Navigate to cron overview first to ensure extension is enabled + await navigateToCronOverviewAndEnable(page, ref) + + // Navigate to jobs page + await navigateToCronJobsPage(page, ref) + + // Clean up any existing test jobs + while ((await page.getByRole('row', { name: new RegExp(cronJobName) }).count()) > 0) { + await deleteCronJob(page, cronJobName) + } + }) + + test.afterAll(async () => { + // Clean up test jobs + try { + while ((await page.getByRole('row', { name: new RegExp(cronJobName) }).count()) > 0) { + await deleteCronJob(page, cronJobName) + } + } catch { + // Ignore cleanup errors + } + }) + + test('can view cron jobs page', async ({ ref }) => { + await navigateToCronJobsPage(page, ref) + + // Verify the page elements + await expect(page.getByRole('button', { name: 'Create job' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Refresh' })).toBeVisible() + await expect(page.getByPlaceholder('Search for a job')).toBeVisible() + }) + + test('can create a new cron job', async ({ ref }) => { + await navigateToCronJobsPage(page, ref) + + // Click create job button + await page.getByRole('button', { name: 'Create job' }).click() + + // Wait for the dialog to open + await expect(page.getByRole('heading', { name: 'Create a new cron job' })).toBeVisible() + + // Fill in job name using the input name attribute + await page.locator('input[name="name"]').fill(cronJobName) + + // Click the "Every minute" preset button to set schedule + await page.getByRole('button', { name: 'Every minute' }).click() + + // Fill in the SQL command using the Monaco editor + await page.getByRole('code').click() + await page.getByRole('textbox', { name: /Editor content/ }).fill("SELECT 'test';") + + // Save the job + await page.getByRole('button', { name: 'Create cron job' }).click() + + // Wait for success toast + await expect(page.getByText(/Successfully created cron job/)).toBeVisible({ timeout: 10000 }) + + // Verify the job appears in the list + await expect(page.getByRole('row', { name: new RegExp(cronJobName) })).toBeVisible() + }) + + test('can search for cron jobs', async ({ ref }) => { + await navigateToCronJobsPage(page, ref) + + // Search for the test job + const searchInput = page.getByPlaceholder('Search for a job') + await searchInput.fill(cronJobName) + await searchInput.press('Enter') + + // Should find the job + await expect(page.getByRole('row', { name: new RegExp(cronJobName) })).toBeVisible() + + // Search for non-existent job + await searchInput.fill('nonexistent_job_xyz_12345') + await searchInput.press('Enter') + + // Should show empty state + await expect(page.getByText('No cron jobs found')).toBeVisible() + + // Clear search by clearing the input + await searchInput.clear() + await searchInput.press('Enter') + }) + + test('can edit a cron job', async ({ ref }) => { + await navigateToCronJobsPage(page, ref) + + // Find the test job row and right-click to open context menu + const jobRow = page.getByRole('row', { name: new RegExp(cronJobName) }) + await jobRow.click({ button: 'right' }) + + // Click "Edit job" in the context menu + await page.getByRole('menuitem', { name: 'Edit job' }).click() + + // Wait for the edit sheet to open + await expect(page.getByRole('heading', { name: `Edit ${cronJobName}` })).toBeVisible() + + // Note: Job names cannot be changed after creation, so we'll verify we can change the schedule + // Click a different schedule preset + await page.getByRole('button', { name: 'Every 5 minutes' }).click() + + // Save the changes + await page.getByRole('button', { name: 'Save cron job' }).click() + + // Wait for success toast + await expect(page.getByText(/Successfully updated cron job/)).toBeVisible({ timeout: 10000 }) + }) + + test('can view cron job run history', async ({ ref }) => { + await navigateToCronJobsPage(page, ref) + + // Click on the job row to view history (click on the name cell, not the action button) + const jobRow = page.getByRole('row', { name: new RegExp(cronJobName) }) + await jobRow.getByRole('gridcell', { name: cronJobName }).click() + + // Should navigate to the job detail page + await page.waitForURL(/.*\/integrations\/cron\/jobs\/\d+/) + + // Verify we're on the job history page - should show the job name in the heading + await expect(page.getByRole('heading', { name: cronJobName })).toBeVisible() + + // Go back to jobs list + await page.goBack() + await page.waitForLoadState('networkidle') + }) + + test('can delete a cron job', async ({ ref }) => { + await navigateToCronJobsPage(page, ref) + + // Delete the test job + await deleteCronJob(page, cronJobName) + + // Verify the job is no longer in the list + await expect(page.getByRole('row', { name: new RegExp(cronJobName) })).not.toBeVisible() + }) + }) + + test.describe('Cron Jobs Page Features', () => { + test('refresh button reloads cron jobs', async ({ page, ref }) => { + await navigateToCronOverviewAndEnable(page, ref) + await navigateToCronJobsPage(page, ref) + + // Click refresh - it should not throw an error + await page.getByRole('button', { name: 'Refresh' }).click() + + // The grid should still be visible after refresh + await expect(page.getByRole('grid')).toBeVisible() + }) + + test('navigation tabs work correctly', async ({ page, ref }) => { + await navigateToCronOverviewAndEnable(page, ref) + + // Click on Jobs tab + await page.getByRole('link', { name: 'Jobs' }).click() + await page.waitForURL(/.*\/integrations\/cron\/jobs/) + + // Verify we're on the jobs page + await expect(page.getByRole('button', { name: 'Create job' })).toBeVisible() + + // Click on Overview tab (use exact match to avoid ambiguity) + await page.getByRole('link', { name: 'Overview', exact: true }).click() + await page.waitForURL(/.*\/integrations\/cron\/overview/) + + // Verify we're on the overview page + await expect(page.getByText('Schedule recurring Jobs in Postgres')).toBeVisible() + }) + }) +}) + +test.describe('Large Table Overflow Notice', () => { + const cleanupJobName = 'delete-job-run-details' + + test('shows cleanup notice and allows cleanup workflow', async ({ page, ref }) => { + // First ensure pg_cron is enabled (without any mocks) + await navigateToCronOverviewAndEnable(page, ref) + + // Now set up the mock for the row count estimate + 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')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ live_tuple_estimate: 500000 }]), + }) + } else { + await route.continue() + } + }) + + // Navigate to the cron jobs page (this will trigger the mocked estimate query) + 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({ + timeout: 15000, + }) + + // Should show the estimated row count in the description + await expect(page.getByText(/approximately 500,000 rows/)).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 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() + + // 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 + await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({ timeout: 30000 }) + + // Step 2 should now be enabled - click "Schedule cleanup job" + await page.getByRole('button', { name: 'Schedule cleanup job' }).click() + + // Wait for Step 2 to complete - should show success message + await expect(page.getByText('Daily cleanup job scheduled successfully')).toBeVisible({ + timeout: 15000, + }) + + // Clean up: remove the route mock and delete the scheduled cleanup job + 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() + }) +})