RetryManager.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  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 * as WebApiManager from './WebApiManager.js'
  8. import * as RedisManager from './RedisManager.js'
  9. import * as ErrorRecorder from './ErrorRecorder.js'
  10. const sleep = promisify(setTimeout)
  11. const TEMPORARY_FAILURES = [
  12. 'Error: ENOSPC: no space left on device, write',
  13. 'Error: ESOCKETTIMEDOUT',
  14. 'Error: failed to extend lock',
  15. 'Error: tried to release timed out lock',
  16. 'Error: Timeout',
  17. ]
  18. const HARD_FAILURES = [
  19. 'Error: history store a non-success status code: 422',
  20. 'OError: 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. await resyncProject(failure.project_id, {
  43. hard: failureRequiresHardResync(failure),
  44. })
  45. })
  46. return result
  47. }
  48. }
  49. export const retryFailures = callbackify(promises.retryFailures)
  50. function softErrorSelector(failure) {
  51. return (
  52. (isTemporaryFailure(failure) && !isRepeatedFailure(failure)) ||
  53. (isFirstFailure(failure) && !isHardFailure(failure))
  54. )
  55. }
  56. function hardErrorSelector(failure) {
  57. return (
  58. (isHardFailure(failure) || isRepeatedFailure(failure)) &&
  59. !isStuckFailure(failure)
  60. )
  61. }
  62. function isTemporaryFailure(failure) {
  63. return TEMPORARY_FAILURES.includes(failure.error)
  64. }
  65. export function isHardFailure(failure) {
  66. return HARD_FAILURES.includes(failure.error)
  67. }
  68. export function isFirstFailure(failure) {
  69. return failure.attempts <= 1
  70. }
  71. function isRepeatedFailure(failure) {
  72. return failure.attempts > 3
  73. }
  74. function isStuckFailure(failure) {
  75. return (
  76. failure.resyncAttempts != null &&
  77. failure.resyncAttempts >= MAX_RESYNC_ATTEMPTS
  78. )
  79. }
  80. function failureRequiresHardResync(failure) {
  81. return (
  82. failure.resyncAttempts != null &&
  83. failure.resyncAttempts >= MAX_SOFT_RESYNC_ATTEMPTS
  84. )
  85. }
  86. async function getFailureBatch(selector, limit) {
  87. let failures = await ErrorRecorder.promises.getFailedProjects()
  88. failures = failures.filter(selector)
  89. // randomise order
  90. failures = _.shuffle(failures)
  91. // put a limit on the number to retry
  92. const projectsToRetryCount = failures.length
  93. if (limit && projectsToRetryCount > limit) {
  94. failures = failures.slice(0, limit)
  95. }
  96. logger.debug({ projectsToRetryCount, limit }, 'retrying failed projects')
  97. return failures
  98. }
  99. async function retryFailureBatch(failures, timeout, retryHandler) {
  100. const startTime = new Date()
  101. // keep track of successes and failures
  102. const failed = []
  103. const succeeded = []
  104. for (const failure of failures) {
  105. const projectId = failure.project_id
  106. const timeTaken = new Date() - startTime
  107. if (timeout && timeTaken > timeout) {
  108. // finish early due to timeout
  109. logger.debug('background retries timed out')
  110. break
  111. }
  112. logger.debug(
  113. { projectId, timeTaken },
  114. 'retrying failed project in background'
  115. )
  116. try {
  117. await retryHandler(failure)
  118. succeeded.push(projectId)
  119. } catch (err) {
  120. failed.push(projectId)
  121. }
  122. }
  123. return { succeeded, failed }
  124. }
  125. async function resyncProject(projectId, options = {}) {
  126. const { hard = false } = options
  127. try {
  128. if (!/^[0-9a-f]{24}$/.test(projectId)) {
  129. logger.debug({ projectId }, 'clearing bad project id')
  130. await ErrorRecorder.promises.clearError(projectId)
  131. return
  132. }
  133. await checkProjectHasHistoryId(projectId)
  134. if (hard) {
  135. await SyncManager.promises.startHardResync(projectId)
  136. } else {
  137. await SyncManager.promises.startResync(projectId)
  138. }
  139. await waitUntilRedisQueueIsEmpty(projectId)
  140. await checkFailureRecordWasRemoved(projectId)
  141. } catch (err) {
  142. throw new OError({
  143. message: 'failed to resync project',
  144. info: { projectId, hard },
  145. }).withCause(err)
  146. }
  147. }
  148. async function checkProjectHasHistoryId(projectId) {
  149. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  150. if (historyId == null) {
  151. throw new OError('no history id')
  152. }
  153. }
  154. async function waitUntilRedisQueueIsEmpty(projectId) {
  155. for (let attempts = 0; attempts < 30; attempts++) {
  156. const updatesCount =
  157. await RedisManager.promises.countUnprocessedUpdates(projectId)
  158. if (updatesCount === 0) {
  159. return
  160. }
  161. await sleep(1000)
  162. }
  163. throw new OError('queue not empty')
  164. }
  165. async function checkFailureRecordWasRemoved(projectId) {
  166. const failureRecord = await ErrorRecorder.promises.getFailureRecord(projectId)
  167. if (failureRecord) {
  168. throw new OError('failure record still exists')
  169. }
  170. }