DocumentUpdaterManager.js 4.9 KB

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