RetryManager.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import _ from 'lodash'
  2. import { promisify, callbackify } from '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. 'OpsOutOfOrderError: project structure version out of order',
  21. 'OpsOutOfOrderError: project structure version out of order on incoming updates',
  22. 'OpsOutOfOrderError: doc version out of order',
  23. 'OpsOutOfOrderError: doc version out of order on incoming updates',
  24. ]
  25. const MAX_RESYNC_ATTEMPTS = 2
  26. const MAX_SOFT_RESYNC_ATTEMPTS = 1
  27. export const promises = {}
  28. promises.retryFailures = async (options = {}) => {
  29. const { failureType, timeout, limit } = options
  30. if (failureType === 'soft') {
  31. const batch = await getFailureBatch(softErrorSelector, limit)
  32. const result = await retryFailureBatch(batch, timeout, async failure => {
  33. await UpdatesProcessor.promises.processUpdatesForProject(
  34. failure.project_id
  35. )
  36. })
  37. return result
  38. } else if (failureType === 'hard') {
  39. const batch = await getFailureBatch(hardErrorSelector, limit)
  40. const result = await retryFailureBatch(batch, timeout, async failure => {
  41. await resyncProject(failure.project_id, {
  42. hard: failureRequiresHardResync(failure),
  43. })
  44. })
  45. return result
  46. }
  47. }
  48. export const retryFailures = callbackify(promises.retryFailures)
  49. function softErrorSelector(failure) {
  50. return (
  51. (isTemporaryFailure(failure) && !isRepeatedFailure(failure)) ||
  52. (isFirstFailure(failure) && !isHardFailure(failure))
  53. )
  54. }
  55. function hardErrorSelector(failure) {
  56. return (
  57. (isHardFailure(failure) || isRepeatedFailure(failure)) &&
  58. !isStuckFailure(failure)
  59. )
  60. }
  61. function isTemporaryFailure(failure) {
  62. return TEMPORARY_FAILURES.includes(failure.error)
  63. }
  64. function isHardFailure(failure) {
  65. return HARD_FAILURES.includes(failure.error)
  66. }
  67. function isFirstFailure(failure) {
  68. return failure.attempts <= 1
  69. }
  70. function isRepeatedFailure(failure) {
  71. return failure.attempts > 3
  72. }
  73. function isStuckFailure(failure) {
  74. return (
  75. failure.resyncAttempts != null &&
  76. failure.resyncAttempts >= MAX_RESYNC_ATTEMPTS
  77. )
  78. }
  79. function failureRequiresHardResync(failure) {
  80. return (
  81. failure.resyncAttempts != null &&
  82. failure.resyncAttempts >= MAX_SOFT_RESYNC_ATTEMPTS
  83. )
  84. }
  85. async function getFailureBatch(selector, limit) {
  86. let failures = await ErrorRecorder.promises.getFailedProjects()
  87. failures = failures.filter(selector)
  88. // randomise order
  89. failures = _.shuffle(failures)
  90. // put a limit on the number to retry
  91. const projectsToRetryCount = failures.length
  92. if (limit && projectsToRetryCount > limit) {
  93. failures = failures.slice(0, limit)
  94. }
  95. logger.debug({ projectsToRetryCount, limit }, 'retrying failed projects')
  96. return failures
  97. }
  98. async function retryFailureBatch(failures, timeout, retryHandler) {
  99. const startTime = new Date()
  100. // keep track of successes and failures
  101. const failed = []
  102. const succeeded = []
  103. for (const failure of failures) {
  104. const projectId = failure.project_id
  105. const timeTaken = new Date() - startTime
  106. if (timeout && timeTaken > timeout) {
  107. // finish early due to timeout
  108. logger.debug('background retries timed out')
  109. break
  110. }
  111. logger.debug(
  112. { projectId, timeTaken },
  113. 'retrying failed project in background'
  114. )
  115. try {
  116. await retryHandler(failure)
  117. succeeded.push(projectId)
  118. } catch (err) {
  119. failed.push(projectId)
  120. }
  121. }
  122. return { succeeded, failed }
  123. }
  124. async function resyncProject(projectId, options = {}) {
  125. const { hard = false } = options
  126. try {
  127. if (!/^[0-9a-f]{24}$/.test(projectId)) {
  128. logger.debug({ projectId }, 'clearing bad project id')
  129. await ErrorRecorder.promises.record(projectId, 0, null)
  130. return
  131. }
  132. await checkProjectHasHistoryId(projectId)
  133. if (hard) {
  134. await SyncManager.promises.startHardResync(projectId)
  135. } else {
  136. await SyncManager.promises.startResync(projectId)
  137. }
  138. await waitUntilRedisQueueIsEmpty(projectId)
  139. await checkFailureRecordWasRemoved(projectId)
  140. } catch (err) {
  141. throw new OError({
  142. message: 'failed to resync project',
  143. info: { projectId, hard },
  144. }).withCause(err)
  145. }
  146. }
  147. async function checkProjectHasHistoryId(projectId) {
  148. const historyId = await WebApiManager.promises.getHistoryId(projectId)
  149. if (historyId == null) {
  150. throw new OError('no history id')
  151. }
  152. }
  153. async function waitUntilRedisQueueIsEmpty(projectId) {
  154. for (let attempts = 0; attempts < 30; attempts++) {
  155. const updatesCount = await RedisManager.promises.countUnprocessedUpdates(
  156. projectId
  157. )
  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. }