RedisManager.js 13 KB

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