ProjectVerifier.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. // @ts-check
  2. import { verifyProjectWithErrorContext } from '../storage/lib/backupVerifier.mjs'
  3. import { promiseMapSettledWithLimit } from '@overleaf/promise-utils'
  4. import logger from '@overleaf/logger'
  5. import metrics from '@overleaf/metrics'
  6. import {
  7. getSampleProjectsCursor,
  8. getProjectsCreatedInDateRangeCursor,
  9. getProjectsUpdatedInDateRangeCursor,
  10. } from './ProjectSampler.mjs'
  11. import OError from '@overleaf/o-error'
  12. import { setTimeout } from 'node:timers/promises'
  13. const MS_PER_30_DAYS = 30 * 24 * 60 * 60 * 1000
  14. const failureCounter = new metrics.prom.Counter({
  15. name: 'backup_project_verification_failed',
  16. help: 'Number of projects that failed verification',
  17. labelNames: ['name'],
  18. })
  19. const successCounter = new metrics.prom.Counter({
  20. name: 'backup_project_verification_succeeded',
  21. help: 'Number of projects that succeeded verification',
  22. })
  23. let WRITE_METRICS = false
  24. /**
  25. * @typedef {import('node:events').EventEmitter} EventEmitter
  26. */
  27. /**
  28. * Allows writing metrics to be enabled or disabled.
  29. * @param {Boolean} writeMetrics
  30. */
  31. export function setWriteMetrics(writeMetrics) {
  32. WRITE_METRICS = writeMetrics
  33. }
  34. /**
  35. *
  36. * @param {Error|unknown} error
  37. * @param {string} historyId
  38. */
  39. function handleVerificationError(error, historyId) {
  40. const name = error instanceof Error ? error.name : 'UnknownError'
  41. logger.error({ historyId, error, name }, 'error verifying project backup')
  42. WRITE_METRICS && failureCounter.inc({ name })
  43. return name
  44. }
  45. /**
  46. *
  47. * @param {Date} startDate
  48. * @param {Date} endDate
  49. * @param {number} interval
  50. * @returns {Array<VerificationJobSpecification>}
  51. */
  52. function splitJobs(startDate, endDate, interval) {
  53. /** @type {Array<VerificationJobSpecification>} */
  54. const jobs = []
  55. while (startDate < endDate) {
  56. const nextStart = new Date(
  57. Math.min(startDate.getTime() + interval, endDate.getTime())
  58. )
  59. jobs.push({ startDate, endDate: nextStart })
  60. startDate = nextStart
  61. }
  62. return jobs
  63. }
  64. /**
  65. *
  66. * @param {AsyncGenerator<string>} historyIdCursor
  67. * @param {EventEmitter} [eventEmitter]
  68. * @param {number} [delay] - Allows a delay between each verification
  69. * @return {Promise<{verified: number, total: number, errorTypes: *[], hasFailure: boolean}>}
  70. */
  71. async function verifyProjectsFromCursor(
  72. historyIdCursor,
  73. eventEmitter,
  74. delay = 0
  75. ) {
  76. const errorTypes = []
  77. let verified = 0
  78. let total = 0
  79. let receivedShutdownSignal = false
  80. if (eventEmitter) {
  81. eventEmitter.once('shutdown', () => {
  82. receivedShutdownSignal = true
  83. })
  84. }
  85. for await (const historyId of historyIdCursor) {
  86. if (receivedShutdownSignal) {
  87. break
  88. }
  89. total++
  90. try {
  91. await verifyProjectWithErrorContext(historyId)
  92. logger.debug({ historyId }, 'verified project backup successfully')
  93. WRITE_METRICS && successCounter.inc()
  94. verified++
  95. } catch (error) {
  96. const errorType = handleVerificationError(error, historyId)
  97. errorTypes.push(errorType)
  98. }
  99. if (delay > 0) {
  100. await setTimeout(delay)
  101. }
  102. }
  103. return {
  104. verified,
  105. total,
  106. errorTypes,
  107. hasFailure: errorTypes.length > 0,
  108. }
  109. }
  110. /**
  111. *
  112. * @param {number} nProjectsToSample
  113. * @param {EventEmitter} [signal]
  114. * @param {number} [delay]
  115. * @return {Promise<VerificationJobStatus>}
  116. */
  117. export async function verifyRandomProjectSample(
  118. nProjectsToSample,
  119. signal,
  120. delay = 0
  121. ) {
  122. const historyIds = await getSampleProjectsCursor(nProjectsToSample)
  123. return await verifyProjectsFromCursor(historyIds, signal, delay)
  124. }
  125. /**
  126. * Samples projects with history IDs between the specified dates and verifies them.
  127. *
  128. * @param {Date} startDate
  129. * @param {Date} endDate
  130. * @param {number} projectsPerRange
  131. * @param {EventEmitter} [signal]
  132. * @return {Promise<VerificationJobStatus>}
  133. */
  134. async function verifyRange(startDate, endDate, projectsPerRange, signal) {
  135. logger.info({ startDate, endDate }, 'verifying range')
  136. const results = await verifyProjectsFromCursor(
  137. getProjectsCreatedInDateRangeCursor(startDate, endDate, projectsPerRange),
  138. signal
  139. )
  140. if (results.total === 0) {
  141. logger.debug(
  142. { start: startDate, end: endDate },
  143. 'No projects found in range'
  144. )
  145. }
  146. const jobStatus = {
  147. ...results,
  148. startDate,
  149. endDate,
  150. }
  151. logger.debug(
  152. { ...jobStatus, errorTypes: Array.from(new Set(jobStatus.errorTypes)) },
  153. 'Verified range'
  154. )
  155. return jobStatus
  156. }
  157. /**
  158. * @typedef {Object} VerificationJobSpecification
  159. * @property {Date} startDate
  160. * @property {Date} endDate
  161. */
  162. /**
  163. * @typedef {import('./types.d.ts').VerificationJobStatus} VerificationJobStatus
  164. */
  165. /**
  166. * @typedef {Object} VerifyDateRangeOptions
  167. * @property {Date} startDate
  168. * @property {Date} endDate
  169. * @property {number} [interval]
  170. * @property {number} [projectsPerRange]
  171. * @property {number} [concurrency]
  172. * @property {EventEmitter} [signal]
  173. */
  174. /**
  175. *
  176. * @param {VerifyDateRangeOptions} options
  177. * @return {Promise<VerificationJobStatus>}
  178. */
  179. export async function verifyProjectsCreatedInDateRange({
  180. concurrency = 0,
  181. projectsPerRange = 10,
  182. startDate,
  183. endDate,
  184. interval = MS_PER_30_DAYS,
  185. signal,
  186. }) {
  187. const jobs = splitJobs(startDate, endDate, interval)
  188. if (jobs.length === 0) {
  189. throw new OError('Time range could not be split into jobs', {
  190. start: startDate,
  191. end: endDate,
  192. interval,
  193. })
  194. }
  195. const settlements = await promiseMapSettledWithLimit(
  196. concurrency,
  197. jobs,
  198. ({ startDate, endDate }) =>
  199. verifyRange(startDate, endDate, projectsPerRange, signal)
  200. )
  201. return settlements.reduce(
  202. /**
  203. *
  204. * @param {VerificationJobStatus} acc
  205. * @param settlement
  206. * @return {VerificationJobStatus}
  207. */
  208. (acc, settlement) => {
  209. if (settlement.status !== 'rejected') {
  210. if (settlement.value.hasFailure) {
  211. acc.hasFailure = true
  212. }
  213. acc.total += settlement.value.total
  214. acc.verified += settlement.value.verified
  215. acc.errorTypes = acc.errorTypes.concat(settlement.value.errorTypes)
  216. } else {
  217. logger.error({ ...settlement.reason }, 'Error processing range')
  218. }
  219. return acc
  220. },
  221. /** @type {VerificationJobStatus} */
  222. {
  223. startDate,
  224. endDate,
  225. verified: 0,
  226. total: 0,
  227. hasFailure: false,
  228. errorTypes: [],
  229. }
  230. )
  231. }
  232. /**
  233. * Verifies that projects that have recently gone out of RPO have been updated.
  234. *
  235. * @param {Date} startDate
  236. * @param {Date} endDate
  237. * @param {number} nProjects
  238. * @param {EventEmitter} [signal]
  239. * @return {Promise<VerificationJobStatus>}
  240. */
  241. export async function verifyProjectsUpdatedInDateRange(
  242. startDate,
  243. endDate,
  244. nProjects,
  245. signal
  246. ) {
  247. logger.debug(
  248. { startDate, endDate, nProjects },
  249. 'Sampling projects updated in date range'
  250. )
  251. const results = await verifyProjectsFromCursor(
  252. getProjectsUpdatedInDateRangeCursor(startDate, endDate, nProjects),
  253. signal
  254. )
  255. if (results.total === 0) {
  256. logger.debug(
  257. { start: startDate, end: endDate },
  258. 'No projects updated recently'
  259. )
  260. }
  261. const jobStatus = {
  262. ...results,
  263. startDate,
  264. endDate,
  265. }
  266. logger.debug(
  267. { ...jobStatus, errorTypes: Array.from(new Set(jobStatus.errorTypes)) },
  268. 'Verified recently updated projects'
  269. )
  270. return jobStatus
  271. }
  272. /**
  273. *
  274. * @param {EventEmitter} signal
  275. * @return {void}
  276. */
  277. export function loopRandomProjects(signal) {
  278. let shutdown = false
  279. signal.on('shutdown', function () {
  280. shutdown = true
  281. })
  282. async function loop() {
  283. do {
  284. try {
  285. const result = await verifyRandomProjectSample(100, signal, 2_000)
  286. logger.debug({ result }, 'verified random project sample')
  287. } catch (error) {
  288. logger.error({ error }, 'error verifying random project sample')
  289. }
  290. // eslint-disable-next-line no-unmodified-loop-condition
  291. } while (!shutdown)
  292. }
  293. loop()
  294. }