RedisManager.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import { callbackify, promisify } from 'node:util'
  2. import { setTimeout } from 'node:timers/promises'
  3. import logger from '@overleaf/logger'
  4. import Settings from '@overleaf/settings'
  5. import redis from '@overleaf/redis-wrapper'
  6. import metrics from '@overleaf/metrics'
  7. import OError from '@overleaf/o-error'
  8. // maximum size taken from the redis queue, to prevent project history
  9. // consuming unbounded amounts of memory
  10. let RAW_UPDATE_SIZE_THRESHOLD = 4 * 1024 * 1024
  11. // maximum length of ops (insertion and deletions) to process in a single
  12. // iteration
  13. let MAX_UPDATE_OP_LENGTH = 1024
  14. // warn if we exceed this raw update size, the final compressed updates we send
  15. // could be smaller than this
  16. const WARN_RAW_UPDATE_SIZE = 1024 * 1024
  17. // maximum number of new docs to process in a single iteration
  18. let MAX_NEW_DOC_CONTENT_COUNT = 32
  19. const CACHE_TTL_IN_SECONDS = 3600
  20. const Keys = Settings.redis.project_history.key_schema
  21. const rclient = redis.createClient(Settings.redis.project_history)
  22. async function countUnprocessedUpdates(projectId) {
  23. const key = Keys.projectHistoryOps({ project_id: projectId })
  24. const updates = await rclient.llen(key)
  25. return updates
  26. }
  27. async function getOldestDocUpdates(projectId, batchSize) {
  28. const key = Keys.projectHistoryOps({ project_id: projectId })
  29. const updates = await rclient.lrange(key, 0, batchSize - 1)
  30. return updates
  31. }
  32. export function parseDocUpdates(jsonUpdates) {
  33. return jsonUpdates.map(update => JSON.parse(update))
  34. }
  35. async function getUpdatesInBatches(projectId, batchSize, runner) {
  36. let moreBatches = true
  37. while (moreBatches) {
  38. let rawUpdates = await getOldestDocUpdates(projectId, batchSize)
  39. moreBatches = rawUpdates.length === batchSize
  40. if (rawUpdates.length === 0) {
  41. return
  42. }
  43. // don't process any more batches if we are single stepping
  44. if (batchSize === 1) {
  45. moreBatches = false
  46. }
  47. // consume the updates up to a maximum total number of bytes
  48. // ensuring that at least one update will be processed (we may
  49. // exceed RAW_UPDATE_SIZE_THRESHOLD is the first update is bigger
  50. // than that).
  51. let totalRawUpdatesSize = 0
  52. const updatesToProcess = []
  53. for (const rawUpdate of rawUpdates) {
  54. const nextTotalSize = totalRawUpdatesSize + rawUpdate.length
  55. if (
  56. updatesToProcess.length > 0 &&
  57. nextTotalSize > RAW_UPDATE_SIZE_THRESHOLD
  58. ) {
  59. // stop consuming updates if we have at least one and the
  60. // next update would exceed the size threshold
  61. break
  62. } else {
  63. updatesToProcess.push(rawUpdate)
  64. totalRawUpdatesSize += rawUpdate.length
  65. }
  66. }
  67. // if we hit the size limit above, only process the updates up to that point
  68. if (updatesToProcess.length < rawUpdates.length) {
  69. moreBatches = true // process remaining raw updates in the next iteration
  70. rawUpdates = updatesToProcess
  71. }
  72. metrics.timing('redis.incoming.bytes', totalRawUpdatesSize, 1)
  73. if (totalRawUpdatesSize > WARN_RAW_UPDATE_SIZE) {
  74. const rawUpdateSizes = rawUpdates.map(rawUpdate => rawUpdate.length)
  75. logger.warn(
  76. { projectId, totalRawUpdatesSize, rawUpdateSizes },
  77. 'large raw update size'
  78. )
  79. }
  80. let updates
  81. try {
  82. updates = parseDocUpdates(rawUpdates)
  83. } catch (error) {
  84. throw OError.tag(error, 'failed to parse updates', {
  85. projectId,
  86. updates,
  87. })
  88. }
  89. // consume the updates up to a maximum number of ops (insertions and deletions)
  90. let totalOpLength = 0
  91. let updatesToProcessCount = 0
  92. let totalDocContentCount = 0
  93. for (const parsedUpdate of updates) {
  94. if (parsedUpdate.resyncDocContent) {
  95. totalDocContentCount++
  96. }
  97. if (totalDocContentCount > MAX_NEW_DOC_CONTENT_COUNT) {
  98. break
  99. }
  100. const nextTotalOpLength = totalOpLength + (parsedUpdate?.op?.length || 1)
  101. if (
  102. updatesToProcessCount > 0 &&
  103. nextTotalOpLength > MAX_UPDATE_OP_LENGTH
  104. ) {
  105. break
  106. } else {
  107. totalOpLength = nextTotalOpLength
  108. updatesToProcessCount++
  109. }
  110. }
  111. // if we hit the op limit above, only process the updates up to that point
  112. if (updatesToProcessCount < updates.length) {
  113. logger.debug(
  114. {
  115. projectId,
  116. updatesToProcessCount,
  117. updates_count: updates.length,
  118. totalOpLength,
  119. },
  120. 'restricting number of ops to be processed'
  121. )
  122. moreBatches = true
  123. // there is a 1:1 mapping between rawUpdates and updates
  124. // which we need to preserve here to ensure we only
  125. // delete the updates that are actually processed
  126. rawUpdates = rawUpdates.slice(0, updatesToProcessCount)
  127. updates = updates.slice(0, updatesToProcessCount)
  128. }
  129. logger.debug({ projectId }, 'retrieved raw updates from redis')
  130. await runner(updates)
  131. await deleteAppliedDocUpdates(projectId, rawUpdates)
  132. }
  133. }
  134. async function deleteAppliedDocUpdates(projectId, updates) {
  135. const multi = rclient.multi()
  136. // Delete all the updates which have been applied (exact match)
  137. for (const update of updates) {
  138. // Delete the first occurrence of the update with LREM KEY COUNT
  139. // VALUE by setting COUNT to 1 which 'removes COUNT elements equal to
  140. // value moving from head to tail.'
  141. //
  142. // If COUNT is 0 the entire list would be searched which would block
  143. // redis snce it would be an O(N) operation where N is the length of
  144. // the queue, in a multi of the batch size.
  145. metrics.summary('redis.projectHistoryOps', update.length, {
  146. status: 'lrem',
  147. })
  148. multi.lrem(Keys.projectHistoryOps({ project_id: projectId }), 1, update)
  149. }
  150. if (updates.length > 0) {
  151. multi.del(Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }))
  152. }
  153. await multi.exec()
  154. }
  155. /**
  156. * Deletes the entire queue - use with caution
  157. */
  158. async function destroyDocUpdatesQueue(projectId) {
  159. await rclient.del(
  160. Keys.projectHistoryOps({ project_id: projectId }),
  161. Keys.projectHistoryFirstOpTimestamp({ project_id: projectId })
  162. )
  163. }
  164. /**
  165. * Iterate over keys asynchronously using redis scan (non-blocking)
  166. *
  167. * handle all the cluster nodes or single redis server
  168. */
  169. async function _getKeys(pattern, limit) {
  170. const nodes = rclient.nodes?.('master') || [rclient]
  171. const keysByNode = []
  172. for (const node of nodes) {
  173. const keys = await _getKeysFromNode(node, pattern, limit)
  174. keysByNode.push(keys)
  175. }
  176. return [].concat(...keysByNode)
  177. }
  178. async function _getKeysFromNode(node, pattern, limit) {
  179. let cursor = 0 // redis iterator
  180. const keySet = new Set() // avoid duplicate results
  181. const batchSize = limit != null ? Math.min(limit, 1000) : 1000
  182. // scan over all keys looking for pattern
  183. while (true) {
  184. const reply = await node.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize)
  185. const [newCursor, keys] = reply
  186. cursor = newCursor
  187. for (const key of keys) {
  188. keySet.add(key)
  189. }
  190. const noResults = cursor === '0' // redis returns string results not numeric
  191. const limitReached = limit != null && keySet.size >= limit
  192. if (noResults || limitReached) {
  193. return Array.from(keySet)
  194. }
  195. // avoid hitting redis too hard
  196. await setTimeout(10)
  197. }
  198. }
  199. /**
  200. * Extract ids from keys like DocsWithHistoryOps:57fd0b1f53a8396d22b2c24b
  201. * or DocsWithHistoryOps:{57fd0b1f53a8396d22b2c24b} (for redis cluster)
  202. */
  203. function _extractIds(keyList) {
  204. return keyList.map(key => {
  205. const m = key.match(/:\{?([0-9a-f]{24})\}?/) // extract object id
  206. return m[1]
  207. })
  208. }
  209. async function getProjectIdsWithHistoryOps(limit) {
  210. const projectKeys = await _getKeys(
  211. Keys.projectHistoryOps({ project_id: '*' }),
  212. limit
  213. )
  214. const projectIds = _extractIds(projectKeys)
  215. return projectIds
  216. }
  217. async function getProjectIdsWithHistoryOpsCount() {
  218. const projectIds = await getProjectIdsWithHistoryOps()
  219. const queuedProjectsCount = projectIds.length
  220. metrics.globalGauge('queued-projects', queuedProjectsCount)
  221. return queuedProjectsCount
  222. }
  223. async function setFirstOpTimestamp(projectId) {
  224. const key = Keys.projectHistoryFirstOpTimestamp({ project_id: projectId })
  225. // store current time as an integer (string)
  226. await rclient.setnx(key, Date.now())
  227. }
  228. async function getFirstOpTimestamp(projectId) {
  229. const key = Keys.projectHistoryFirstOpTimestamp({ project_id: projectId })
  230. const result = await rclient.get(key)
  231. // convert stored time back to a numeric timestamp
  232. const timestamp = parseInt(result, 10)
  233. // check for invalid timestamp
  234. if (isNaN(timestamp)) {
  235. return null
  236. }
  237. // convert numeric timestamp to a date object
  238. const firstOpTimestamp = new Date(timestamp)
  239. return firstOpTimestamp
  240. }
  241. async function clearFirstOpTimestamp(projectId) {
  242. const key = Keys.projectHistoryFirstOpTimestamp({ project_id: projectId })
  243. await rclient.del(key)
  244. }
  245. async function getProjectIdsWithFirstOpTimestamps(limit) {
  246. const projectKeys = await _getKeys(
  247. Keys.projectHistoryFirstOpTimestamp({ project_id: '*' }),
  248. limit
  249. )
  250. const projectIds = _extractIds(projectKeys)
  251. return projectIds
  252. }
  253. async function clearDanglingFirstOpTimestamp(projectId) {
  254. const count = await rclient.exists(
  255. Keys.projectHistoryFirstOpTimestamp({ project_id: projectId }),
  256. Keys.projectHistoryOps({ project_id: projectId })
  257. )
  258. if (count === 2 || count === 0) {
  259. // both (or neither) keys are present, so don't delete the timestamp
  260. return 0
  261. }
  262. // only one key is present, which makes this a dangling record,
  263. // so delete the timestamp
  264. const cleared = await rclient.del(
  265. Keys.projectHistoryFirstOpTimestamp({ project_id: projectId })
  266. )
  267. return cleared
  268. }
  269. async function getCachedHistoryId(projectId) {
  270. const key = Keys.projectHistoryCachedHistoryId({ project_id: projectId })
  271. const historyId = await rclient.get(key)
  272. return historyId
  273. }
  274. async function setCachedHistoryId(projectId, historyId) {
  275. const key = Keys.projectHistoryCachedHistoryId({ project_id: projectId })
  276. await rclient.setex(key, CACHE_TTL_IN_SECONDS, historyId)
  277. }
  278. async function clearCachedHistoryId(projectId) {
  279. const key = Keys.projectHistoryCachedHistoryId({ project_id: projectId })
  280. await rclient.del(key)
  281. }
  282. // for tests
  283. export function setMaxUpdateOpLength(value) {
  284. MAX_UPDATE_OP_LENGTH = value
  285. }
  286. export function setRawUpdateSizeThreshold(value) {
  287. RAW_UPDATE_SIZE_THRESHOLD = value
  288. }
  289. export function setMaxNewDocContentCount(value) {
  290. MAX_NEW_DOC_CONTENT_COUNT = value
  291. }
  292. // EXPORTS
  293. const countUnprocessedUpdatesCb = callbackify(countUnprocessedUpdates)
  294. const getOldestDocUpdatesCb = callbackify(getOldestDocUpdates)
  295. const deleteAppliedDocUpdatesCb = callbackify(deleteAppliedDocUpdates)
  296. const destroyDocUpdatesQueueCb = callbackify(destroyDocUpdatesQueue)
  297. const getProjectIdsWithHistoryOpsCb = callbackify(getProjectIdsWithHistoryOps)
  298. const getProjectIdsWithHistoryOpsCountCb = callbackify(
  299. getProjectIdsWithHistoryOpsCount
  300. )
  301. const setFirstOpTimestampCb = callbackify(setFirstOpTimestamp)
  302. const getFirstOpTimestampCb = callbackify(getFirstOpTimestamp)
  303. const clearFirstOpTimestampCb = callbackify(clearFirstOpTimestamp)
  304. const getProjectIdsWithFirstOpTimestampsCb = callbackify(
  305. getProjectIdsWithFirstOpTimestamps
  306. )
  307. const clearDanglingFirstOpTimestampCb = callbackify(
  308. clearDanglingFirstOpTimestamp
  309. )
  310. const getCachedHistoryIdCb = callbackify(getCachedHistoryId)
  311. const setCachedHistoryIdCb = callbackify(setCachedHistoryId)
  312. const clearCachedHistoryIdCb = callbackify(clearCachedHistoryId)
  313. const getUpdatesInBatchesCb = function (
  314. projectId,
  315. batchSize,
  316. runner,
  317. callback
  318. ) {
  319. const runnerPromises = promisify(runner)
  320. getUpdatesInBatches(projectId, batchSize, runnerPromises)
  321. .then(result => {
  322. callback(null, result)
  323. })
  324. .catch(err => {
  325. callback(err)
  326. })
  327. }
  328. export {
  329. countUnprocessedUpdatesCb as countUnprocessedUpdates,
  330. getOldestDocUpdatesCb as getOldestDocUpdates,
  331. deleteAppliedDocUpdatesCb as deleteAppliedDocUpdates,
  332. destroyDocUpdatesQueueCb as destroyDocUpdatesQueue,
  333. getUpdatesInBatchesCb as getUpdatesInBatches,
  334. getProjectIdsWithHistoryOpsCb as getProjectIdsWithHistoryOps,
  335. getProjectIdsWithHistoryOpsCountCb as getProjectIdsWithHistoryOpsCount,
  336. setFirstOpTimestampCb as setFirstOpTimestamp,
  337. getFirstOpTimestampCb as getFirstOpTimestamp,
  338. clearFirstOpTimestampCb as clearFirstOpTimestamp,
  339. getProjectIdsWithFirstOpTimestampsCb as getProjectIdsWithFirstOpTimestamps,
  340. clearDanglingFirstOpTimestampCb as clearDanglingFirstOpTimestamp,
  341. getCachedHistoryIdCb as getCachedHistoryId,
  342. setCachedHistoryIdCb as setCachedHistoryId,
  343. clearCachedHistoryIdCb as clearCachedHistoryId,
  344. }
  345. export const promises = {
  346. countUnprocessedUpdates,
  347. getOldestDocUpdates,
  348. deleteAppliedDocUpdates,
  349. destroyDocUpdatesQueue,
  350. getUpdatesInBatches,
  351. getProjectIdsWithHistoryOps,
  352. getProjectIdsWithHistoryOpsCount,
  353. setFirstOpTimestamp,
  354. getFirstOpTimestamp,
  355. clearFirstOpTimestamp,
  356. getProjectIdsWithFirstOpTimestamps,
  357. clearDanglingFirstOpTimestamp,
  358. getCachedHistoryId,
  359. setCachedHistoryId,
  360. clearCachedHistoryId,
  361. }