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).
This commit is contained in:
Charis authored and GitHub committed 2026-01-22 11:17:49 -05:00
1 parent ee25ba20dc
commit d34cc65e30
20 files changed
+1769 -231

No files matched your search

@@ -228,9 +228,9 @@ export const formatCronJobColumns = ({
}: {
onSelectEdit: (job: CronJob) => void
onSelectDelete: (job: CronJob) => void
}) => {
}): Array<Column<CronJob>> => {
return CRON_TABLE_COLUMNS.map((col) => {
const res: Column<any> = {
const res: Column<CronJob> = {
key: col.id,
name: col.name,
minWidth: col.minWidth ?? 100,
@@ -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 (
<Admonition
type="warning"
title={noticeTitle}
description={noticeDescription}
className="max-w-3xl w-full"
>
<div className="space-y-4 text-sm">
<p>
Remove old run history now, then schedule a cron job that keeps trimming{' '}
<code>cron.job_run_details</code> automatically so the overview remains responsive.
</p>
{/* Step 1: Delete older entries */}
<div className="space-y-2">
<p className="font-medium text-foreground">Step 1: Delete older entries</p>
{isDeleting ? (
<DeletionProgress progress={cleanupState.progress} onCancel={onCancelDeletion} />
) : isDeleteSuccess ? (
<DeletionSuccess totalRowsDeleted={cleanupState.totalRowsDeleted} />
) : isDeleteError ? (
<DeletionError error={cleanupState.error} onRetry={onRetryDeletion} />
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="sm:w-64">
<Select_Shadcn_
value={selectedInterval}
onValueChange={onIntervalChange}
disabled={isBusy}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select an interval" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{CLEANUP_INTERVALS.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
<Button type="default" disabled={isBusy} onClick={onRunDeleteSql}>
Delete rows now
</Button>
</div>
)}
</div>
{/* Step 2: Schedule automated cleanup (only available after successful deletion) */}
<div className="space-y-2">
<p className="font-medium text-foreground">Step 2: Schedule an automated cleanup</p>
{!canSchedule ? (
<p className="text-foreground-lighter text-xs">
Complete step 1 to enable scheduling a daily cleanup job.
</p>
) : isScheduleSuccess ? (
<ScheduleSuccess onRefresh={onRefresh} />
) : (
<>
<CodeBlock
hideLineNumbers
language="sql"
value={getScheduleDeleteCronJobRunDetailsSql(selectedInterval)}
className="py-3 px-4 text-xs"
wrapperClassName="max-w-full"
/>
<Button
type="default"
className="mt-1"
loading={isScheduling}
disabled={isScheduling}
onClick={onRunScheduleSql}
>
Schedule cleanup job
</Button>
</>
)}
</div>
</div>
</Admonition>
)
}
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 (
<div className="space-y-2">
<div className="flex items-center gap-3">
<Progress value={percentComplete} className="flex-1 h-2" />
<span className="text-xs text-foreground-light whitespace-nowrap">
{percentComplete}% ({currentBatch}/{totalBatches} batches)
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-foreground-light">
Deleted {totalRowsDeleted.toLocaleString()} rows so far...
</span>
<Button type="outline" size="tiny" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
)
}
interface DeletionSuccessProps {
totalRowsDeleted: number
}
const DeletionSuccess = ({ totalRowsDeleted }: DeletionSuccessProps) => (
<div className="flex items-center gap-2 text-brand">
<CheckCircle2 size={16} />
<span className="text-sm">Successfully deleted {totalRowsDeleted.toLocaleString()} rows.</span>
</div>
)
interface DeletionErrorProps {
error: string
onRetry: () => void
}
const DeletionError = ({ error, onRetry }: DeletionErrorProps) => (
<div className="space-y-2">
<div className="flex items-center gap-2 text-destructive">
<XCircle size={16} />
<span className="text-sm">Deletion failed: {error}</span>
</div>
<Button type="default" size="small" onClick={onRetry}>
Retry
</Button>
</div>
)
interface ScheduleSuccessProps {
onRefresh: () => void
}
const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (
<div className="space-y-2">
<div className="flex items-center gap-2 text-brand">
<CheckCircle2 size={16} />
<span className="text-sm">Daily cleanup job scheduled successfully.</span>
</div>
<div className="flex items-center gap-2">
<p className="text-foreground-lighter text-xs">
Refresh to reload the cron jobs and view the new cleanup job.
</p>
<Button type="default" size="tiny" icon={<RefreshCw size={14} />} onClick={onRefresh} />
</div>
</div>
)
@@ -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<CronJob>[]
rows: CronJob[]
isLoading: boolean
error: ResponseError | Error | null
searchQuery?: string | null
onScroll: (event: UIEvent<HTMLDivElement>) => void
onRowClick: (row: CronJob, event: MouseEvent<HTMLDivElement>) => void
overlay?: ReactNode
}
export const CronJobsTabDataGrid = ({
columns,
rows,
isLoading,
error,
searchQuery,
onScroll,
onRowClick,
overlay,
}: CronJobsTabDataGridProps) => {
const fallbackContent = overlay ? (
<div className="absolute top-20 px-6 w-full flex justify-center">{overlay}</div>
) : isLoading ? (
<div className="absolute top-12 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : error ? (
<div className="absolute top-28 px-10 flex flex-col items-center justify-center w-full">
<AlertError subject="Failed to retrieve cron jobs" error={error} />
</div>
) : (
<div className="absolute top-32 px-6 w-full">
<div className="text-center text-sm flex flex-col gap-y-1">
<p className="text-foreground">
{!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'}
</p>
<p className="text-foreground-light">
{!!searchQuery
? 'There are currently no cron jobs based on the search applied'
: 'There are currently no cron jobs created yet in your project'}
</p>
</div>
</div>
)
return (
<DataGrid
className="flex-grow border-t-0"
rowHeight={44}
headerRowHeight={36}
columns={columns}
rows={rows}
onScroll={onScroll}
rowKeyGetter={(row) => 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 (
<Row
key={props.row.jobid}
{...props}
onClick={(event) => {
onRowClick(props.row, event)
}}
/>
)
},
noRowsFallback: fallbackContent,
}}
/>
)
}
@@ -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 (
<Admonition
type="warning"
title="Error displaying cron jobs"
description="There was an error displaying cron jobs. Please try again."
className="max-w-3xl w-full"
>
<div className="space-y-3 text-sm">
{error?.message && (
<p className="text-foreground-light break-words">
Error message: <code className="text-xs">{error.message}</code>
</p>
)}
{onRetry && (
<Button
type="default"
loading={isRetrying}
disabled={isRetrying}
className="mt-1"
onClick={onRetry}
>
Retry check
</Button>
)}
</div>
</Admonition>
)
}
@@ -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<HTMLInputElement>) => {
if (event.key === 'Enter' || event.code === 'NumpadEnter') {
onSearchSubmit()
}
}
return (
<div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap gap-y-2">
<Input
size="tiny"
className="w-52"
placeholder="Search for a job"
icon={<Search />}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={handleKeyDown}
actions={[
search && (
<Button
key="clear-search"
size="tiny"
type="text"
icon={<X />}
onClick={onClearSearch}
className="p-0 h-5 w-5"
/>
),
]}
/>
<div className="flex items-center gap-x-2">
<Button type="default" icon={<RefreshCw />} loading={isRefreshing} onClick={onRefresh}>
Refresh
</Button>
<Button onClick={onCreateJob}>Create job</Button>
</div>
</div>
)
}
@@ -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' },
]
@@ -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<number>(0)
const gridRef = useRef<DataGridHandle>(null)
// Track the ID being deleted to exclude it from error checking
const deletingCronJobIdRef = useRef<string | null>(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 (
<CronJobRunDetailsOverflowNotice
mode="confirmed"
estimatedRows={dataStatus.estimatedRows}
selectedInterval={cleanupInterval}
onIntervalChange={setCleanupInterval}
cleanupState={cleanupState}
onRunDeleteSql={() => runBatchedDeletion(cleanupInterval)}
onRunScheduleSql={() => scheduleCleanup(cleanupInterval)}
onCancelDeletion={cancelDeletion}
onRetryDeletion={() => runBatchedDeletion(cleanupInterval)}
onRefresh={grid.refetch}
/>
)
case 'overflow-suspected':
return (
<CronJobRunDetailsOverflowNotice
mode="suspected"
estimatedRows={dataStatus.estimatedRows}
selectedInterval={cleanupInterval}
onIntervalChange={setCleanupInterval}
cleanupState={cleanupState}
onRunDeleteSql={() => runBatchedDeletion(cleanupInterval)}
onRunScheduleSql={() => scheduleCleanup(cleanupInterval)}
onCancelDeletion={cancelDeletion}
onRetryDeletion={() => runBatchedDeletion(cleanupInterval)}
onRefresh={grid.refetch}
/>
)
case 'estimate-error':
return (
<CronJobRunDetailsEstimateErrorNotice
error={dataStatus.error}
isRetrying={dataStatus.isRetrying}
onRetry={dataStatus.retry}
/>
)
case 'loading':
case 'ready':
default:
return undefined
}
}, [
dataStatus,
cleanupInterval,
setCleanupInterval,
cleanupState,
runBatchedDeletion,
scheduleCleanup,
cancelDeletion,
grid.refetch,
])
const deletingCronJobIdRef = useRef<string | null>(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<number>(0)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 = () => {
<>
<div className="h-full w-full space-y-4">
<div className="h-full w-full flex flex-col relative">
<div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap">
<Input
size="tiny"
className="w-52"
placeholder="Search for a job"
icon={<Search />}
value={search ?? ''}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter' || e.code === 'NumpadEnter') setSearchQuery(search.trim())
}}
actions={[
search && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => {
setSearch('')
setSearchQuery(null)
}}
className="p-0 h-5 w-5"
/>
),
]}
/>
<div className="flex items-center gap-x-2">
<Button
type="default"
icon={<RefreshCw />}
loading={isRefetching && !isFetchingNextPage}
onClick={() => refetch()}
>
Refresh
</Button>
<Button onClick={onOpenCreateJobSheet}>Create job</Button>
</div>
</div>
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
<DataGrid
ref={gridRef}
className="flex-grow border-t-0"
rowHeight={44}
headerRowHeight={36}
columns={columns}
rows={cronJobs}
rowKeyGetter={(row) => 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 (
<Row
key={props.row.jobid}
{...props}
onClick={(e) => {
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)
}
}}
/>
)
},
}}
<CronJobsTabHeader
search={search}
isRefreshing={grid.isRefetching && !grid.isFetchingNextPage}
onSearchChange={setSearch}
onSearchSubmit={handleSearchSubmit}
onClearSearch={handleClearSearch}
onRefresh={grid.refetch}
onCreateJob={onOpenCreateJobSheet}
/>
{/* [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 ? (
<div className="absolute top-28 px-10 w-full">
<GenericSkeletonLoader />
</div>
) : isError ? (
<div className="absolute top-28 px-10 flex flex-col items-center justify-center w-full">
<AlertError subject="Failed to retrieve cron jobs" error={error} />
</div>
) : (
<div className="absolute top-32 px-6 w-full">
<div className="text-center text-sm flex flex-col gap-y-1">
<p className="text-foreground">
{!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'}
</p>
<p className="text-foreground-light">
{!!searchQuery
? 'There are currently no cron jobs based on the search applied'
: 'There are currently no cron jobs created yet in your project'}
</p>
</div>
</div>
)
) : null}
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
{isLoadingCount ? (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
) : (
`Total: ${count} jobs`
)}
</div>
<LoadingLine loading={grid.isLoading || grid.isRefetching || grid.isFetchingNextPage} />
<CronJobsTabDataGrid
columns={columns}
rows={grid.rows}
isLoading={grid.isLoading}
error={grid.error}
searchQuery={searchQuery}
onScroll={handleScroll}
onRowClick={handleRowClick}
overlay={gridOverlay}
/>
<CronJobsFooter count={count} />
</div>
</div>
@@ -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) => (
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
{count.isLoading ? (
<span className="flex items-center gap-2">
<Loader2 size={14} className="animate-spin" /> Loading...
</span>
) : (
`Total: ${count.value ?? 0} jobs${count.isEstimate ? ' (estimate)' : ''}`
)}
</div>
)
// Confirmation modal for unsaved changes
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
@@ -0,0 +1,205 @@
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,
getDeleteOldCronJobRunDetailsByCtidKey,
getDeleteOldCronJobRunDetailsByCtidSql,
getJobRunDetailsPageCountKey,
getJobRunDetailsPageCountSql,
getScheduleDeleteCronJobRunDetailsKey,
getScheduleDeleteCronJobRunDetailsSql,
} from 'data/sql/queries/delete-cron-job-run-details'
import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
// Delay between batches to allow other queries to proceed (in milliseconds)
const BATCH_DELAY_MS = 100
type UseCronJobsCleanupActionsOptions = ConnectionVars
export interface BatchDeletionProgress {
currentBatch: number
totalBatches: number
totalRowsDeleted: number
}
export type CleanupState =
| { status: 'idle' }
| { 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,
connectionString,
}: UseCronJobsCleanupActionsOptions) => {
const [cleanupInterval, setCleanupInterval] = useState(CLEANUP_INTERVALS[0].value)
const [cleanupState, setCleanupState] = useState<CleanupState>({ 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,
}
}
@@ -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<CronJob>
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 }
+1 -1
View File
@@ -1,4 +1,4 @@
export type ConnectionVars = {
projectRef?: string
connectionString?: string
connectionString?: string | null
}
@@ -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<typeof getCronJobRunDetailsEstimate>
>
export type DatabaseCronJobRunDetailsEstimateError = Error
export const useCronJobRunDetailsEstimateQuery = <TData = DatabaseCronJobRunDetailsEstimateData>(
{ projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<
DatabaseCronJobRunDetailsEstimateData,
DatabaseCronJobRunDetailsEstimateError,
TData
> = {}
) =>
useQuery<DatabaseCronJobRunDetailsEstimateData, DatabaseCronJobRunDetailsEstimateError, TData>({
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 }),
})
}
@@ -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<Array<{ live_tuple_estimate: number }>>({
projectRef,
connectionString,
sql: cronJobsCountEstimateSql,
queryKey: cronJobsCountEstimateKey(projectRef),
})
return result?.[0]?.live_tuple_estimate
}
export type DatabaseCronJobsCountEstimateData = Awaited<ReturnType<typeof getCronJobsCountEstimate>>
export type DatabaseCronJobsCountEstimateError = Error
export const useCronJobsCountEstimateQuery = <TData = DatabaseCronJobsCountEstimateData>(
{ projectRef, connectionString }: DatabaseCronJobsCountEstimateVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<
DatabaseCronJobsCountEstimateData,
DatabaseCronJobsCountEstimateError,
TData
> = {}
) =>
useQuery<DatabaseCronJobsCountEstimateData, DatabaseCronJobsCountEstimateError, TData>({
queryKey: cronJobsCountEstimateKey(projectRef),
queryFn: () => getCronJobsCountEstimate({ projectRef, connectionString }),
enabled: enabled && projectRef !== undefined,
staleTime: 5 * 60 * 1000,
...options,
})
@@ -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 = <TData = DatabaseCronJobRunData>(
DatabaseCronJobError,
InfiniteData<TData>,
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,
})
@@ -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])
@@ -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])
}
+49 -9
View File
@@ -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)
})
})
+15 -10
View File
@@ -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
@@ -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()
@@ -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()
+336
View File
@@ -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()
})
})