ErrorRecorder.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. // @ts-check
  2. import { callbackify } from 'node:util'
  3. import logger from '@overleaf/logger'
  4. import metrics from '@overleaf/metrics'
  5. import OError from '@overleaf/o-error'
  6. import { db } from './mongodb.js'
  7. /**
  8. * @import { ProjectHistoryFailure } from './mongo-types'
  9. */
  10. /**
  11. * @template {{error: string}|{}} T
  12. * @param {T} failure
  13. * @return {T}
  14. */
  15. function normalizeFailure(failure) {
  16. if ('error' in failure && failure.error?.includes('OError:')) {
  17. return {
  18. ...failure,
  19. error: failure.error.replace('OError:', 'Error:'),
  20. }
  21. }
  22. return failure
  23. }
  24. /**
  25. * @param {string} projectId
  26. * @param {number} queueSize
  27. * @param {Error} error
  28. * @return {Promise<ProjectHistoryFailure>} the failure record
  29. */
  30. async function record(projectId, queueSize, error) {
  31. const errorRecord = {
  32. queueSize,
  33. error: error.toString(),
  34. stack: error.stack ?? '',
  35. ts: new Date(),
  36. }
  37. logger.debug(
  38. { projectId, errorRecord },
  39. 'recording failed attempt to process updates'
  40. )
  41. const result = await db.projectHistoryFailures.findOneAndUpdate(
  42. { project_id: projectId },
  43. {
  44. $set: errorRecord,
  45. $inc: { attempts: 1 },
  46. $push: {
  47. history: {
  48. $each: [errorRecord],
  49. $position: 0,
  50. // only keep recent failures
  51. $slice: 10,
  52. },
  53. },
  54. },
  55. { upsert: true, returnDocument: 'after', includeResultMetadata: true }
  56. )
  57. if (result.value == null) {
  58. // Since we upsert, the result should always have a value
  59. throw new OError('no value returned when recording an error', { projectId })
  60. }
  61. return normalizeFailure(result.value)
  62. }
  63. async function clearError(projectId) {
  64. await db.projectHistoryFailures.deleteOne({ project_id: projectId })
  65. }
  66. async function setForceDebug(projectId, state) {
  67. if (state == null) {
  68. state = true
  69. }
  70. logger.debug({ projectId, state }, 'setting forceDebug state for project')
  71. await db.projectHistoryFailures.updateOne(
  72. { project_id: projectId },
  73. { $set: { forceDebug: state } },
  74. { upsert: true }
  75. )
  76. }
  77. // we only record the sync start time, and not the end time, because the
  78. // record should be cleared on success.
  79. async function recordSyncStart(projectId) {
  80. await db.projectHistoryFailures.updateOne(
  81. { project_id: projectId },
  82. {
  83. $currentDate: { resyncStartedAt: true },
  84. $inc: { resyncAttempts: 1 },
  85. $push: {
  86. history: {
  87. $each: [{ resyncStartedAt: new Date() }],
  88. $position: 0,
  89. $slice: 10,
  90. },
  91. },
  92. },
  93. { upsert: true }
  94. )
  95. }
  96. /**
  97. * @param {string} sourceProjectId
  98. * @param {string} targetProjectId
  99. * @return {Promise<void>}
  100. */
  101. async function cloneFailure(sourceProjectId, targetProjectId) {
  102. const failure = await db.projectHistoryFailures.findOne(
  103. { project_id: sourceProjectId.toString() },
  104. { projection: { _id: 0, project_id: 0 } }
  105. )
  106. if (!failure) return
  107. await db.projectHistoryFailures.insertOne({
  108. ...failure,
  109. project_id: targetProjectId.toString(),
  110. })
  111. }
  112. /**
  113. * @param projectId
  114. */
  115. async function getFailureRecord(projectId) {
  116. const result = await db.projectHistoryFailures.findOne({
  117. project_id: projectId,
  118. })
  119. return result && normalizeFailure(result)
  120. }
  121. async function getLastFailure(projectId) {
  122. const result = await db.projectHistoryFailures.findOneAndUpdate(
  123. { project_id: projectId },
  124. { $inc: { requestCount: 1 } }, // increment the request count every time we check the last failure
  125. { projection: { error: 1, ts: 1 } }
  126. )
  127. return result?.value && normalizeFailure(result.value)
  128. }
  129. async function getFailedProjects() {
  130. return await db.projectHistoryFailures
  131. .find({})
  132. .map(normalizeFailure)
  133. .toArray()
  134. }
  135. async function getFailuresByType() {
  136. const results = await getFailedProjects()
  137. const failureCounts = {}
  138. const failureAttempts = {}
  139. const failureRequests = {}
  140. const maxQueueSize = {}
  141. // count all the failures and number of attempts by type
  142. for (const result of results || []) {
  143. const failureType = 'error' in result ? result.error : 'resync'
  144. const attempts = result.attempts || 1 // allow for field to be absent
  145. const requests = result.requestCount || 0
  146. const queueSize = 'queueSize' in result ? result.queueSize : 0
  147. if (failureCounts[failureType] > 0) {
  148. failureCounts[failureType]++
  149. failureAttempts[failureType] += attempts
  150. failureRequests[failureType] += requests
  151. maxQueueSize[failureType] = Math.max(queueSize, maxQueueSize[failureType])
  152. } else {
  153. failureCounts[failureType] = 1
  154. failureAttempts[failureType] = attempts
  155. failureRequests[failureType] = requests
  156. maxQueueSize[failureType] = queueSize
  157. }
  158. }
  159. return { failureCounts, failureAttempts, failureRequests, maxQueueSize }
  160. }
  161. /**
  162. * Mapping between error messages and short labels.
  163. * @type {Record<string, string>}
  164. */
  165. const SHORT_ERROR_NAMES = {
  166. 'Error: bad response from filestore: 404': 'filestore-404',
  167. 'Error: bad response from filestore: 500': 'filestore-500',
  168. 'NotFoundError: got a 404 from web api': 'web-api-404',
  169. 'Error: history store a non-success status code: 413': 'history-store-413',
  170. 'Error: history store a non-success status code: 422': 'history-store-422',
  171. 'Error: history store a non-success status code: 500': 'history-store-500',
  172. 'Error: history store a non-success status code: 503': 'history-store-503',
  173. 'Error: web returned a non-success status code: 500 (attempts: 2)': 'web-500',
  174. 'Error: ESOCKETTIMEDOUT': 'socket-timeout',
  175. 'Error: no project found': 'no-project-found',
  176. 'OpsOutOfOrderError: project structure version out of order on incoming updates':
  177. 'incoming-project-version-out-of-order',
  178. 'OpsOutOfOrderError: doc version out of order on incoming updates':
  179. 'incoming-doc-version-out-of-order',
  180. 'OpsOutOfOrderError: project structure version out of order':
  181. 'chunk-project-version-out-of-order',
  182. 'OpsOutOfOrderError: doc version out of order':
  183. 'chunk-doc-version-out-of-order',
  184. 'Error: failed to extend lock': 'lock-overrun',
  185. 'Error: tried to release timed out lock': 'lock-overrun',
  186. 'Error: Timeout': 'lock-overrun',
  187. 'Error: sync ongoing': 'sync-ongoing',
  188. 'SyncError: unexpected resyncProjectStructure update': 'sync-error',
  189. '[object Error]': 'unknown-error-object',
  190. 'UpdateWithUnknownFormatError: update with unknown format': 'unknown-format',
  191. 'Error: update with unknown format': 'unknown-format',
  192. 'TextOperationError: The base length of the second operation has to be the target length of the first operation':
  193. 'text-op-error',
  194. 'Error: ENOSPC: no space left on device, write': 'ENOSPC',
  195. '*': 'other',
  196. }
  197. async function getFailuresFull() {
  198. const results = []
  199. for await (const failure of await getFailedProjects()) {
  200. results.push({
  201. category:
  202. 'error' in failure ? SHORT_ERROR_NAMES[failure.error] : undefined,
  203. ...failure,
  204. })
  205. }
  206. return results
  207. }
  208. async function getFailures() {
  209. const { failureCounts, failureAttempts, failureRequests, maxQueueSize } =
  210. await getFailuresByType()
  211. let attempts, failureType, label, requests
  212. // set all the known errors to zero if not present (otherwise gauges stay on their last value)
  213. const summaryCounts = {}
  214. const summaryAttempts = {}
  215. const summaryRequests = {}
  216. const summaryMaxQueueSize = {}
  217. for (failureType in SHORT_ERROR_NAMES) {
  218. label = SHORT_ERROR_NAMES[failureType]
  219. summaryCounts[label] = 0
  220. summaryAttempts[label] = 0
  221. summaryRequests[label] = 0
  222. summaryMaxQueueSize[label] = 0
  223. }
  224. // record a metric for each type of failure
  225. for (failureType in failureCounts) {
  226. const failureCount = failureCounts[failureType]
  227. label = SHORT_ERROR_NAMES[failureType] || SHORT_ERROR_NAMES['*']
  228. summaryCounts[label] += failureCount
  229. summaryAttempts[label] += failureAttempts[failureType]
  230. summaryRequests[label] += failureRequests[failureType]
  231. summaryMaxQueueSize[label] = Math.max(
  232. maxQueueSize[failureType],
  233. summaryMaxQueueSize[label]
  234. )
  235. }
  236. for (label in summaryCounts) {
  237. const count = summaryCounts[label]
  238. metrics.globalGauge('failed', count, 1, { status: label })
  239. }
  240. for (label in summaryAttempts) {
  241. attempts = summaryAttempts[label]
  242. metrics.globalGauge('attempts', attempts, 1, { status: label })
  243. }
  244. for (label in summaryRequests) {
  245. requests = summaryRequests[label]
  246. metrics.globalGauge('requests', requests, 1, { status: label })
  247. }
  248. for (label in summaryMaxQueueSize) {
  249. const queueSize = summaryMaxQueueSize[label]
  250. metrics.globalGauge('max-queue-size', queueSize, 1, { status: label })
  251. }
  252. return {
  253. counts: summaryCounts,
  254. attempts: summaryAttempts,
  255. requests: summaryRequests,
  256. maxQueueSize: summaryMaxQueueSize,
  257. }
  258. }
  259. // EXPORTS
  260. const getFailuresFullCb = callbackify(getFailuresFull)
  261. const getFailedProjectsCb = callbackify(getFailedProjects)
  262. const getFailureRecordCb = callbackify(getFailureRecord)
  263. const getFailuresCb = callbackify(getFailures)
  264. const cloneFailureCb = callbackify(cloneFailure)
  265. const getLastFailureCb = callbackify(getLastFailure)
  266. const recordCb = callbackify(record)
  267. const clearErrorCb = callbackify(clearError)
  268. const recordSyncStartCb = callbackify(recordSyncStart)
  269. const setForceDebugCb = callbackify(setForceDebug)
  270. export {
  271. cloneFailureCb as cloneFailure,
  272. getFailuresFullCb as getFailuresFull,
  273. getFailedProjectsCb as getFailedProjects,
  274. getFailureRecordCb as getFailureRecord,
  275. getLastFailureCb as getLastFailure,
  276. getFailuresCb as getFailures,
  277. recordCb as record,
  278. clearErrorCb as clearError,
  279. recordSyncStartCb as recordSyncStart,
  280. setForceDebugCb as setForceDebug,
  281. }
  282. export const promises = {
  283. getFailedProjects,
  284. cloneFailure,
  285. getFailureRecord,
  286. getLastFailure,
  287. getFailures,
  288. record,
  289. clearError,
  290. recordSyncStart,
  291. setForceDebug,
  292. }