PersistenceManager.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. const { promisify } = require('node:util')
  2. const { promisifyMultiResult } = require('@overleaf/promise-utils')
  3. const Settings = require('@overleaf/settings')
  4. const Errors = require('./Errors')
  5. const Metrics = require('./Metrics')
  6. const logger = require('@overleaf/logger')
  7. const request = require('requestretry').defaults({
  8. maxAttempts: 2,
  9. retryDelay: 10,
  10. })
  11. // We have to be quick with HTTP calls because we're holding a lock that
  12. // expires after 30 seconds. We can't let any errors in the rest of the stack
  13. // hold us up, and need to bail out quickly if there is a problem.
  14. const MAX_HTTP_REQUEST_LENGTH = 5000 // 5 seconds
  15. function updateMetric(method, error, response) {
  16. // find the status, with special handling for connection timeouts
  17. // https://github.com/request/request#timeouts
  18. let status
  19. if (error && error.connect === true) {
  20. status = `${error.code} (connect)`
  21. } else if (error) {
  22. status = error.code
  23. } else if (response) {
  24. status = response.statusCode
  25. }
  26. Metrics.inc(method, 1, { status })
  27. if (error && error.attempts > 1) {
  28. Metrics.inc(`${method}-retries`, 1, { status: 'error' })
  29. }
  30. if (response && response.attempts > 1) {
  31. Metrics.inc(`${method}-retries`, 1, { status: 'success' })
  32. }
  33. }
  34. function getDoc(projectId, docId, options = {}, _callback) {
  35. const timer = new Metrics.Timer('persistenceManager.getDoc')
  36. if (typeof options === 'function') {
  37. _callback = options
  38. options = {}
  39. }
  40. const callback = function (...args) {
  41. timer.done()
  42. _callback(...args)
  43. }
  44. const urlPath = `/project/${projectId}/doc/${docId}`
  45. const requestParams = {
  46. url: `${Settings.apis.web.url}${urlPath}`,
  47. method: 'GET',
  48. headers: {
  49. accept: 'application/json',
  50. },
  51. auth: {
  52. user: Settings.apis.web.user,
  53. pass: Settings.apis.web.pass,
  54. sendImmediately: true,
  55. },
  56. jar: false,
  57. timeout: MAX_HTTP_REQUEST_LENGTH,
  58. }
  59. if (options.peek) {
  60. requestParams.qs = { peek: 'true' }
  61. }
  62. request(requestParams, (error, res, body) => {
  63. updateMetric('getDoc', error, res)
  64. if (error) {
  65. logger.error({ err: error, projectId, docId }, 'web API request failed')
  66. return callback(new Error('error connecting to web API'))
  67. }
  68. if (res.statusCode >= 200 && res.statusCode < 300) {
  69. try {
  70. body = JSON.parse(body)
  71. } catch (e) {
  72. return callback(e)
  73. }
  74. if (body.lines == null) {
  75. return callback(new Error('web API response had no doc lines'))
  76. }
  77. if (body.version == null) {
  78. return callback(new Error('web API response had no valid doc version'))
  79. }
  80. if (body.pathname == null) {
  81. return callback(new Error('web API response had no valid doc pathname'))
  82. }
  83. if (!body.pathname) {
  84. logger.warn(
  85. { projectId, docId },
  86. 'missing pathname in PersistenceManager getDoc'
  87. )
  88. Metrics.inc('pathname', 1, {
  89. path: 'PersistenceManager.getDoc',
  90. status: body.pathname === '' ? 'zero-length' : 'undefined',
  91. })
  92. }
  93. if (body.otMigrationStage > 0) {
  94. // Use history-ot
  95. body.lines = { content: body.lines.join('\n') }
  96. body.ranges = {}
  97. }
  98. callback(
  99. null,
  100. body.lines,
  101. body.version,
  102. body.ranges,
  103. body.pathname,
  104. body.projectHistoryId?.toString(),
  105. body.historyRangesSupport || false,
  106. body.resolvedCommentIds || []
  107. )
  108. } else if (res.statusCode === 404) {
  109. callback(new Errors.NotFoundError(`doc not not found: ${urlPath}`))
  110. } else if (res.statusCode === 413) {
  111. callback(
  112. new Errors.FileTooLargeError(`doc exceeds maximum size: ${urlPath}`)
  113. )
  114. } else {
  115. callback(
  116. new Error(`error accessing web API: ${urlPath} ${res.statusCode}`)
  117. )
  118. }
  119. })
  120. }
  121. function setDoc(
  122. projectId,
  123. docId,
  124. lines,
  125. version,
  126. ranges,
  127. lastUpdatedAt,
  128. lastUpdatedBy,
  129. _callback
  130. ) {
  131. const timer = new Metrics.Timer('persistenceManager.setDoc')
  132. const callback = function (...args) {
  133. timer.done()
  134. _callback(...args)
  135. }
  136. const urlPath = `/project/${projectId}/doc/${docId}`
  137. request(
  138. {
  139. url: `${Settings.apis.web.url}${urlPath}`,
  140. method: 'POST',
  141. json: {
  142. lines,
  143. ranges,
  144. version,
  145. lastUpdatedBy,
  146. lastUpdatedAt,
  147. },
  148. auth: {
  149. user: Settings.apis.web.user,
  150. pass: Settings.apis.web.pass,
  151. sendImmediately: true,
  152. },
  153. jar: false,
  154. timeout: MAX_HTTP_REQUEST_LENGTH,
  155. },
  156. (error, res, body) => {
  157. updateMetric('setDoc', error, res)
  158. if (error) {
  159. logger.error({ err: error, projectId, docId }, 'web API request failed')
  160. return callback(new Error('error connecting to web API'))
  161. }
  162. if (res.statusCode >= 200 && res.statusCode < 300) {
  163. callback(null, body)
  164. } else if (res.statusCode === 404) {
  165. callback(new Errors.NotFoundError(`doc not not found: ${urlPath}`))
  166. } else if (res.statusCode === 413) {
  167. callback(
  168. new Errors.FileTooLargeError(`doc exceeds maximum size: ${urlPath}`)
  169. )
  170. } else {
  171. callback(
  172. new Error(`error accessing web API: ${urlPath} ${res.statusCode}`)
  173. )
  174. }
  175. }
  176. )
  177. }
  178. module.exports = {
  179. getDoc,
  180. setDoc,
  181. promises: {
  182. getDoc: promisifyMultiResult(getDoc, [
  183. 'lines',
  184. 'version',
  185. 'ranges',
  186. 'pathname',
  187. 'projectHistoryId',
  188. 'historyRangesSupport',
  189. 'resolvedCommentIds',
  190. ]),
  191. setDoc: promisify(setDoc),
  192. },
  193. }