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 ( + { + setOpen(isOpen) + if (isOpen) track('cron_job_cleanup_enable_button_clicked', { origin: 'header' }) + }} + > + + + + + + Enable automatic cleanup + + Schedules a daily job that deletes old cron job run records + + + + +

+ Every cron job run is recorded in the{' '} + cron.job_run_details table. + Without periodic cleanup, the table grows indefinitely and bloats the database. +

+
+

Delete run history

+
+ +
+
+ +
+ + + + +
+
+ ) +} 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 = ({ />
+ 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)} );`