RedisManager.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. const Settings = require('@overleaf/settings')
  2. const RedisWrapper = require('@overleaf/redis-wrapper')
  3. const logger = require('@overleaf/logger')
  4. const OError = require('@overleaf/o-error')
  5. const { callbackifyAll } = require('@overleaf/promise-utils')
  6. const metrics = require('./Metrics')
  7. const Errors = require('./Errors')
  8. const crypto = require('node:crypto')
  9. const { docIsTooLarge } = require('./Limits')
  10. const rclient = RedisWrapper.createClient(Settings.redis.documentupdater)
  11. // Sometimes Redis calls take an unexpectedly long time. We have to be
  12. // quick with Redis calls because we're holding a lock that expires
  13. // after 30 seconds. We can't let any errors in the rest of the stack
  14. // hold us up, and need to bail out quickly if there is a problem.
  15. const MAX_REDIS_REQUEST_LENGTH = 5000 // 5 seconds
  16. const PROJECT_BLOCK_TTL_SECS = 30
  17. // Make times easy to read
  18. const minutes = 60 // seconds for Redis expire
  19. const logHashReadErrors = Settings.documentupdater?.logHashErrors?.read
  20. const MEGABYTES = 1024 * 1024
  21. const MAX_RANGES_SIZE = 3 * MEGABYTES
  22. const keys = Settings.redis.documentupdater.key_schema
  23. const RedisManager = {
  24. async putDocInMemory(
  25. projectId,
  26. docId,
  27. docLines,
  28. version,
  29. ranges,
  30. resolvedCommentIds,
  31. pathname,
  32. projectHistoryId,
  33. historyRangesSupport
  34. ) {
  35. const timer = new metrics.Timer('redis.put-doc')
  36. const shareJSTextOT = Array.isArray(docLines)
  37. const docLinesArray = docLines
  38. docLines = JSON.stringify(docLines)
  39. if (docLines.indexOf('\u0000') !== -1) {
  40. // this check was added to catch memory corruption in JSON.stringify.
  41. // It sometimes returned null bytes at the end of the string.
  42. throw new OError('null bytes found in doc lines', { docId })
  43. }
  44. // Do an optimised size check on the docLines using the serialised
  45. // length as an upper bound
  46. const sizeBound = docLines.length
  47. if (
  48. shareJSTextOT && // editor-core has a size check in TextOperation.apply and TextOperation.applyToLength.
  49. docIsTooLarge(sizeBound, docLinesArray, Settings.max_doc_length)
  50. ) {
  51. const docSize = docLines.length
  52. throw new OError('blocking doc insert into redis: doc is too large', {
  53. projectId,
  54. docId,
  55. docSize,
  56. })
  57. }
  58. const docHash = RedisManager._computeHash(docLines)
  59. // record bytes sent to redis
  60. metrics.summary('redis.docLines', docLines.length, { status: 'set' })
  61. logger.debug(
  62. { projectId, docId, version, docHash, pathname, projectHistoryId },
  63. 'putting doc in redis'
  64. )
  65. ranges = RedisManager._serializeRanges(ranges)
  66. // update docsInProject set before writing doc contents
  67. const projectBlockMulti = rclient.multi()
  68. projectBlockMulti.exists(keys.projectBlock({ project_id: projectId }))
  69. projectBlockMulti.sadd(keys.docsInProject({ project_id: projectId }), docId)
  70. const reply = await projectBlockMulti.exec()
  71. const projectBlocked = reply[0] === 1
  72. if (projectBlocked) {
  73. // We don't clean up the spurious docId added in the docsInProject
  74. // set. There is a risk that the docId was successfully added by a
  75. // concurrent process. This set is used when unloading projects. An
  76. // extra docId will not prevent the project from being uploaded, but
  77. // a missing docId means that the doc might stay in Redis forever.
  78. throw new OError('Project blocked from loading docs', { projectId })
  79. }
  80. await RedisManager.setHistoryRangesSupportFlag(docId, historyRangesSupport)
  81. if (!pathname) {
  82. metrics.inc('pathname', 1, {
  83. path: 'RedisManager.setDoc',
  84. status: pathname === '' ? 'zero-length' : 'undefined',
  85. })
  86. }
  87. // Make sure that this MULTI operation only operates on doc
  88. // specific keys, i.e. keys that have the doc id in curly braces.
  89. // The curly braces identify a hash key for Redis and ensures that
  90. // the MULTI's operations are all done on the same node in a
  91. // cluster environment.
  92. const multi = rclient.multi()
  93. multi.mset({
  94. [keys.docLines({ doc_id: docId })]: docLines,
  95. [keys.projectKey({ doc_id: docId })]: projectId,
  96. [keys.docVersion({ doc_id: docId })]: version,
  97. [keys.docHash({ doc_id: docId })]: docHash,
  98. [keys.ranges({ doc_id: docId })]: ranges,
  99. [keys.pathname({ doc_id: docId })]: pathname,
  100. [keys.projectHistoryId({ doc_id: docId })]: projectHistoryId,
  101. })
  102. if (historyRangesSupport) {
  103. multi.del(keys.resolvedCommentIds({ doc_id: docId }))
  104. if (resolvedCommentIds.length > 0) {
  105. multi.sadd(
  106. keys.resolvedCommentIds({ doc_id: docId }),
  107. ...resolvedCommentIds
  108. )
  109. }
  110. }
  111. try {
  112. await multi.exec()
  113. } catch (err) {
  114. throw OError.tag(err, 'failed to write doc to Redis in MULTI', {
  115. previousErrors: err.previousErrors.map(e => ({
  116. name: e.name,
  117. message: e.message,
  118. command: e.command,
  119. })),
  120. })
  121. }
  122. timer.done()
  123. },
  124. async removeDocFromMemory(projectId, docId) {
  125. logger.debug({ projectId, docId }, 'removing doc from redis')
  126. // Make sure that this MULTI operation only operates on doc
  127. // specific keys, i.e. keys that have the doc id in curly braces.
  128. // The curly braces identify a hash key for Redis and ensures that
  129. // the MULTI's operations are all done on the same node in a
  130. // cluster environment.
  131. let multi = rclient.multi()
  132. multi.strlen(keys.docLines({ doc_id: docId }))
  133. multi.del(
  134. keys.docLines({ doc_id: docId }),
  135. keys.projectKey({ doc_id: docId }),
  136. keys.docVersion({ doc_id: docId }),
  137. keys.docHash({ doc_id: docId }),
  138. keys.ranges({ doc_id: docId }),
  139. keys.pathname({ doc_id: docId }),
  140. keys.projectHistoryId({ doc_id: docId }),
  141. keys.unflushedTime({ doc_id: docId }),
  142. keys.lastUpdatedAt({ doc_id: docId }),
  143. keys.lastUpdatedBy({ doc_id: docId }),
  144. keys.resolvedCommentIds({ doc_id: docId })
  145. )
  146. const response = await multi.exec()
  147. const length = response?.[0]
  148. if (length > 0) {
  149. // record bytes freed in redis
  150. metrics.summary('redis.docLines', length, { status: 'del' })
  151. }
  152. // Make sure that this MULTI operation only operates on project
  153. // specific keys, i.e. keys that have the project id in curly braces.
  154. // The curly braces identify a hash key for Redis and ensures that
  155. // the MULTI's operations are all done on the same node in a
  156. // cluster environment.
  157. multi = rclient.multi()
  158. multi.srem(keys.docsInProject({ project_id: projectId }), docId)
  159. multi.del(keys.projectState({ project_id: projectId }))
  160. await multi.exec()
  161. await rclient.srem(keys.historyRangesSupport(), docId)
  162. },
  163. async checkOrSetProjectState(projectId, newState) {
  164. // Make sure that this MULTI operation only operates on project
  165. // specific keys, i.e. keys that have the project id in curly braces.
  166. // The curly braces identify a hash key for Redis and ensures that
  167. // the MULTI's operations are all done on the same node in a
  168. // cluster environment.
  169. const multi = rclient.multi()
  170. multi.getset(keys.projectState({ project_id: projectId }), newState)
  171. multi.expire(keys.projectState({ project_id: projectId }), 30 * minutes)
  172. const response = await multi.exec()
  173. logger.debug(
  174. { projectId, newState, oldState: response[0] },
  175. 'checking project state'
  176. )
  177. return response[0] !== newState
  178. },
  179. async clearProjectState(projectId) {
  180. await rclient.del(keys.projectState({ project_id: projectId }))
  181. },
  182. async getDoc(projectId, docId) {
  183. const timer = new metrics.Timer('redis.get-doc')
  184. const collectKeys = [
  185. keys.docLines({ doc_id: docId }),
  186. keys.docVersion({ doc_id: docId }),
  187. keys.docHash({ doc_id: docId }),
  188. keys.projectKey({ doc_id: docId }),
  189. keys.ranges({ doc_id: docId }),
  190. keys.pathname({ doc_id: docId }),
  191. keys.projectHistoryId({ doc_id: docId }),
  192. keys.unflushedTime({ doc_id: docId }),
  193. keys.lastUpdatedAt({ doc_id: docId }),
  194. keys.lastUpdatedBy({ doc_id: docId }),
  195. ]
  196. let [
  197. docLines,
  198. version,
  199. storedHash,
  200. docProjectId,
  201. ranges,
  202. pathname,
  203. projectHistoryId,
  204. unflushedTime,
  205. lastUpdatedAt,
  206. lastUpdatedBy,
  207. ] = await rclient.mget(...collectKeys)
  208. const result = await rclient.sismember(keys.historyRangesSupport(), docId)
  209. const historyRangesSupport = result === 1
  210. const resolvedCommentIds = await rclient.smembers(
  211. keys.resolvedCommentIds({ doc_id: docId })
  212. )
  213. const timeSpan = timer.done()
  214. // check if request took too long and bail out. only do this for
  215. // get, because it is the first call in each update, so if this
  216. // passes we'll assume others have a reasonable chance to succeed.
  217. if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
  218. throw new OError('redis getDoc exceeded timeout', { projectId, docId })
  219. }
  220. // record bytes loaded from redis
  221. if (docLines != null) {
  222. metrics.summary('redis.docLines', docLines.length, {
  223. status: 'get',
  224. })
  225. }
  226. // check sha1 hash value if present
  227. if (docLines != null && storedHash != null) {
  228. const computedHash = RedisManager._computeHash(docLines)
  229. if (logHashReadErrors && computedHash !== storedHash) {
  230. logger.error(
  231. {
  232. projectId,
  233. docId,
  234. docProjectId,
  235. computedHash,
  236. storedHash,
  237. docLines,
  238. },
  239. 'hash mismatch on retrieved document'
  240. )
  241. }
  242. }
  243. docLines = JSON.parse(docLines)
  244. ranges = RedisManager._deserializeRanges(ranges)
  245. version = parseInt(version || 0, 10)
  246. // check doc is in requested project
  247. if (docProjectId != null && docProjectId !== projectId) {
  248. throw new Errors.NotFoundError('document not found', {
  249. projectId,
  250. docId,
  251. docProjectId,
  252. })
  253. }
  254. if (docLines && version && !pathname) {
  255. metrics.inc('pathname', 1, {
  256. path: 'RedisManager.getDoc',
  257. status: pathname === '' ? 'zero-length' : 'undefined',
  258. })
  259. }
  260. return {
  261. lines: docLines,
  262. version,
  263. ranges,
  264. pathname,
  265. projectHistoryId,
  266. unflushedTime,
  267. lastUpdatedAt,
  268. lastUpdatedBy,
  269. historyRangesSupport,
  270. resolvedCommentIds,
  271. }
  272. },
  273. async getDocVersion(docId) {
  274. const result = await rclient.mget(keys.docVersion({ doc_id: docId }))
  275. let [version] = result || []
  276. version = parseInt(version, 10)
  277. return version
  278. },
  279. async getDocLines(docId) {
  280. const docLines = await rclient.get(keys.docLines({ doc_id: docId }))
  281. return docLines
  282. },
  283. async getPreviousDocOps(docId, start, end) {
  284. const timer = new metrics.Timer('redis.get-prev-docops')
  285. const length = await rclient.llen(keys.docOps({ doc_id: docId }))
  286. let version = await rclient.get(keys.docVersion({ doc_id: docId }))
  287. version = parseInt(version, 10)
  288. const firstVersionInRedis = version - length
  289. if (start < firstVersionInRedis || end > version) {
  290. throw new Errors.OpRangeNotAvailableError(
  291. 'doc ops range is not loaded in redis',
  292. { firstVersionInRedis, version, ttlInS: RedisManager.DOC_OPS_TTL }
  293. )
  294. }
  295. start = start - firstVersionInRedis
  296. if (end > -1) {
  297. end = end - firstVersionInRedis
  298. }
  299. if (isNaN(start) || isNaN(end)) {
  300. throw new OError('inconsistent version or lengths', {
  301. docId,
  302. length,
  303. version,
  304. start,
  305. end,
  306. })
  307. }
  308. const jsonOps = await rclient.lrange(
  309. keys.docOps({ doc_id: docId }),
  310. start,
  311. end
  312. )
  313. const ops = jsonOps.map(jsonOp => JSON.parse(jsonOp))
  314. const timeSpan = timer.done()
  315. if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
  316. throw new Error('redis getPreviousDocOps exceeded timeout')
  317. }
  318. return ops
  319. },
  320. DOC_OPS_TTL: 60 * minutes,
  321. DOC_OPS_MAX_LENGTH: 100,
  322. async updateDocument(
  323. projectId,
  324. docId,
  325. docLines,
  326. newVersion,
  327. appliedOps,
  328. ranges,
  329. updateMeta
  330. ) {
  331. if (appliedOps == null) {
  332. appliedOps = []
  333. }
  334. const shareJSTextOT = Array.isArray(docLines)
  335. const currentVersion = await RedisManager.getDocVersion(docId)
  336. if (currentVersion + appliedOps.length !== newVersion) {
  337. throw new OError(`Version mismatch. '${docId}' is corrupted.`, {
  338. docId,
  339. currentVersion,
  340. newVersion,
  341. opsLength: appliedOps.length,
  342. })
  343. }
  344. const jsonOps = appliedOps.map(op => JSON.stringify(op))
  345. for (const op of jsonOps) {
  346. if (op.indexOf('\u0000') !== -1) {
  347. // this check was added to catch memory corruption in JSON.stringify
  348. throw new OError('null bytes found in jsonOps', {
  349. docId,
  350. jsonOps,
  351. })
  352. }
  353. }
  354. const newDocLines = JSON.stringify(docLines)
  355. if (newDocLines.indexOf('\u0000') !== -1) {
  356. // this check was added to catch memory corruption in JSON.stringify
  357. throw new OError('null bytes found in doc lines', {
  358. docId,
  359. newDocLines,
  360. })
  361. }
  362. // Do an optimised size check on the docLines using the serialised
  363. // length as an upper bound
  364. const sizeBound = newDocLines.length
  365. if (
  366. shareJSTextOT && // editor-core has a size check in TextOperation.apply and TextOperation.applyToLength.
  367. docIsTooLarge(sizeBound, docLines, Settings.max_doc_length)
  368. ) {
  369. const docSize = newDocLines.length
  370. throw new OError('blocking doc update: doc is too large', {
  371. projectId,
  372. docId,
  373. docSize,
  374. })
  375. }
  376. const newHash = RedisManager._computeHash(newDocLines)
  377. const opVersions = appliedOps.map(op => op?.v)
  378. logger.debug(
  379. {
  380. docId,
  381. version: newVersion,
  382. hash: newHash,
  383. opVersions,
  384. },
  385. 'updating doc in redis'
  386. )
  387. // record bytes sent to redis in update
  388. metrics.summary('redis.docLines', newDocLines.length, {
  389. status: 'update',
  390. })
  391. const jsonRanges = RedisManager._serializeRanges(ranges)
  392. if (jsonRanges && jsonRanges.indexOf('\u0000') !== -1) {
  393. // this check was added to catch memory corruption in JSON.stringify
  394. throw new OError('null bytes found in ranges', { docId })
  395. }
  396. // Make sure that this MULTI operation only operates on doc
  397. // specific keys, i.e. keys that have the doc id in curly braces.
  398. // The curly braces identify a hash key for Redis and ensures that
  399. // the MULTI's operations are all done on the same node in a
  400. // cluster environment.
  401. const multi = rclient.multi()
  402. multi.mset({
  403. [keys.docLines({ doc_id: docId })]: newDocLines,
  404. [keys.docVersion({ doc_id: docId })]: newVersion,
  405. [keys.docHash({ doc_id: docId })]: newHash,
  406. [keys.ranges({ doc_id: docId })]: jsonRanges,
  407. [keys.lastUpdatedAt({ doc_id: docId })]: Date.now(),
  408. [keys.lastUpdatedBy({ doc_id: docId })]: updateMeta && updateMeta.user_id,
  409. })
  410. multi.ltrim(
  411. keys.docOps({ doc_id: docId }),
  412. -RedisManager.DOC_OPS_MAX_LENGTH,
  413. -1
  414. ) // index 3
  415. // push the ops last so we can get the lengths at fixed index position 7
  416. if (jsonOps.length > 0) {
  417. multi.rpush(keys.docOps({ doc_id: docId }), ...jsonOps) // index 5
  418. // expire must come after rpush since before it will be a no-op if the list is empty
  419. multi.expire(keys.docOps({ doc_id: docId }), RedisManager.DOC_OPS_TTL) // index 6
  420. }
  421. // Set the unflushed timestamp to the current time if not set ("NX" flag).
  422. multi.set(keys.unflushedTime({ doc_id: docId }), Date.now(), 'NX')
  423. await multi.exec()
  424. },
  425. async renameDoc(projectId, docId, userId, update, projectHistoryId) {
  426. const { lines, version } = await RedisManager.getDoc(projectId, docId)
  427. if (lines != null && version != null) {
  428. if (!update.newPathname) {
  429. logger.warn(
  430. { projectId, docId, update },
  431. 'missing pathname in RedisManager.renameDoc'
  432. )
  433. metrics.inc('pathname', 1, {
  434. path: 'RedisManager.renameDoc',
  435. status: update.newPathname === '' ? 'zero-length' : 'undefined',
  436. })
  437. }
  438. await rclient.set(keys.pathname({ doc_id: docId }), update.newPathname)
  439. }
  440. },
  441. async clearUnflushedTime(docId) {
  442. await rclient.del(keys.unflushedTime({ doc_id: docId }))
  443. },
  444. async updateCommentState(docId, commentId, resolved) {
  445. if (resolved) {
  446. await rclient.sadd(keys.resolvedCommentIds({ doc_id: docId }), commentId)
  447. } else {
  448. await rclient.srem(keys.resolvedCommentIds({ doc_id: docId }), commentId)
  449. }
  450. },
  451. async getDocIdsInProject(projectId) {
  452. return await rclient.smembers(keys.docsInProject({ project_id: projectId }))
  453. },
  454. /**
  455. * Get lastupdatedat timestamps for an array of docIds
  456. */
  457. async getDocTimestamps(docIds) {
  458. const timestamps = []
  459. for (const docId of docIds) {
  460. const timestamp = await rclient.get(keys.lastUpdatedAt({ doc_id: docId }))
  461. timestamps.push(timestamp)
  462. }
  463. return timestamps
  464. },
  465. /**
  466. * Store the project id in a sorted set ordered by time with a random offset
  467. * to smooth out spikes
  468. */
  469. async queueFlushAndDeleteProject(projectId) {
  470. const SMOOTHING_OFFSET =
  471. Settings.smoothingOffset > 0
  472. ? Math.round(Settings.smoothingOffset * Math.random())
  473. : 0
  474. await rclient.zadd(
  475. keys.flushAndDeleteQueue(),
  476. Date.now() + SMOOTHING_OFFSET,
  477. projectId
  478. )
  479. },
  480. /**
  481. * Find the oldest queued flush that is before the cutoff time
  482. */
  483. async getNextProjectToFlushAndDelete(cutoffTime) {
  484. const projectsReady = await rclient.zrangebyscore(
  485. keys.flushAndDeleteQueue(),
  486. 0,
  487. cutoffTime,
  488. 'WITHSCORES',
  489. 'LIMIT',
  490. 0,
  491. 1
  492. )
  493. // return if no projects ready to be processed
  494. if (!projectsReady || projectsReady.length === 0) {
  495. return
  496. }
  497. // pop the oldest entry (get and remove in a multi)
  498. const multi = rclient.multi()
  499. // Poor man's version of ZPOPMIN, which is only available in Redis 5.
  500. multi.zrange(keys.flushAndDeleteQueue(), 0, 0, 'WITHSCORES')
  501. multi.zremrangebyrank(keys.flushAndDeleteQueue(), 0, 0)
  502. multi.zcard(keys.flushAndDeleteQueue()) // the total length of the queue (for metrics)
  503. const reply = await multi.exec()
  504. if (!reply || reply.length === 0) {
  505. return
  506. }
  507. const [key, timestamp] = reply[0]
  508. const queueLength = reply[2]
  509. return { projectId: key, flushTimestamp: timestamp, queueLength }
  510. },
  511. async setHistoryRangesSupportFlag(docId, historyRangesSupport) {
  512. if (historyRangesSupport) {
  513. await rclient.sadd(keys.historyRangesSupport(), docId)
  514. } else {
  515. await rclient.srem(keys.historyRangesSupport(), docId)
  516. }
  517. },
  518. async blockProject(projectId) {
  519. // Make sure that this MULTI operation only operates on project
  520. // specific keys, i.e. keys that have the project id in curly braces.
  521. // The curly braces identify a hash key for Redis and ensures that
  522. // the MULTI's operations are all done on the same node in a
  523. // cluster environment.
  524. const multi = rclient.multi()
  525. multi.setex(
  526. keys.projectBlock({ project_id: projectId }),
  527. PROJECT_BLOCK_TTL_SECS,
  528. '1'
  529. )
  530. multi.scard(keys.docsInProject({ project_id: projectId }))
  531. const reply = await multi.exec()
  532. const docsInProject = reply[1]
  533. if (docsInProject > 0) {
  534. // Too late to lock the project
  535. await rclient.del(keys.projectBlock({ project_id: projectId }))
  536. return false
  537. }
  538. return true
  539. },
  540. async unblockProject(projectId) {
  541. const reply = await rclient.del(
  542. keys.projectBlock({ project_id: projectId })
  543. )
  544. const wasBlocked = reply === 1
  545. return wasBlocked
  546. },
  547. _serializeRanges(ranges) {
  548. let jsonRanges = JSON.stringify(ranges)
  549. if (jsonRanges && jsonRanges.length > MAX_RANGES_SIZE) {
  550. throw new Error('ranges are too large')
  551. }
  552. if (jsonRanges === '{}') {
  553. // Most doc will have empty ranges so don't fill redis with lots of '{}' keys
  554. jsonRanges = null
  555. }
  556. return jsonRanges
  557. },
  558. _deserializeRanges(ranges) {
  559. if (ranges == null || ranges === '') {
  560. return {}
  561. } else {
  562. return JSON.parse(ranges)
  563. }
  564. },
  565. _computeHash(docLines) {
  566. // use sha1 checksum of doclines to detect data corruption.
  567. //
  568. // note: must specify 'utf8' encoding explicitly, as the default is
  569. // binary in node < v5
  570. return crypto.createHash('sha1').update(docLines, 'utf8').digest('hex')
  571. },
  572. async cleanupTestRedis() {
  573. await RedisWrapper.cleanupTestRedis(rclient)
  574. },
  575. }
  576. module.exports = {
  577. rclient,
  578. ...callbackifyAll(RedisManager, {
  579. multiResult: {
  580. getDoc: [
  581. 'lines',
  582. 'version',
  583. 'ranges',
  584. 'pathname',
  585. 'projectHistoryId',
  586. 'unflushedTime',
  587. 'lastUpdatedAt',
  588. 'lastUpdatedBy',
  589. 'historyRangesSupport',
  590. 'resolvedCommentIds',
  591. ],
  592. getNextProjectToFlushAndDelete: [
  593. 'projectId',
  594. 'flushTimestamp',
  595. 'queueLength',
  596. ],
  597. },
  598. }),
  599. promises: RedisManager,
  600. }