DocumentUpdaterManager.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. const request = require('request')
  2. const _ = require('lodash')
  3. const OError = require('@overleaf/o-error')
  4. const logger = require('@overleaf/logger')
  5. const settings = require('@overleaf/settings')
  6. const metrics = require('@overleaf/metrics')
  7. const {
  8. ClientRequestedMissingOpsError,
  9. DocumentUpdaterRequestFailedError,
  10. NullBytesInOpError,
  11. UpdateTooLargeError,
  12. } = require('./Errors')
  13. const rclient = require('@overleaf/redis-wrapper').createClient(
  14. settings.redis.documentupdater
  15. )
  16. const Keys = settings.redis.documentupdater.key_schema
  17. const DocumentUpdaterManager = {
  18. getDocument(projectId, docId, fromVersion, callback) {
  19. const timer = new metrics.Timer('get-document')
  20. const url = `${settings.apis.documentupdater.url}/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}&historyOTSupport=true`
  21. logger.debug(
  22. { projectId, docId, fromVersion },
  23. 'getting doc from document updater'
  24. )
  25. request.get(url, function (err, res, body) {
  26. timer.done()
  27. if (err) {
  28. OError.tag(err, 'error getting doc from doc updater')
  29. return callback(err)
  30. }
  31. if (res.statusCode >= 200 && res.statusCode < 300) {
  32. logger.debug(
  33. { projectId, docId },
  34. 'got doc from document document updater'
  35. )
  36. try {
  37. body = JSON.parse(body)
  38. } catch (error) {
  39. OError.tag(error, 'error parsing doc updater response')
  40. return callback(error)
  41. }
  42. body = body || {}
  43. callback(
  44. null,
  45. body.lines,
  46. body.version,
  47. body.ranges,
  48. body.ops,
  49. body.ttlInS,
  50. body.type
  51. )
  52. } else if (res.statusCode === 422 && body?.firstVersionInRedis) {
  53. callback(new ClientRequestedMissingOpsError(422, body))
  54. } else if ([404, 422].includes(res.statusCode)) {
  55. callback(new ClientRequestedMissingOpsError(res.statusCode))
  56. } else {
  57. callback(
  58. new DocumentUpdaterRequestFailedError('getDocument', res.statusCode)
  59. )
  60. }
  61. })
  62. },
  63. checkDocument(projectId, docId, callback) {
  64. // in this call fromVersion = -1 means get document without docOps
  65. DocumentUpdaterManager.getDocument(projectId, docId, -1, callback)
  66. },
  67. flushProjectToMongoAndDelete(projectId, callback) {
  68. // this method is called when the last connected user leaves the project
  69. logger.debug({ projectId }, 'deleting project from document updater')
  70. const timer = new metrics.Timer('delete.mongo.project')
  71. // flush the project in the background when all users have left
  72. const url =
  73. `${settings.apis.documentupdater.url}/project/${projectId}?background=true` +
  74. (settings.shutDownInProgress ? '&shutdown=true' : '')
  75. request.del(url, function (err, res) {
  76. timer.done()
  77. if (err) {
  78. OError.tag(err, 'error deleting project from document updater')
  79. callback(err)
  80. } else if (res.statusCode >= 200 && res.statusCode < 300) {
  81. logger.debug({ projectId }, 'deleted project from document updater')
  82. callback(null)
  83. } else {
  84. callback(
  85. new DocumentUpdaterRequestFailedError(
  86. 'flushProjectToMongoAndDelete',
  87. res.statusCode
  88. )
  89. )
  90. }
  91. })
  92. },
  93. _getPendingUpdateListKey() {
  94. const shard = _.random(0, settings.pendingUpdateListShardCount - 1)
  95. if (shard === 0) {
  96. return 'pending-updates-list'
  97. } else {
  98. return `pending-updates-list-${shard}`
  99. }
  100. },
  101. queueChange(projectId, docId, change, callback) {
  102. const allowedKeys = [
  103. 'doc',
  104. 'op',
  105. 'v',
  106. 'dupIfSource',
  107. 'meta',
  108. 'lastV',
  109. 'hash',
  110. ]
  111. change = _.pick(change, allowedKeys)
  112. const jsonChange = JSON.stringify(change)
  113. if (jsonChange.indexOf('\u0000') !== -1) {
  114. // memory corruption check
  115. return callback(new NullBytesInOpError(jsonChange))
  116. }
  117. const updateSize = jsonChange.length
  118. if (updateSize > settings.maxUpdateSize) {
  119. return callback(new UpdateTooLargeError(updateSize))
  120. }
  121. // record metric for each update added to queue
  122. metrics.summary('redis.pendingUpdates', updateSize, { status: 'push' })
  123. const docKey = `${projectId}:${docId}`
  124. // Push onto pendingUpdates for doc_id first, because once the doc updater
  125. // gets an entry on pending-updates-list, it starts processing.
  126. rclient.rpush(
  127. Keys.pendingUpdates({ doc_id: docId }),
  128. jsonChange,
  129. function (error) {
  130. if (error) {
  131. error = new OError('error pushing update into redis').withCause(error)
  132. return callback(error)
  133. }
  134. const queueKey = DocumentUpdaterManager._getPendingUpdateListKey()
  135. rclient.rpush(queueKey, docKey, function (error) {
  136. if (error) {
  137. error = new OError('error pushing doc_id into redis')
  138. .withInfo({ queueKey })
  139. .withCause(error)
  140. }
  141. callback(error)
  142. })
  143. }
  144. )
  145. },
  146. }
  147. module.exports = DocumentUpdaterManager