CronJobsTab.useCleanupActions.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import {
  2. getDeleteOldCronJobRunDetailsByCtidSql,
  3. getJobRunDetailsPageCountSql,
  4. } from '@supabase/pg-meta'
  5. import { useCallback, useRef, useState } from 'react'
  6. import { toast } from 'sonner'
  7. import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
  8. import type { ConnectionVars } from '@/data/common.types'
  9. import {
  10. CTID_BATCH_PAGE_SIZE,
  11. validatePageNumber,
  12. } from '@/data/database-cron-jobs/database-cron-jobs.utils'
  13. import {
  14. getDeleteOldCronJobRunDetailsByCtidKey,
  15. getJobRunDetailsPageCountKey,
  16. } from '@/data/database-cron-jobs/keys'
  17. import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation'
  18. import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
  19. // Delay between batches to allow other queries to proceed (in milliseconds)
  20. const BATCH_DELAY_MS = 100
  21. type UseCronJobsCleanupActionsOptions = ConnectionVars
  22. export interface BatchDeletionProgress {
  23. currentBatch: number
  24. totalBatches: number
  25. totalRowsDeleted: number
  26. }
  27. export type CleanupState =
  28. | { status: 'idle' }
  29. | { status: 'deleting'; progress: BatchDeletionProgress }
  30. | { status: 'delete-success'; totalRowsDeleted: number }
  31. | { status: 'delete-error'; error: string }
  32. export const useCronJobsCleanupActions = ({
  33. projectRef,
  34. connectionString,
  35. }: UseCronJobsCleanupActionsOptions) => {
  36. const [cleanupInterval, setCleanupInterval] = useState(CLEANUP_INTERVALS[0].value)
  37. const [cleanupState, setCleanupState] = useState<CleanupState>({ status: 'idle' })
  38. // Ref to track cancellation
  39. const cancelledRef = useRef(false)
  40. const { mutateAsync: executeSql } = useExecuteSqlMutation({
  41. onError: () => {}, // Error handled inline
  42. })
  43. const {
  44. mutate: scheduleCronJobCleanup,
  45. isPending: isScheduling,
  46. isSuccess: isScheduleSuccess,
  47. } = useScheduleCronJobRunDetailsCleanupMutation()
  48. /**
  49. * Run batched deletion using ctid ranges.
  50. * This approach scans the table in page chunks to avoid:
  51. * - Buffer cache pollution from full table scans
  52. * - Long-running transactions that block vacuum
  53. * - Lock accumulation from deleting millions of rows at once
  54. */
  55. const runBatchedDeletion = useCallback(
  56. async (interval: string) => {
  57. if (!projectRef) {
  58. console.error('[CronJobsTab > batch deletion] Project reference is required')
  59. toast.error('There was an error running the cleanup. Please try again.')
  60. return
  61. }
  62. cancelledRef.current = false
  63. try {
  64. // Step 1: Get the total number of pages in the table
  65. setCleanupState({
  66. status: 'deleting',
  67. progress: { currentBatch: 0, totalBatches: 0, totalRowsDeleted: 0 },
  68. })
  69. const pageCountResult = await executeSql({
  70. projectRef,
  71. connectionString,
  72. sql: getJobRunDetailsPageCountSql(),
  73. queryKey: getJobRunDetailsPageCountKey(projectRef),
  74. })
  75. const rawTotalPages = pageCountResult.result?.[0]?.num_pages ?? 0
  76. const totalPages = Number(rawTotalPages)
  77. if (!Number.isFinite(totalPages) || totalPages < 0) {
  78. throw new Error(
  79. `[CronJobs > cleanup actions] Invalid page count returned: ${rawTotalPages}`
  80. )
  81. }
  82. if (totalPages === 0) {
  83. setCleanupState({ status: 'delete-success', totalRowsDeleted: 0 })
  84. toast.success('The job_run_details table is empty.')
  85. return
  86. }
  87. const totalBatches = Math.ceil(totalPages / CTID_BATCH_PAGE_SIZE)
  88. let totalRowsDeleted = 0
  89. // Step 2: Iterate through pages in batches
  90. for (let batch = 0; batch < totalBatches; batch++) {
  91. // Check for cancellation
  92. if (cancelledRef.current) {
  93. setCleanupState({ status: 'idle' })
  94. toast.info('Deletion cancelled.')
  95. return
  96. }
  97. const startPage = batch * CTID_BATCH_PAGE_SIZE
  98. const endPage = Math.min((batch + 1) * CTID_BATCH_PAGE_SIZE, totalPages + 1)
  99. validatePageNumber(startPage, 'startPage')
  100. validatePageNumber(endPage, 'endPage')
  101. setCleanupState({
  102. status: 'deleting',
  103. progress: {
  104. currentBatch: batch + 1,
  105. totalBatches,
  106. totalRowsDeleted,
  107. },
  108. })
  109. const deleteResult = await executeSql({
  110. projectRef,
  111. connectionString,
  112. sql: getDeleteOldCronJobRunDetailsByCtidSql(interval, startPage, endPage),
  113. queryKey: getDeleteOldCronJobRunDetailsByCtidKey(projectRef, interval, startPage),
  114. })
  115. const deletedCount = deleteResult.result?.[0]?.deleted_count ?? 0
  116. totalRowsDeleted += deletedCount
  117. if (cancelledRef.current) {
  118. setCleanupState({ status: 'idle' })
  119. toast.info('Deletion cancelled.')
  120. return
  121. }
  122. if (batch < totalBatches - 1) {
  123. await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS))
  124. }
  125. }
  126. setCleanupState({ status: 'delete-success', totalRowsDeleted })
  127. toast.success(
  128. `Deleted ${totalRowsDeleted.toLocaleString()} cron job runs older than ${interval}.`
  129. )
  130. } catch (error) {
  131. console.error('[CronJobs] Batch deletion failed with error: %O', error)
  132. const errorMessage = error instanceof Error ? error.message : 'Unknown error'
  133. setCleanupState({ status: 'delete-error', error: errorMessage })
  134. toast.error('Running the cleanup failed. Please try again.')
  135. }
  136. },
  137. [projectRef, connectionString, executeSql]
  138. )
  139. /**
  140. * Schedule a daily cleanup job.
  141. * This should only be called after a successful initial deletion.
  142. */
  143. const scheduleCleanup = useCallback(
  144. async ({ interval, onSuccess }: { interval: string; onSuccess?: () => void }) => {
  145. if (!projectRef) {
  146. console.error('[CronJobsTab > schedule cleanup] Project reference is required')
  147. toast.error('There was an error scheduling the cleanup. Please try again.')
  148. return
  149. }
  150. scheduleCronJobCleanup(
  151. { projectRef, connectionString, interval },
  152. {
  153. onSuccess: () => {
  154. toast.success('Scheduled daily cleanup job.')
  155. onSuccess?.()
  156. },
  157. }
  158. )
  159. },
  160. [connectionString, projectRef, scheduleCronJobCleanup]
  161. )
  162. /**
  163. * Cancel an in-progress deletion.
  164. */
  165. const cancelDeletion = useCallback(() => {
  166. cancelledRef.current = true
  167. setCleanupState({ status: 'idle' })
  168. }, [])
  169. return {
  170. cleanupInterval,
  171. cleanupState,
  172. isScheduling,
  173. isScheduleSuccess,
  174. setCleanupInterval,
  175. runBatchedDeletion,
  176. scheduleCleanup,
  177. cancelDeletion,
  178. }
  179. }