RedisManager.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. /* eslint-disable
  2. camelcase,
  3. */
  4. // TODO: This file was created by bulk-decaffeinate.
  5. // Fix any style issues and re-enable lint.
  6. /*
  7. * decaffeinate suggestions:
  8. * DS101: Remove unnecessary use of Array.from
  9. * DS102: Remove unnecessary code created because of implicit returns
  10. * DS205: Consider reworking code to avoid use of IIFEs
  11. * DS207: Consider shorter variations of null checks
  12. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  13. */
  14. import { promisify } from 'util'
  15. import logger from '@overleaf/logger'
  16. import Settings from '@overleaf/settings'
  17. import async from 'async'
  18. import redis from '@overleaf/redis-wrapper'
  19. import metrics from '@overleaf/metrics'
  20. import OError from '@overleaf/o-error'
  21. // maximum size taken from the redis queue, to prevent project history
  22. // consuming unbounded amounts of memory
  23. let RAW_UPDATE_SIZE_THRESHOLD = 4 * 1024 * 1024
  24. // maximum length of ops (insertion and deletions) to process in a single
  25. // iteration
  26. let MAX_UPDATE_OP_LENGTH = 1024
  27. // warn if we exceed this raw update size, the final compressed updates we send
  28. // could be smaller than this
  29. const WARN_RAW_UPDATE_SIZE = 1024 * 1024
  30. // maximum number of new docs to process in a single iteration
  31. let MAX_NEW_DOC_CONTENT_COUNT = 32
  32. const CACHE_TTL_IN_SECONDS = 3600
  33. const Keys = Settings.redis.project_history.key_schema
  34. const rclient = redis.createClient(Settings.redis.project_history)
  35. /**
  36. * Container for functions that need to be mocked in tests
  37. *
  38. * TODO: Rewrite tests in terms of exported functions only
  39. */
  40. export const _mocks = {}
  41. export function countUnprocessedUpdates(project_id, callback) {
  42. const key = Keys.projectHistoryOps({ project_id })
  43. return rclient.llen(key, callback)
  44. }
  45. _mocks.getOldestDocUpdates = (project_id, batch_size, callback) => {
  46. if (callback == null) {
  47. callback = function () {}
  48. }
  49. const key = Keys.projectHistoryOps({ project_id })
  50. rclient.lrange(key, 0, batch_size - 1, callback)
  51. }
  52. export function getOldestDocUpdates(...args) {
  53. _mocks.getOldestDocUpdates(...args)
  54. }
  55. _mocks.parseDocUpdates = (json_updates, callback) => {
  56. let parsed_updates
  57. if (callback == null) {
  58. callback = function () {}
  59. }
  60. try {
  61. parsed_updates = Array.from(json_updates || []).map(update =>
  62. JSON.parse(update)
  63. )
  64. } catch (e) {
  65. return callback(e)
  66. }
  67. callback(null, parsed_updates)
  68. }
  69. export function parseDocUpdates(...args) {
  70. _mocks.parseDocUpdates(...args)
  71. }
  72. export function getUpdatesInBatches(project_id, batch_size, runner, callback) {
  73. let moreBatches = true
  74. let lastResults = []
  75. const processBatch = cb =>
  76. getOldestDocUpdates(project_id, batch_size, function (error, raw_updates) {
  77. let raw_update
  78. if (error != null) {
  79. return cb(OError.tag(error))
  80. }
  81. moreBatches = raw_updates.length === batch_size
  82. if (raw_updates.length === 0) {
  83. return cb()
  84. }
  85. // don't process any more batches if we are single stepping
  86. if (batch_size === 1) {
  87. moreBatches = false
  88. }
  89. // consume the updates up to a maximum total number of bytes
  90. // ensuring that at least one update will be processed (we may
  91. // exceed RAW_UPDATE_SIZE_THRESHOLD is the first update is bigger
  92. // than that).
  93. let total_raw_updates_size = 0
  94. const updates_to_process = []
  95. for (raw_update of Array.from(raw_updates)) {
  96. const next_total_size = total_raw_updates_size + raw_update.length
  97. if (
  98. updates_to_process.length > 0 &&
  99. next_total_size > RAW_UPDATE_SIZE_THRESHOLD
  100. ) {
  101. // stop consuming updates if we have at least one and the
  102. // next update would exceed the size threshold
  103. break
  104. } else {
  105. updates_to_process.push(raw_update)
  106. total_raw_updates_size += raw_update.length
  107. }
  108. }
  109. // if we hit the size limit above, only process the updates up to that point
  110. if (updates_to_process.length < raw_updates.length) {
  111. moreBatches = true // process remaining raw updates in the next iteration
  112. raw_updates = updates_to_process
  113. }
  114. metrics.timing('redis.incoming.bytes', total_raw_updates_size, 1)
  115. if (total_raw_updates_size > WARN_RAW_UPDATE_SIZE) {
  116. const raw_update_sizes = (() => {
  117. const result = []
  118. for (raw_update of Array.from(raw_updates)) {
  119. result.push(raw_update.length)
  120. }
  121. return result
  122. })()
  123. logger.warn(
  124. { project_id, total_raw_updates_size, raw_update_sizes },
  125. 'large raw update size'
  126. )
  127. }
  128. return parseDocUpdates(raw_updates, function (error, updates) {
  129. if (error != null) {
  130. OError.tag(error, 'failed to parse updates', {
  131. project_id,
  132. updates,
  133. })
  134. return cb(error)
  135. }
  136. // consume the updates up to a maximum number of ops (insertions and deletions)
  137. let total_op_length = 0
  138. let updates_to_process_count = 0
  139. let total_doc_content_count = 0
  140. for (const parsed_update of Array.from(updates)) {
  141. if (parsed_update.resyncDocContent) {
  142. total_doc_content_count++
  143. }
  144. if (total_doc_content_count > MAX_NEW_DOC_CONTENT_COUNT) {
  145. break
  146. }
  147. const next_total_op_length =
  148. total_op_length + (parsed_update?.op?.length || 1)
  149. if (
  150. updates_to_process_count > 0 &&
  151. next_total_op_length > MAX_UPDATE_OP_LENGTH
  152. ) {
  153. break
  154. } else {
  155. total_op_length = next_total_op_length
  156. updates_to_process_count++
  157. }
  158. }
  159. // if we hit the op limit above, only process the updates up to that point
  160. if (updates_to_process_count < updates.length) {
  161. logger.debug(
  162. {
  163. project_id,
  164. updates_to_process_count,
  165. updates_count: updates.length,
  166. total_op_length,
  167. },
  168. 'restricting number of ops to be processed'
  169. )
  170. moreBatches = true
  171. // there is a 1:1 mapping between raw_updates and updates
  172. // which we need to preserve here to ensure we only
  173. // delete the updates that are actually processed
  174. raw_updates = raw_updates.slice(0, updates_to_process_count)
  175. updates = updates.slice(0, updates_to_process_count)
  176. }
  177. logger.debug({ project_id }, 'retrieved raw updates from redis')
  178. return runner(updates, function (error, ...args) {
  179. lastResults = args
  180. if (error != null) {
  181. return cb(OError.tag(error))
  182. }
  183. return deleteAppliedDocUpdates(project_id, raw_updates, cb)
  184. })
  185. })
  186. })
  187. const hasMoreBatches = (...args) => {
  188. const cb = args[args.length - 1]
  189. return cb(null, moreBatches)
  190. }
  191. return async.doWhilst(processBatch, hasMoreBatches, error =>
  192. callback(error, ...Array.from(lastResults))
  193. )
  194. }
  195. _mocks.deleteAppliedDocUpdates = (project_id, updates, callback) => {
  196. if (callback == null) {
  197. callback = function () {}
  198. }
  199. const multi = rclient.multi()
  200. // Delete all the updates which have been applied (exact match)
  201. for (const update of Array.from(updates || [])) {
  202. // Delete the first occurrence of the update with LREM KEY COUNT
  203. // VALUE by setting COUNT to 1 which 'removes COUNT elements equal to
  204. // value moving from head to tail.'
  205. //
  206. // If COUNT is 0 the entire list would be searched which would block
  207. // redis snce it would be an O(N) operation where N is the length of
  208. // the queue, in a multi of the batch size.
  209. metrics.summary('redis.projectHistoryOps', update.length, {
  210. status: 'lrem',
  211. })
  212. multi.lrem(Keys.projectHistoryOps({ project_id }), 1, update)
  213. }
  214. multi.exec(callback)
  215. }
  216. export function deleteAppliedDocUpdates(...args) {
  217. _mocks.deleteAppliedDocUpdates(...args)
  218. }
  219. export function destroyDocUpdatesQueue(project_id, callback) {
  220. // deletes the entire queue - use with caution
  221. if (callback == null) {
  222. callback = function () {}
  223. }
  224. return rclient.del(Keys.projectHistoryOps({ project_id }), callback)
  225. }
  226. // iterate over keys asynchronously using redis scan (non-blocking)
  227. // handle all the cluster nodes or single redis server
  228. function _getKeys(pattern, limit, callback) {
  229. const nodes = (typeof rclient.nodes === 'function'
  230. ? rclient.nodes('master')
  231. : undefined) || [rclient]
  232. const doKeyLookupForNode = (node, cb) =>
  233. _getKeysFromNode(node, pattern, limit, cb)
  234. return async.concatSeries(nodes, doKeyLookupForNode, callback)
  235. }
  236. function _getKeysFromNode(node, pattern, limit, callback) {
  237. let cursor = 0 // redis iterator
  238. const keySet = {} // use hash to avoid duplicate results
  239. const batchSize = limit != null ? Math.min(limit, 1000) : 1000
  240. // scan over all keys looking for pattern
  241. const doIteration = (
  242. cb // avoid hitting redis too hard
  243. ) =>
  244. node.scan(
  245. cursor,
  246. 'MATCH',
  247. pattern,
  248. 'COUNT',
  249. batchSize,
  250. function (error, reply) {
  251. let keys
  252. if (error != null) {
  253. return callback(OError.tag(error))
  254. }
  255. ;[cursor, keys] = Array.from(reply)
  256. for (const key of Array.from(keys)) {
  257. keySet[key] = true
  258. }
  259. keys = Object.keys(keySet)
  260. const noResults = cursor === '0' // redis returns string results not numeric
  261. const limitReached = limit != null && keys.length >= limit
  262. if (noResults || limitReached) {
  263. return callback(null, keys)
  264. } else {
  265. return setTimeout(doIteration, 10)
  266. }
  267. }
  268. )
  269. return doIteration()
  270. }
  271. // extract ids from keys like DocsWithHistoryOps:57fd0b1f53a8396d22b2c24b
  272. // or DocsWithHistoryOps:{57fd0b1f53a8396d22b2c24b} (for redis cluster)
  273. function _extractIds(keyList) {
  274. const ids = (() => {
  275. const result = []
  276. for (const key of Array.from(keyList)) {
  277. const m = key.match(/:\{?([0-9a-f]{24})\}?/) // extract object id
  278. result.push(m[1])
  279. }
  280. return result
  281. })()
  282. return ids
  283. }
  284. export function getProjectIdsWithHistoryOps(limit, callback) {
  285. if (callback == null) {
  286. callback = function () {}
  287. }
  288. return _getKeys(
  289. Keys.projectHistoryOps({ project_id: '*' }),
  290. limit,
  291. function (error, project_keys) {
  292. if (error != null) {
  293. return callback(OError.tag(error))
  294. }
  295. const project_ids = _extractIds(project_keys)
  296. return callback(error, project_ids)
  297. }
  298. )
  299. }
  300. export function getProjectIdsWithHistoryOpsCount(callback) {
  301. if (callback == null) {
  302. callback = function () {}
  303. }
  304. return getProjectIdsWithHistoryOps(null, function (error, projectIds) {
  305. if (error != null) {
  306. return callback(OError.tag(error))
  307. }
  308. const queuedProjectsCount = projectIds.length
  309. metrics.globalGauge('queued-projects', queuedProjectsCount)
  310. return callback(null, queuedProjectsCount)
  311. })
  312. }
  313. export function setFirstOpTimestamp(project_id, callback) {
  314. if (callback == null) {
  315. callback = function () {}
  316. }
  317. const key = Keys.projectHistoryFirstOpTimestamp({ project_id })
  318. // store current time as an integer (string)
  319. return rclient.setnx(key, Date.now(), callback)
  320. }
  321. export function getFirstOpTimestamp(project_id, callback) {
  322. if (callback == null) {
  323. callback = function () {}
  324. }
  325. const key = Keys.projectHistoryFirstOpTimestamp({ project_id })
  326. return rclient.get(key, function (err, result) {
  327. if (err != null) {
  328. return callback(OError.tag(err))
  329. }
  330. // convert stored time back to a numeric timestamp
  331. const timestamp = parseInt(result, 10)
  332. // check for invalid timestamp
  333. if (isNaN(timestamp)) {
  334. return callback()
  335. }
  336. // convert numeric timestamp to a date object
  337. const firstOpTimestamp = new Date(timestamp)
  338. return callback(null, firstOpTimestamp)
  339. })
  340. }
  341. export function clearFirstOpTimestamp(project_id, callback) {
  342. if (callback == null) {
  343. callback = function () {}
  344. }
  345. const key = Keys.projectHistoryFirstOpTimestamp({ project_id })
  346. return rclient.del(key, callback)
  347. }
  348. export function getProjectIdsWithFirstOpTimestamps(limit, callback) {
  349. return _getKeys(
  350. Keys.projectHistoryFirstOpTimestamp({ project_id: '*' }),
  351. limit,
  352. function (error, project_keys) {
  353. if (error != null) {
  354. return callback(OError.tag(error))
  355. }
  356. const project_ids = _extractIds(project_keys)
  357. return callback(error, project_ids)
  358. }
  359. )
  360. }
  361. export function clearDanglingFirstOpTimestamp(project_id, callback) {
  362. rclient.exists(
  363. Keys.projectHistoryFirstOpTimestamp({ project_id }),
  364. Keys.projectHistoryOps({ project_id }),
  365. function (error, count) {
  366. if (error) {
  367. return callback(error)
  368. }
  369. if (count === 2 || count === 0) {
  370. // both (or neither) keys are present, so don't delete the timestamp
  371. return callback(null, 0)
  372. }
  373. // only one key is present, which makes this a dangling record,
  374. // so delete the timestamp
  375. rclient.del(Keys.projectHistoryFirstOpTimestamp({ project_id }), callback)
  376. }
  377. )
  378. }
  379. export function getCachedHistoryId(project_id, callback) {
  380. const key = Keys.projectHistoryCachedHistoryId({ project_id })
  381. rclient.get(key, function (err, historyId) {
  382. if (err) {
  383. return callback(OError.tag(err))
  384. }
  385. callback(null, historyId)
  386. })
  387. }
  388. export function setCachedHistoryId(project_id, historyId, callback) {
  389. const key = Keys.projectHistoryCachedHistoryId({ project_id })
  390. rclient.setex(key, CACHE_TTL_IN_SECONDS, historyId, callback)
  391. }
  392. export function clearCachedHistoryId(project_id, callback) {
  393. const key = Keys.projectHistoryCachedHistoryId({ project_id })
  394. rclient.del(key, callback)
  395. }
  396. // for tests
  397. export function setMaxUpdateOpLength(value) {
  398. MAX_UPDATE_OP_LENGTH = value
  399. }
  400. export function setRawUpdateSizeThreshold(value) {
  401. RAW_UPDATE_SIZE_THRESHOLD = value
  402. }
  403. export function setMaxNewDocContentCount(value) {
  404. MAX_NEW_DOC_CONTENT_COUNT = value
  405. }
  406. export const promises = {
  407. countUnprocessedUpdates: promisify(countUnprocessedUpdates),
  408. getProjectIdsWithFirstOpTimestamps: promisify(
  409. getProjectIdsWithFirstOpTimestamps
  410. ),
  411. clearDanglingFirstOpTimestamp: promisify(clearDanglingFirstOpTimestamp),
  412. }