mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
import {
|
|
getDeleteOldCronJobRunDetailsByCtidSql,
|
|
getJobRunDetailsPageCountSql,
|
|
} from '@supabase/pg-meta'
|
|
import { useCallback, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
|
|
import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
|
|
import type { ConnectionVars } from '@/data/common.types'
|
|
import {
|
|
CTID_BATCH_PAGE_SIZE,
|
|
validatePageNumber,
|
|
} from '@/data/database-cron-jobs/database-cron-jobs.utils'
|
|
import {
|
|
getDeleteOldCronJobRunDetailsByCtidKey,
|
|
getJobRunDetailsPageCountKey,
|
|
} from '@/data/database-cron-jobs/keys'
|
|
import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation'
|
|
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
|
|
|
// 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 }
|
|
|
|
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
|
|
})
|
|
|
|
const {
|
|
mutate: scheduleCronJobCleanup,
|
|
isPending: isScheduling,
|
|
isSuccess: isScheduleSuccess,
|
|
} = useScheduleCronJobRunDetailsCleanupMutation()
|
|
|
|
/**
|
|
* 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)
|
|
|
|
validatePageNumber(startPage, 'startPage')
|
|
validatePageNumber(endPage, 'endPage')
|
|
|
|
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, onSuccess }: { interval: string; onSuccess?: () => void }) => {
|
|
if (!projectRef) {
|
|
console.error('[CronJobsTab > schedule cleanup] Project reference is required')
|
|
toast.error('There was an error scheduling the cleanup. Please try again.')
|
|
return
|
|
}
|
|
|
|
scheduleCronJobCleanup(
|
|
{ projectRef, connectionString, interval },
|
|
{
|
|
onSuccess: () => {
|
|
toast.success('Scheduled daily cleanup job.')
|
|
onSuccess?.()
|
|
},
|
|
}
|
|
)
|
|
},
|
|
[connectionString, projectRef, scheduleCronJobCleanup]
|
|
)
|
|
|
|
/**
|
|
* Cancel an in-progress deletion.
|
|
*/
|
|
const cancelDeletion = useCallback(() => {
|
|
cancelledRef.current = true
|
|
setCleanupState({ status: 'idle' })
|
|
}, [])
|
|
|
|
return {
|
|
cleanupInterval,
|
|
cleanupState,
|
|
isScheduling,
|
|
isScheduleSuccess,
|
|
setCleanupInterval,
|
|
runBatchedDeletion,
|
|
scheduleCleanup,
|
|
cancelDeletion,
|
|
}
|
|
}
|