diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.test.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.test.tsx
new file mode 100644
index 00000000000..5cae4bee9a2
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.test.tsx
@@ -0,0 +1,138 @@
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { mockAnimationsApi } from 'jsdom-testing-mocks'
+import { HttpResponse } from 'msw'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { EnableCleanupButton } from './CronJobsTab.EnableCleanupButton'
+import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+import { routerMock } from '@/tests/lib/route-mock'
+
+mockAnimationsApi()
+
+const cleanupJobRow = {
+ jobid: 1,
+ jobname: 'delete-job-run-details',
+ schedule: '0 12 * * *',
+ command: `DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days';`,
+ active: true,
+}
+
+// Mutable state for the pg-meta mock, reset per test. Scheduling flips
+// cleanupJobExists so the subsequent existence refetch sees the new job,
+// mirroring the real invalidation flow.
+let cleanupJobExists = false
+let lookupCount = 0
+let scheduleQueries: string[] = []
+
+const renderButton = (onScheduled = vi.fn()) => {
+ customRender(
+
+
+
+ )
+ return onScheduled
+}
+
+describe('EnableCleanupButton', () => {
+ beforeEach(() => {
+ cleanupJobExists = false
+ lookupCount = 0
+ scheduleQueries = []
+
+ // useSelectedProjectQuery -> useParams
+ routerMock.setCurrentUrl('/project/default/integrations/cron/jobs')
+ // useSelectedProjectQuery
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects/:ref',
+ // @ts-expect-error partial project shape
+ response: {
+ cloud_provider: 'localhost',
+ id: 1,
+ inserted_at: '2021-08-02T06:40:40.646Z',
+ name: 'Default Project',
+ organization_id: 1,
+ ref: 'default',
+ region: 'local',
+ status: 'ACTIVE_HEALTHY',
+ },
+ })
+ // The existence lookup (useCronJobQuery by name) and the schedule mutation
+ // both go through the pg-meta query endpoint with different SQL
+ addAPIMock({
+ method: 'post',
+ path: '/platform/pg-meta/:ref/query',
+ response: async ({ request }) => {
+ const { query } = (await request.json()) as { query: string }
+
+ if (query.includes('cron.schedule')) {
+ scheduleQueries.push(query)
+ cleanupJobExists = true
+ return HttpResponse.json([{ schedule: 1 }])
+ }
+
+ // jobname lookup
+ lookupCount += 1
+ return HttpResponse.json(cleanupJobExists ? [cleanupJobRow] : [])
+ },
+ })
+ })
+
+ test('shows the button once the cleanup job is confirmed missing', async () => {
+ renderButton()
+
+ expect(await screen.findByRole('button', { name: 'Enable cleanup' })).toBeInTheDocument()
+ })
+
+ test('hides the button when the cleanup job already exists', async () => {
+ cleanupJobExists = true
+ renderButton()
+
+ await waitFor(() => expect(lookupCount).toBeGreaterThan(0))
+ expect(screen.queryByRole('button', { name: 'Enable cleanup' })).not.toBeInTheDocument()
+ })
+
+ test('schedules the cleanup job with the selected retention interval', async () => {
+ const onScheduled = renderButton()
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Enable cleanup' }))
+
+ const dialog = await screen.findByRole('dialog')
+ expect(within(dialog).getByText('Enable automatic cleanup')).toBeInTheDocument()
+ expect(within(dialog).getByRole('combobox')).toHaveTextContent('Older than 7 days')
+
+ await userEvent.click(within(dialog).getByRole('combobox'))
+ await userEvent.click(await screen.findByRole('option', { name: 'Older than 1 day' }))
+
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Enable cleanup' }))
+
+ await waitFor(() => expect(scheduleQueries).toHaveLength(1))
+ expect(scheduleQueries[0]).toContain(`'delete-job-run-details'`)
+ expect(scheduleQueries[0]).toContain(`'0 12 * * *'`)
+ expect(scheduleQueries[0]).toContain(`interval ''1 day''`)
+
+ await waitFor(() => expect(onScheduled).toHaveBeenCalledTimes(1))
+
+ // The mutation invalidates the existence query, which now returns the job,
+ // so the whole component (dialog included) unmounts
+ await waitFor(() => {
+ expect(screen.queryByRole('button', { name: 'Enable cleanup' })).not.toBeInTheDocument()
+ })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ test('cancel closes the dialog without scheduling', async () => {
+ renderButton()
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Enable cleanup' }))
+ const dialog = await screen.findByRole('dialog')
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(scheduleQueries).toHaveLength(0)
+ expect(screen.getByRole('button', { name: 'Enable cleanup' })).toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.tsx
new file mode 100644
index 00000000000..ab36c741524
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.tsx
@@ -0,0 +1,145 @@
+import { CRON_CLEANUP_JOB_NAME, getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogSectionSeparator,
+ DialogTitle,
+ DialogTrigger,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from 'ui'
+import { CodeBlock } from 'ui-patterns/CodeBlock'
+
+import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
+import { useCronJobQuery } from '@/data/database-cron-jobs/database-cron-job-query'
+import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { useTrack } from '@/lib/telemetry/track'
+
+const DEFAULT_CLEANUP_INTERVAL =
+ CLEANUP_INTERVALS.find((option) => option.value === '7 days')?.value ?? CLEANUP_INTERVALS[0].value
+
+interface EnableCleanupButtonProps {
+ onScheduled: () => void
+}
+
+/**
+ * One-click action to schedule the daily cleanup job that trims old rows from
+ * cron.job_run_details. Hidden once the cleanup job already exists — the job
+ * itself then shows up in the jobs grid.
+ */
+export const EnableCleanupButton = ({ onScheduled }: EnableCleanupButtonProps) => {
+ const track = useTrack()
+ const { data: project } = useSelectedProjectQuery()
+
+ const [open, setOpen] = useState(false)
+ const [cleanupInterval, setCleanupInterval] = useState(DEFAULT_CLEANUP_INTERVAL)
+
+ const { data: cleanupJob, isSuccess } = useCronJobQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ name: CRON_CLEANUP_JOB_NAME,
+ })
+
+ const { mutate: scheduleCleanup, isPending: isScheduling } =
+ useScheduleCronJobRunDetailsCleanupMutation({
+ onSuccess: () => {
+ toast.success('Scheduled daily cleanup job.')
+ setOpen(false)
+ onScheduled()
+ },
+ })
+
+ if (!isSuccess || cleanupJob !== null) return null
+
+ const onConfirm = () => {
+ if (!project?.ref) {
+ return toast.error('There was an error scheduling the cleanup. Please try again.')
+ }
+ track('cron_job_cleanup_enable_button_clicked', {
+ origin: 'dialog',
+ retentionInterval: cleanupInterval,
+ })
+ scheduleCleanup({
+ projectRef: project.ref,
+ connectionString: project.connectionString,
+ interval: cleanupInterval,
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx
index f023019118d..43a7f76b405 100644
--- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx
+++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx
@@ -3,6 +3,7 @@ import type { KeyboardEvent, Ref } from 'react'
import { Button } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
+import { EnableCleanupButton } from './CronJobsTab.EnableCleanupButton'
import { onSearchInputEscape } from '@/lib/keyboard'
interface CronJobsTabHeaderProps {
@@ -59,6 +60,7 @@ export const CronJobsTabHeader = ({
/>
+
} loading={isRefreshing} onClick={onRefresh}>
Refresh
diff --git a/apps/studio/data/database-cron-jobs/database-cron-job-query.ts b/apps/studio/data/database-cron-jobs/database-cron-job-query.ts
index 983f21bfdc9..9093e0ceffd 100644
--- a/apps/studio/data/database-cron-jobs/database-cron-job-query.ts
+++ b/apps/studio/data/database-cron-jobs/database-cron-job-query.ts
@@ -27,13 +27,13 @@ export async function getDatabaseCronJob({
sql: !!id
? safeSql`SELECT * FROM cron.job where jobid = ${literal(id)};`
: safeSql`SELECT * FROM cron.job where jobname = ${literal(name)};`,
- queryKey: ['cron-job', id],
+ queryKey: ['cron-job', id ?? name],
})
- return result[0]
+ return (result[0] ?? null) as CronJob | null
}
-export type DatabaseCronJobData = CronJob
+export type DatabaseCronJobData = CronJob | null
export type DatabaseCronJobError = ResponseError
export const useCronJobQuery = (
@@ -45,7 +45,7 @@ export const useCronJobQuery = (
) =>
useQuery({
queryKey: databaseCronJobsKeys.job(projectRef, id ?? name),
- queryFn: () => getDatabaseCronJob({ projectRef, connectionString, id }),
+ queryFn: () => getDatabaseCronJob({ projectRef, connectionString, id, name }),
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts
index ff052c32e11..62dbd83141e 100644
--- a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts
+++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts
@@ -48,19 +48,13 @@ export const useDatabaseCronJobCreateMutation = ({
return useMutation({
mutationFn: (vars) => createDatabaseCronJob(vars),
async onSuccess(data, variables, context) {
- const { projectRef, searchTerm, identifier } = variables
+ const { projectRef, searchTerm } = variables
await Promise.all([
+ queryClient.invalidateQueries({ queryKey: databaseCronJobsKeys.jobs(projectRef) }),
queryClient.invalidateQueries({
- queryKey: databaseCronJobsKeys.listInfinite(projectRef, searchTerm),
+ queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm),
}),
- ...(!!identifier
- ? [
- queryClient.invalidateQueries({
- queryKey: databaseCronJobsKeys.job(projectRef, identifier),
- }),
- ]
- : []),
])
await onSuccess?.(data, variables, context)
diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts
index 686806ecd29..191b199a8ce 100644
--- a/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts
+++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts
@@ -48,9 +48,12 @@ export const useDatabaseCronJobDeleteMutation = ({
mutationFn: (vars) => deleteDatabaseCronJob(vars),
async onSuccess(data, variables, context) {
const { projectRef, searchTerm } = variables
- await queryClient.invalidateQueries({
- queryKey: databaseCronJobsKeys.listInfinite(projectRef, searchTerm),
- })
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: databaseCronJobsKeys.jobs(projectRef) }),
+ queryClient.invalidateQueries({
+ queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm),
+ }),
+ ])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/apps/studio/data/database-cron-jobs/keys.ts b/apps/studio/data/database-cron-jobs/keys.ts
index 835fd1fa5a2..720f30e3c19 100644
--- a/apps/studio/data/database-cron-jobs/keys.ts
+++ b/apps/studio/data/database-cron-jobs/keys.ts
@@ -4,6 +4,8 @@ export const databaseCronJobsKeys = {
create: () => ['cron-jobs', 'create'] as const,
delete: () => ['cron-jobs', 'delete'] as const,
alter: () => ['cronjobs', 'alter'] as const,
+ /** Prefix of job, listInfinite, count, run, and runsInfinite — but not listInfiniteMinimal */
+ jobs: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const,
job: (projectRef: string | undefined, identifier: number | string | undefined) =>
['projects', projectRef, 'cron-jobs', identifier] as const,
listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) =>
diff --git a/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts
index b7dc3453f3d..d912f70f2ba 100644
--- a/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts
+++ b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts
@@ -1,8 +1,8 @@
-import { getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta'
-import { useMutation } from '@tanstack/react-query'
+import { CRON_CLEANUP_JOB_NAME, getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
-import { getScheduleDeleteCronJobRunDetailsKey } from './keys'
+import { databaseCronJobsKeys, getScheduleDeleteCronJobRunDetailsKey } from './keys'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -43,6 +43,8 @@ export const useScheduleCronJobRunDetailsCleanupMutation = ({
>,
'mutationFn'
> = {}) => {
+ const queryClient = useQueryClient()
+
return useMutation<
ScheduleCronJobRunDetailsCleanupData,
ResponseError,
@@ -50,6 +52,18 @@ export const useScheduleCronJobRunDetailsCleanupMutation = ({
>({
mutationFn: (vars) => scheduleCronJobRunDetailsCleanup(vars),
async onSuccess(data, variables, context) {
+ // Deliberately narrower than the jobs() prefix: invalidating the jobs list here would
+ // refetch it while it sits in the cost-threshold error state, which briefly resets it
+ // to pending and unmounts the overflow notice (closing its dialog mid-flow). Both
+ // callers refetch their own grid via callbacks instead.
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: databaseCronJobsKeys.job(variables.projectRef, CRON_CLEANUP_JOB_NAME),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: databaseCronJobsKeys.count(variables.projectRef),
+ }),
+ ])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/e2e/studio/features/cron-jobs.spec.ts b/e2e/studio/features/cron-jobs.spec.ts
index ec52d25d47f..da4c21e2aab 100644
--- a/e2e/studio/features/cron-jobs.spec.ts
+++ b/e2e/studio/features/cron-jobs.spec.ts
@@ -70,6 +70,27 @@ const unscheduleJobByIdViaAPI = async (page: Page, ref: string, jobId: number) =
})
}
+// Mirrors CRON_CLEANUP_JOB_NAME in @supabase/pg-meta (not a dependency of this package).
+// The name is hardcoded in the product, so tests touching the cleanup feature contend on
+// this single global name.
+const CLEANUP_JOB_NAME = 'delete-job-run-details'
+
+// Runs in the page context (supabase_admin) because cron.unschedule by name only finds jobs
+// the calling role can manage, and the UI schedules the job as supabase_admin. Deletes
+// tolerantly since the job may or may not exist.
+const unscheduleCleanupJobIfExists = async (page: Page, ref: string) => {
+ await page.request.post(toUrl(`/api/platform/pg-meta/${ref}/query`), {
+ failOnStatusCode: true,
+ data: {
+ query: `do $$ begin
+ if exists (select 1 from cron.job where jobname = '${CLEANUP_JOB_NAME}') then
+ perform cron.unschedule('${CLEANUP_JOB_NAME}');
+ end if;
+ end $$;`,
+ },
+ })
+}
+
test.describe('Cron Jobs', () => {
test.beforeAll(async () => {
await withFileOnceSetup(import.meta.url, async () => {
@@ -341,205 +362,298 @@ test.describe('Cron Jobs', () => {
})
})
- test.describe('High Query Cost Banner', () => {
- test('shows banner and still displays cron jobs when query cost exceeds threshold', async ({
- page,
- ref,
- }) => {
- const testJobName = 'pw_high_cost_test_job'
- await navigateToCronJobsPage(page, ref)
- await using _ = await withSetupCleanup(
- async () => {
- await createJobViaAPI(page, ref, testJobName)
- },
- async () => {
- await deleteJobViaAPI(page, ref, testJobName)
- }
- )
+ // Both groups below schedule the product-wide delete-job-run-details job, so they contend
+ // on a single global job name. Opt out of fullyParallel for them (sequential, same worker)
+ // to avoid cross-worker races on that name.
+ test.describe('Cleanup Job Scheduling', () => {
+ test.describe.configure({ mode: 'default' })
- // Now set up the mock for the EXPLAIN query (preflight check) to return high cost
- // This simulates the scenario where cron.job_run_details is too large
- await page.route('**/pg-meta/*/query**', async (route) => {
- const request = route.request()
- const postData = request.postDataJSON()
+ // Regression test for FE-3724: the "Enable cleanup" header button schedules the daily
+ // job_run_details cleanup job without waiting for the high-query-cost banner. Covers the
+ // full cycle in one scenario because the states are causally chained: schedule via the
+ // dialog (button hides), then delete the job and verify the button reappears without a
+ // reload (the cache-invalidation regression).
+ test.describe('Enable Cleanup Button', () => {
+ test('schedules the cleanup job and reappears after the job is deleted', async ({
+ page,
+ ref,
+ }) => {
+ await using _ = await withSetupCleanup(
+ async () => {
+ await unscheduleCleanupJobIfExists(page, ref)
+ },
+ async () => {
+ await unscheduleCleanupJobIfExists(page, ref)
+ }
+ )
+ await navigateToCronJobsPage(page, ref)
- // Intercept EXPLAIN queries for the cron jobs query (the preflight check)
- if (
- postData?.query?.toLowerCase().startsWith('explain') &&
- postData?.query?.includes('cron.job')
- ) {
- // Return a mock EXPLAIN result with very high cost (over 100,000 threshold)
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify([
- {
- 'QUERY PLAN':
- 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
- },
- ]),
- })
- } else {
- await route.continue()
- }
+ const enableCleanupButton = page.getByRole('button', { name: 'Enable cleanup' })
+ await expect(
+ enableCleanupButton,
+ 'Enable cleanup button should be visible when the cleanup job does not exist'
+ ).toBeVisible({ timeout: 30000 })
+
+ await enableCleanupButton.click()
+
+ const dialog = page.getByRole('dialog')
+ await expect(
+ dialog.getByRole('heading', { name: 'Enable automatic cleanup' }),
+ 'Dialog should open with the enable cleanup title'
+ ).toBeVisible()
+ await expect(
+ dialog.getByRole('combobox'),
+ 'Retention select should default to 7 days'
+ ).toContainText('Older than 7 days')
+ await expect(
+ dialog.getByText(/cron\.schedule/),
+ 'Dialog should preview the SQL that will run'
+ ).toBeVisible()
+
+ await dialog.getByRole('button', { name: 'Enable cleanup' }).click()
+
+ await expect(
+ page.getByText('Scheduled daily cleanup job.'),
+ 'Success toast should appear after scheduling'
+ ).toBeVisible({ timeout: 15000 })
+
+ // The scheduled job shows up in the grid and the header button hides, both without a reload
+ const jobRow = page.getByRole('row', { name: new RegExp(CLEANUP_JOB_NAME) })
+ await expect(jobRow, 'Scheduled cleanup job should appear in the grid').toBeVisible({
+ timeout: 10000,
+ })
+ await expect(
+ enableCleanupButton,
+ 'Enable cleanup button should hide once the cleanup job exists'
+ ).toHaveCount(0)
+
+ // Delete the job from the grid and verify the button comes back without a reload
+ await jobRow.click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Delete job' }).click()
+ await expect(page.getByRole('heading', { name: 'Delete this cron job' })).toBeVisible()
+ await page.getByPlaceholder('Type in name of cron job').fill(CLEANUP_JOB_NAME)
+ await page.getByRole('button', { name: `Delete cron job ${CLEANUP_JOB_NAME}` }).click()
+
+ await expect(page.getByText(/Successfully removed cron job/)).toBeVisible({
+ timeout: 10000,
+ })
+ await expect(jobRow).not.toBeVisible({ timeout: 10000 })
+ await expect(
+ enableCleanupButton,
+ 'Enable cleanup button should reappear after the cleanup job is deleted'
+ ).toBeVisible({ timeout: 10000 })
})
-
- // Navigate to the cron jobs page - this will trigger the mocked preflight check
- await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
-
- // Wait for the grid to load - this verifies cron jobs are still visible in minimal mode
- await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
-
- // Should show the overflow banner (minimal mode indicator)
- await expect(
- page.getByText('Last run for each cron job omitted due to high query cost')
- ).toBeVisible({
- timeout: 15000,
- })
-
- // The test job should still be visible in the grid (minimal mode shows jobs without last run)
- await expect(page.getByRole('row', { name: new RegExp(testJobName) })).toBeVisible()
-
- // An alert about high query costs should be visible and contain a "Learn more" button
- await expect(
- page.getByRole('alert').getByRole('button', { name: 'Learn more' })
- ).toBeVisible()
-
- // Remove the route mock for subsequent tests
- await page.unroute('**/pg-meta/*/query**')
})
- test('Learn more dialog shows cleanup options', async ({ page, ref }) => {
- // Set up the mock again for this test
- await page.route('**/pg-meta/*/query**', async (route) => {
- const request = route.request()
- const postData = request.postDataJSON()
+ test.describe('High Query Cost Banner', () => {
+ test('shows banner and still displays cron jobs when query cost exceeds threshold', async ({
+ page,
+ ref,
+ }) => {
+ const testJobName = 'pw_high_cost_test_job'
+ await navigateToCronJobsPage(page, ref)
+ await using _ = await withSetupCleanup(
+ async () => {
+ await createJobViaAPI(page, ref, testJobName)
+ },
+ async () => {
+ await deleteJobViaAPI(page, ref, testJobName)
+ }
+ )
- if (
- postData?.query?.toLowerCase().startsWith('explain') &&
- postData?.query?.includes('cron.job')
- ) {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify([
- {
- 'QUERY PLAN':
- 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
- },
- ]),
- })
- } else {
- await route.continue()
- }
+ // Now set up the mock for the EXPLAIN query (preflight check) to return high cost
+ // This simulates the scenario where cron.job_run_details is too large
+ await page.route('**/pg-meta/*/query**', async (route) => {
+ const request = route.request()
+ const postData = request.postDataJSON()
+
+ // Intercept EXPLAIN queries for the cron jobs query (the preflight check)
+ if (
+ postData?.query?.toLowerCase().startsWith('explain') &&
+ postData?.query?.includes('cron.job')
+ ) {
+ // Return a mock EXPLAIN result with very high cost (over 100,000 threshold)
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ 'QUERY PLAN':
+ 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
+ },
+ ]),
+ })
+ } else {
+ await route.continue()
+ }
+ })
+
+ // Navigate to the cron jobs page - this will trigger the mocked preflight check
+ await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
+
+ // Wait for the grid to load - this verifies cron jobs are still visible in minimal mode
+ await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
+
+ // Should show the overflow banner (minimal mode indicator)
+ await expect(
+ page.getByText('Last run for each cron job omitted due to high query cost')
+ ).toBeVisible({
+ timeout: 15000,
+ })
+
+ // The test job should still be visible in the grid (minimal mode shows jobs without last run)
+ await expect(page.getByRole('row', { name: new RegExp(testJobName) })).toBeVisible()
+
+ // An alert about high query costs should be visible and contain a "Learn more" button
+ await expect(
+ page.getByRole('alert').getByRole('button', { name: 'Learn more' })
+ ).toBeVisible()
+
+ // Remove the route mock for subsequent tests
+ await page.unroute('**/pg-meta/*/query**')
})
- await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
+ test('Learn more dialog shows cleanup options', async ({ page, ref }) => {
+ // Set up the mock again for this test
+ await page.route('**/pg-meta/*/query**', async (route) => {
+ const request = route.request()
+ const postData = request.postDataJSON()
- // Wait for the banner to appear
- await expect(
- page.getByText('Last run for each cron job omitted due to high query cost')
- ).toBeVisible({
- timeout: 15000,
+ if (
+ postData?.query?.toLowerCase().startsWith('explain') &&
+ postData?.query?.includes('cron.job')
+ ) {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ 'QUERY PLAN':
+ 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
+ },
+ ]),
+ })
+ } else {
+ await route.continue()
+ }
+ })
+
+ await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
+
+ // Wait for the banner to appear
+ await expect(
+ page.getByText('Last run for each cron job omitted due to high query cost')
+ ).toBeVisible({
+ timeout: 15000,
+ })
+
+ // Click the "Learn more" button in the alert to open the dialog
+ await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
+
+ // The dialog should open with the explanation
+ await expect(
+ page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
+ ).toBeVisible()
+
+ // Should explain the issue
+ await expect(
+ page.getByText(/the estimated query cost exceeds safety thresholds/)
+ ).toBeVisible()
+
+ // Should show Step 1 with cleanup options
+ await expect(page.getByText('Step 1: Delete older entries')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Delete rows now' })).toBeVisible()
+
+ // Should show the interval selector
+ await expect(page.getByRole('combobox')).toBeVisible()
+
+ // Should show Step 2 (disabled until step 1 is complete)
+ await expect(page.getByText('Step 2: Schedule an automated cleanup')).toBeVisible()
+ await expect(
+ page.getByText('Complete step 1 to enable scheduling a daily cleanup job')
+ ).toBeVisible()
+
+ // Remove the route mock
+ await page.unroute('**/pg-meta/*/query**')
})
- // Click the "Learn more" button in the alert to open the dialog
- await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
+ test('cleanup workflow: delete rows and schedule cleanup job', async ({ page, ref }) => {
+ // This flow schedules the delete-job-run-details job; remove it afterwards so tests
+ // that depend on the job's absence (Enable Cleanup Button) start clean. Deleting via
+ // the UI here causes pointer events issues with Radix dialogs, hence via SQL.
+ await using _ = await withSetupCleanup(
+ async () => {},
+ async () => {
+ await unscheduleCleanupJobIfExists(page, ref)
+ }
+ )
- // The dialog should open with the explanation
- await expect(
- page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
- ).toBeVisible()
+ // Set up the mock for the high cost scenario
+ await page.route('**/pg-meta/*/query**', async (route) => {
+ const request = route.request()
+ const postData = request.postDataJSON()
- // Should explain the issue
- await expect(
- page.getByText(/the estimated query cost exceeds safety thresholds/)
- ).toBeVisible()
+ if (
+ postData?.query?.toLowerCase().startsWith('explain') &&
+ postData?.query?.includes('cron.job')
+ ) {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ 'QUERY PLAN':
+ 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
+ },
+ ]),
+ })
+ } else {
+ await route.continue()
+ }
+ })
- // 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()
+ await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
- // Should show the interval selector
- await expect(page.getByRole('combobox')).toBeVisible()
+ // Wait for the banner and click Learn more
+ await expect(
+ page.getByText('Last run for each cron job omitted due to high query cost')
+ ).toBeVisible({
+ timeout: 15000,
+ })
+ await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
- // 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 a daily cleanup job')
- ).toBeVisible()
+ // Wait for dialog to open
+ await expect(
+ page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
+ ).toBeVisible()
- // Remove the route mock
- await page.unroute('**/pg-meta/*/query**')
- })
+ // Step 1: Click "Delete rows now" to run the cleanup
+ await page.getByRole('button', { name: 'Delete rows now' }).click()
- test('cleanup workflow: delete rows and schedule cleanup job', async ({ page, ref }) => {
- // Set up the mock for the high cost scenario
- await page.route('**/pg-meta/*/query**', async (route) => {
- const request = route.request()
- const postData = request.postDataJSON()
+ // Wait for Step 1 to complete - should show success message
+ // Note: In a fresh DB, there might be 0 rows deleted, which is still success
+ await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({
+ timeout: 30000,
+ })
- if (
- postData?.query?.toLowerCase().startsWith('explain') &&
- postData?.query?.includes('cron.job')
- ) {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify([
- {
- 'QUERY PLAN':
- 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
- },
- ]),
- })
- } else {
- await route.continue()
- }
+ // Step 2 should now be enabled - the "Schedule cleanup job" button should be visible
+ await expect(page.getByRole('button', { name: 'Schedule cleanup job' })).toBeVisible()
+
+ // Should show the SQL preview for the cleanup job
+ await expect(page.getByText(/cron\.schedule/)).toBeVisible()
+
+ // Click "Schedule cleanup job"
+ await page.getByRole('button', { name: 'Schedule cleanup job' }).click()
+
+ // Wait for Step 2 to complete - should show success message
+ await expect(page.getByText('Daily cleanup job scheduled successfully')).toBeVisible({
+ timeout: 15000,
+ })
+
+ // Clean up: remove the route mock; the scheduled delete-job-run-details job is
+ // removed by the withSetupCleanup teardown at the top of this test.
+ await page.unroute('**/pg-meta/*/query**')
})
-
- await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
-
- // Wait for the banner and click Learn more
- await expect(
- page.getByText('Last run for each cron job omitted due to high query cost')
- ).toBeVisible({
- timeout: 15000,
- })
- await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
-
- // Wait for dialog to open
- await expect(
- page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
- ).toBeVisible()
-
- // Step 1: Click "Delete rows now" to run the cleanup
- await page.getByRole('button', { name: 'Delete rows now' }).click()
-
- // Wait for Step 1 to complete - should show success message
- // Note: In a fresh DB, there might be 0 rows deleted, which is still success
- await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({ timeout: 30000 })
-
- // Step 2 should now be enabled - the "Schedule cleanup job" button should be visible
- await expect(page.getByRole('button', { name: 'Schedule cleanup job' })).toBeVisible()
-
- // Should show the SQL preview for the cleanup job
- await expect(page.getByText(/cron\.schedule/)).toBeVisible()
-
- // Click "Schedule cleanup job"
- await page.getByRole('button', { name: 'Schedule cleanup job' }).click()
-
- // Wait for Step 2 to complete - should show success message
- await expect(page.getByText('Daily cleanup job scheduled successfully')).toBeVisible({
- timeout: 15000,
- })
-
- // Clean up: remove the route mock
- await page.unroute('**/pg-meta/*/query**')
-
- // Note: Test jobs (pw_high_cost_test_job, delete-job-run-details) will be cleaned up
- // in the next test run by the CRUD tests' beforeAll cleanup, or manually.
- // Attempting to delete here causes pointer events issues with Radix dialogs.
})
})
})
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 53a26d13203..f959ec01e51 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -234,6 +234,26 @@ export interface CronJobHistoryClickedEvent {
groups: TelemetryGroups
}
+/**
+ * Enable cleanup button clicked, either in the cron jobs page header (opens the
+ * confirmation dialog) or within the dialog itself (schedules the daily cleanup
+ * job that deletes old rows from cron.job_run_details).
+ *
+ * @group Events
+ * @source studio
+ * @page /dashboard/project/{ref}/integrations/cron/jobs
+ */
+export interface CronJobCleanupEnableButtonClickedEvent {
+ action: 'cron_job_cleanup_enable_button_clicked'
+ properties: {
+ /** Where the button was clicked from */
+ origin: 'header' | 'dialog'
+ /** Retention period chosen for the cleanup job, e.g. 7 days. Only sent on dialog confirm. */
+ retentionInterval?: string
+ }
+ groups: TelemetryGroups
+}
+
/**
* A feature preview was enabled by the user through the FeaturePreviewModal.
*
@@ -3553,6 +3573,7 @@ export type TelemetryEvent =
| CronJobUpdateClickedEvent
| CronJobDeleteClickedEvent
| CronJobHistoryClickedEvent
+ | CronJobCleanupEnableButtonClickedEvent
| FeaturePreviewEnabledEvent
| FeaturePreviewDisabledEvent
| TimezonePickerClickedEvent
diff --git a/packages/pg-meta/src/sql/studio/database/cron-jobs.ts b/packages/pg-meta/src/sql/studio/database/cron-jobs.ts
index cd8bb41094a..0c5a7738400 100644
--- a/packages/pg-meta/src/sql/studio/database/cron-jobs.ts
+++ b/packages/pg-meta/src/sql/studio/database/cron-jobs.ts
@@ -107,7 +107,7 @@ WITH deleted AS (
SELECT count(*) as deleted_count FROM deleted;`
}
-const CRON_CLEANUP_SCHEDULE_NAME = 'delete-job-run-details'
+export const CRON_CLEANUP_JOB_NAME = 'delete-job-run-details'
const CRON_CLEANUP_SCHEDULE_EXPRESSION = '0 12 * * *'
export const getScheduleDeleteCronJobRunDetailsSql = (interval: string): SafeSqlFragment => {
@@ -115,7 +115,7 @@ export const getScheduleDeleteCronJobRunDetailsSql = (interval: string): SafeSql
return safeSql`
SELECT cron.schedule(
- ${literal(CRON_CLEANUP_SCHEDULE_NAME)},
+ ${literal(CRON_CLEANUP_JOB_NAME)},
${literal(CRON_CLEANUP_SCHEDULE_EXPRESSION)},
${literal(command)}
);`