RetryManager.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import _ from 'lodash'
  2. import { promisify, callbackify } from 'node:util'
  3. import logger from '@overleaf/logger'
  4. import OError from '@overleaf/o-error'
  5. import * as UpdatesProcessor from './UpdatesProcessor.js'
  6. import * as SyncManager from './SyncManager.js'
  7. import { SYNC_ONGOING_ERROR_MESSAGE } from './Errors.js'
  8. import * as WebApiManager from './WebApiManager.js'
  9. import * as RedisManager from './RedisManager.js'
  10. import * as ErrorRecorder from './ErrorRecorder.js'
  11. const sleep = promisify(setTimeout)
  12. const TEMPORARY_FAILURES = [
  13. 'Error: ENOSPC: no space left on device, write',
  14. 'Error: ESOCKETTIMEDOUT',
  15. 'Error: failed to extend lock',
  16. 'Error: tried to release timed out lock',
  17. 'Error: Timeout',
  18. ]
  19. const HARD_FAILURES = [
  20. 'Error: history store a non-success status code: 422',
  21. 'OpsOutOfOrderError: project structure version out of order',
  22. 'OpsOutOfOrderError: project structure version out of order on incoming updates',
  23. 'OpsOutOfOrderError: doc version out of order',
  24. 'OpsOutOfOrderError: doc version out of order on incoming updates',
  25. ]
  26. const MAX_RESYNC_ATTEMPTS = 2
  27. const MAX_SOFT_RESYNC_ATTEMPTS = 1
  28. export const promises = {}
  29. promises.retryFailures = async (options = {}) => {
  30. const { failureType, timeout, limit } = options
  31. if (failureType === 'soft') {
  32. const batch = await getFailureBatch(softErrorSelector, limit)
  33. const result = await retryFailureBatch(batch, timeout, async failure => {
  34. await UpdatesProcessor.promises.processUpdatesForProject(
  35. failure.project_id
  36. )
  37. })
  38. return result
  39. } else if (failureType === 'hard') {
  40. const batch = await getFailureBatch(hardErrorSelector, limit)
  41. const result = await retryFailureBatch(batch, timeout, async failure => {
  42. // Ongoing-sync failures always use soft resync to preserve sync state.
  43. // SyncManager needs existing state to detect and clear stuck syncs.
  44. const hard =
  45. failureRequiresHardResync(failure) && !isOngoingSyncFailure(failure)
  46. await resyncProject(failure.project_id, { hard })
  47. })
  48. return result
  49. }
  50. }
  51. export const retryFailures = callbackify(promises.retryFailures)
  52. function softErrorSelector(failure) {
  53. return (
  54. (isTemporaryFailure(failure) && !isRepeatedFailure(failure)) ||
  55. (isFirstFailure(failure) && !isHardFailure(failure))
  56. )
  57. }
  58. function hardErrorSelector(failure) {
  59. // Ongoing-sync failures are always retried via soft resync.
  60. // SyncManager's stuck detection handles the retry limit (stuckClearCount).
  61. if (isOngoingSyncFailure(failure)) return true
  62. // Other failures: retry hard/repeated ones, but stop after MAX_RESYNC_ATTEMPTS
  63. return (
  64. (isHardFailure(failure) || isRepeatedFailure(failure)) &&
  65. !isStuckFailure(failure)
  66. )
  67. }
  68. function isTemporaryFailure(failure) {
  69. return TEMPORARY_FAILURES.includes(failure.error)
  70. }
  71. export function isHardFailure(failure) {
  72. return HARD_FAILURES.includes(failure.error)
  73. }
  74. export function isFirstFailure(failure) {
  75. return failure.attempts <= 1
  76. }
  77. function isRepeatedFailure(failure) {
  78. return failure.attempts > 3
  79. }
  80. export function isOngoingSyncFailure(failure) {
  81. return failure.error?.includes(SYNC_ONGOING_ERROR_MESSAGE) ?? false
  82. }
  83. function isStuckFailure(failure) {
  84. return (
  85. failure.resyncAttempts != null &&
  86. failure.resyncAttempts >= MAX_RESYNC_ATTEMPTS
  87. )
  88. }
  89. function failureRequiresHardResync(failure) {
  90. return (
  91. failure.resyncAttempts != null &&
  92. failure.resyncAttempts >= MAX_SOFT_RESYNC_ATTEMPTS
  93. )
  94. }
  95. async function getFailureBatch(selector, limit) {
  96. let failures = await ErrorRecorder.promises.getFailedProjects()
  97. failures = failures.filter(selector)
  98. // randomise order
  99. failures = _.shuffle(failures)
  100. // put a limit on the number to retry
  101. const projectsToRetryCount = failures.length
  102. if (limit && projectsToRetryCount > limit) {
  103. failures = failures.slice(0, limit)
  104. }
  105. logger.debug({ projectsToRetryCount, limit }, 'retrying failed projects')
  106. return failures
  107. }
  108. async function retryFailureBatch(failures, timeout, retryHandler) {
  109. const startTime = new Date()
  110. // keep track of successes and failures
  111. const failed = []
  112. const succeeded = []
  113. for (const failure of failures) {
  114. const projectId = failure.project_id
  115. const timeTaken = new Date() - startTime
  116. if (timeout && timeTaken > timeout) {
  117. // finish early due to timeout
  118. logger.debug('background retries timed out')
  119. break
  120. }
  121. logger.debug(
  122. { projectId, timeTaken },
  123. 'retrying failed project in background'
  124. )
  125. try {
  126. await retryHandler(failure)
  127. succeeded.push(projectId)
  128. } catch (err) {
  129. failed.push(projectId)
  130. }
  131. }
  132. return { succeeded, failed }
  133. }
  134. async function resyncProject(projectId, options = {}) {
  135. const { hard = false } = options
  136. try {
  137. if (!/^[0-9a-f]{24}$/.test(projectId)) {
  138. logger.debug({ projectId }, 'clearing bad project id')
  139. await ErrorRecorder.promises.clearError(projectId)
  140. return
  141. }
  142. await checkProjectHasHistoryId(projectId)
  143. if (hard) {
  144. await SyncManager.promises.startHardResync(projectId)
  145. } else {
  146. await SyncManager.promises.startResync(projectId)
  147. }
  148. await waitUntilRedisQueueIsEmpty(projectId)
  149. await checkFailureRecordWasRemoved(projectId)
  150. } catch (err) {
  151. throw new OError({
  152. message: 'failed to resync project',
  153. info: { projectId, hard },
  154. }).withCause(err)
  155. }
  156. }
  157. async function checkProjectHasHistoryId(projectId) {
  158. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  159. if (historyId == null) {
  160. throw new OError('no history id')
  161. }
  162. }
  163. async function waitUntilRedisQueueIsEmpty(projectId) {
  164. for (let attempts = 0; attempts < 30; attempts++) {
  165. const updatesCount =
  166. await RedisManager.promises.countUnprocessedUpdates(projectId)
  167. if (updatesCount === 0) {
  168. return
  169. }
  170. await sleep(1000)
  171. }
  172. throw new OError('queue not empty')
  173. }
  174. async function checkFailureRecordWasRemoved(projectId) {
  175. const failureRecord = await ErrorRecorder.promises.getFailureRecord(projectId)
  176. if (failureRecord) {
  177. throw new OError('failure record still exists')
  178. }
  179. }