| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- const Settings = require('@overleaf/settings')
- const RedisWrapper = require('@overleaf/redis-wrapper')
- const logger = require('@overleaf/logger')
- const OError = require('@overleaf/o-error')
- const { callbackifyAll } = require('@overleaf/promise-utils')
- const metrics = require('./Metrics')
- const Errors = require('./Errors')
- const crypto = require('node:crypto')
- const { docIsTooLarge } = require('./Limits')
- const rclient = RedisWrapper.createClient(Settings.redis.documentupdater)
- // Sometimes Redis calls take an unexpectedly long time. We have to be
- // quick with Redis calls because we're holding a lock that expires
- // after 30 seconds. We can't let any errors in the rest of the stack
- // hold us up, and need to bail out quickly if there is a problem.
- const MAX_REDIS_REQUEST_LENGTH = 5000 // 5 seconds
- const PROJECT_BLOCK_TTL_SECS = 30
- // Make times easy to read
- const minutes = 60 // seconds for Redis expire
- const logHashReadErrors = Settings.documentupdater?.logHashErrors?.read
- const MEGABYTES = 1024 * 1024
- const MAX_RANGES_SIZE = 3 * MEGABYTES
- const keys = Settings.redis.documentupdater.key_schema
- const RedisManager = {
- async putDocInMemory(
- projectId,
- docId,
- docLines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- historyRangesSupport
- ) {
- const timer = new metrics.Timer('redis.put-doc')
- const shareJSTextOT = Array.isArray(docLines)
- const docLinesArray = docLines
- docLines = JSON.stringify(docLines)
- if (docLines.indexOf('\u0000') !== -1) {
- // this check was added to catch memory corruption in JSON.stringify.
- // It sometimes returned null bytes at the end of the string.
- throw new OError('null bytes found in doc lines', { docId })
- }
- // Do an optimised size check on the docLines using the serialised
- // length as an upper bound
- const sizeBound = docLines.length
- if (
- shareJSTextOT && // editor-core has a size check in TextOperation.apply and TextOperation.applyToLength.
- docIsTooLarge(sizeBound, docLinesArray, Settings.max_doc_length)
- ) {
- const docSize = docLines.length
- throw new OError('blocking doc insert into redis: doc is too large', {
- projectId,
- docId,
- docSize,
- })
- }
- const docHash = RedisManager._computeHash(docLines)
- // record bytes sent to redis
- metrics.summary('redis.docLines', docLines.length, { status: 'set' })
- logger.debug(
- { projectId, docId, version, docHash, pathname, projectHistoryId },
- 'putting doc in redis'
- )
- ranges = RedisManager._serializeRanges(ranges)
- // update docsInProject set before writing doc contents
- const projectBlockMulti = rclient.multi()
- projectBlockMulti.exists(keys.projectBlock({ project_id: projectId }))
- projectBlockMulti.sadd(keys.docsInProject({ project_id: projectId }), docId)
- const reply = await projectBlockMulti.exec()
- const projectBlocked = reply[0] === 1
- if (projectBlocked) {
- // We don't clean up the spurious docId added in the docsInProject
- // set. There is a risk that the docId was successfully added by a
- // concurrent process. This set is used when unloading projects. An
- // extra docId will not prevent the project from being uploaded, but
- // a missing docId means that the doc might stay in Redis forever.
- throw new OError('Project blocked from loading docs', { projectId })
- }
- await RedisManager.setHistoryRangesSupportFlag(docId, historyRangesSupport)
- if (!pathname) {
- metrics.inc('pathname', 1, {
- path: 'RedisManager.setDoc',
- status: pathname === '' ? 'zero-length' : 'undefined',
- })
- }
- // Make sure that this MULTI operation only operates on doc
- // specific keys, i.e. keys that have the doc id in curly braces.
- // The curly braces identify a hash key for Redis and ensures that
- // the MULTI's operations are all done on the same node in a
- // cluster environment.
- const multi = rclient.multi()
- multi.mset({
- [keys.docLines({ doc_id: docId })]: docLines,
- [keys.projectKey({ doc_id: docId })]: projectId,
- [keys.docVersion({ doc_id: docId })]: version,
- [keys.docHash({ doc_id: docId })]: docHash,
- [keys.ranges({ doc_id: docId })]: ranges,
- [keys.pathname({ doc_id: docId })]: pathname,
- [keys.projectHistoryId({ doc_id: docId })]: projectHistoryId,
- })
- if (historyRangesSupport) {
- multi.del(keys.resolvedCommentIds({ doc_id: docId }))
- if (resolvedCommentIds.length > 0) {
- multi.sadd(
- keys.resolvedCommentIds({ doc_id: docId }),
- ...resolvedCommentIds
- )
- }
- }
- try {
- await multi.exec()
- } catch (err) {
- throw OError.tag(err, 'failed to write doc to Redis in MULTI', {
- previousErrors: err.previousErrors.map(e => ({
- name: e.name,
- message: e.message,
- command: e.command,
- })),
- })
- }
- timer.done()
- },
- async removeDocFromMemory(projectId, docId) {
- logger.debug({ projectId, docId }, 'removing doc from redis')
- // Make sure that this MULTI operation only operates on doc
- // specific keys, i.e. keys that have the doc id in curly braces.
- // The curly braces identify a hash key for Redis and ensures that
- // the MULTI's operations are all done on the same node in a
- // cluster environment.
- let multi = rclient.multi()
- multi.strlen(keys.docLines({ doc_id: docId }))
- multi.del(
- keys.docLines({ doc_id: docId }),
- keys.projectKey({ doc_id: docId }),
- keys.docVersion({ doc_id: docId }),
- keys.docHash({ doc_id: docId }),
- keys.ranges({ doc_id: docId }),
- keys.pathname({ doc_id: docId }),
- keys.projectHistoryId({ doc_id: docId }),
- keys.unflushedTime({ doc_id: docId }),
- keys.lastUpdatedAt({ doc_id: docId }),
- keys.lastUpdatedBy({ doc_id: docId }),
- keys.resolvedCommentIds({ doc_id: docId })
- )
- const response = await multi.exec()
- const length = response?.[0]
- if (length > 0) {
- // record bytes freed in redis
- metrics.summary('redis.docLines', length, { status: 'del' })
- }
- // Make sure that this MULTI operation only operates on project
- // specific keys, i.e. keys that have the project id in curly braces.
- // The curly braces identify a hash key for Redis and ensures that
- // the MULTI's operations are all done on the same node in a
- // cluster environment.
- multi = rclient.multi()
- multi.srem(keys.docsInProject({ project_id: projectId }), docId)
- multi.del(keys.projectState({ project_id: projectId }))
- await multi.exec()
- await rclient.srem(keys.historyRangesSupport(), docId)
- },
- async checkOrSetProjectState(projectId, newState) {
- // Make sure that this MULTI operation only operates on project
- // specific keys, i.e. keys that have the project id in curly braces.
- // The curly braces identify a hash key for Redis and ensures that
- // the MULTI's operations are all done on the same node in a
- // cluster environment.
- const multi = rclient.multi()
- multi.getset(keys.projectState({ project_id: projectId }), newState)
- multi.expire(keys.projectState({ project_id: projectId }), 30 * minutes)
- const response = await multi.exec()
- logger.debug(
- { projectId, newState, oldState: response[0] },
- 'checking project state'
- )
- return response[0] !== newState
- },
- async clearProjectState(projectId) {
- await rclient.del(keys.projectState({ project_id: projectId }))
- },
- async getDoc(projectId, docId) {
- const timer = new metrics.Timer('redis.get-doc')
- const collectKeys = [
- keys.docLines({ doc_id: docId }),
- keys.docVersion({ doc_id: docId }),
- keys.docHash({ doc_id: docId }),
- keys.projectKey({ doc_id: docId }),
- keys.ranges({ doc_id: docId }),
- keys.pathname({ doc_id: docId }),
- keys.projectHistoryId({ doc_id: docId }),
- keys.unflushedTime({ doc_id: docId }),
- keys.lastUpdatedAt({ doc_id: docId }),
- keys.lastUpdatedBy({ doc_id: docId }),
- ]
- let [
- docLines,
- version,
- storedHash,
- docProjectId,
- ranges,
- pathname,
- projectHistoryId,
- unflushedTime,
- lastUpdatedAt,
- lastUpdatedBy,
- ] = await rclient.mget(...collectKeys)
- const result = await rclient.sismember(keys.historyRangesSupport(), docId)
- const historyRangesSupport = result === 1
- const resolvedCommentIds = await rclient.smembers(
- keys.resolvedCommentIds({ doc_id: docId })
- )
- const timeSpan = timer.done()
- // check if request took too long and bail out. only do this for
- // get, because it is the first call in each update, so if this
- // passes we'll assume others have a reasonable chance to succeed.
- if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
- throw new OError('redis getDoc exceeded timeout', { projectId, docId })
- }
- // record bytes loaded from redis
- if (docLines != null) {
- metrics.summary('redis.docLines', docLines.length, {
- status: 'get',
- })
- }
- // check sha1 hash value if present
- if (docLines != null && storedHash != null) {
- const computedHash = RedisManager._computeHash(docLines)
- if (logHashReadErrors && computedHash !== storedHash) {
- logger.error(
- {
- projectId,
- docId,
- docProjectId,
- computedHash,
- storedHash,
- docLines,
- },
- 'hash mismatch on retrieved document'
- )
- }
- }
- docLines = JSON.parse(docLines)
- ranges = RedisManager._deserializeRanges(ranges)
- version = parseInt(version || 0, 10)
- // check doc is in requested project
- if (docProjectId != null && docProjectId !== projectId) {
- throw new Errors.NotFoundError('document not found', {
- projectId,
- docId,
- docProjectId,
- })
- }
- if (docLines && version && !pathname) {
- metrics.inc('pathname', 1, {
- path: 'RedisManager.getDoc',
- status: pathname === '' ? 'zero-length' : 'undefined',
- })
- }
- return {
- lines: docLines,
- version,
- ranges,
- pathname,
- projectHistoryId,
- unflushedTime,
- lastUpdatedAt,
- lastUpdatedBy,
- historyRangesSupport,
- resolvedCommentIds,
- }
- },
- async getDocRanges(docId) {
- const json = await rclient.get(keys.ranges({ doc_id: docId }))
- const ranges = RedisManager._deserializeRanges(json)
- return ranges
- },
- async getDocVersion(docId) {
- const result = await rclient.mget(keys.docVersion({ doc_id: docId }))
- let [version] = result || []
- version = parseInt(version, 10)
- return version
- },
- async getDocLines(docId) {
- const docLines = await rclient.get(keys.docLines({ doc_id: docId }))
- return docLines
- },
- async getPreviousDocOps(docId, start, end) {
- const timer = new metrics.Timer('redis.get-prev-docops')
- const length = await rclient.llen(keys.docOps({ doc_id: docId }))
- let version = await rclient.get(keys.docVersion({ doc_id: docId }))
- version = parseInt(version, 10)
- const firstVersionInRedis = version - length
- if (start < firstVersionInRedis || end > version) {
- throw new Errors.OpRangeNotAvailableError(
- 'doc ops range is not loaded in redis',
- { firstVersionInRedis, version, ttlInS: RedisManager.DOC_OPS_TTL }
- )
- }
- start = start - firstVersionInRedis
- if (end > -1) {
- end = end - firstVersionInRedis
- }
- if (isNaN(start) || isNaN(end)) {
- throw new OError('inconsistent version or lengths', {
- docId,
- length,
- version,
- start,
- end,
- })
- }
- const jsonOps = await rclient.lrange(
- keys.docOps({ doc_id: docId }),
- start,
- end
- )
- const ops = jsonOps.map(jsonOp => JSON.parse(jsonOp))
- const timeSpan = timer.done()
- if (timeSpan > MAX_REDIS_REQUEST_LENGTH) {
- throw new Error('redis getPreviousDocOps exceeded timeout')
- }
- return ops
- },
- DOC_OPS_TTL: 60 * minutes,
- DOC_OPS_MAX_LENGTH: 100,
- async updateDocument(
- projectId,
- docId,
- docLines,
- newVersion,
- appliedOps,
- ranges,
- updateMeta
- ) {
- if (appliedOps == null) {
- appliedOps = []
- }
- const shareJSTextOT = Array.isArray(docLines)
- const currentVersion = await RedisManager.getDocVersion(docId)
- if (currentVersion + appliedOps.length !== newVersion) {
- throw new OError('Version mismatch. doc is corrupted', {
- docId,
- currentVersion,
- newVersion,
- opsLength: appliedOps.length,
- })
- }
- const jsonOps = appliedOps.map(op => JSON.stringify(op))
- for (const op of jsonOps) {
- if (op.indexOf('\u0000') !== -1) {
- // this check was added to catch memory corruption in JSON.stringify
- throw new OError('null bytes found in jsonOps', {
- docId,
- jsonOps,
- })
- }
- }
- const newDocLines = JSON.stringify(docLines)
- if (newDocLines.indexOf('\u0000') !== -1) {
- // this check was added to catch memory corruption in JSON.stringify
- throw new OError('null bytes found in doc lines', {
- docId,
- newDocLines,
- })
- }
- // Do an optimised size check on the docLines using the serialised
- // length as an upper bound
- const sizeBound = newDocLines.length
- if (
- shareJSTextOT && // editor-core has a size check in TextOperation.apply and TextOperation.applyToLength.
- docIsTooLarge(sizeBound, docLines, Settings.max_doc_length)
- ) {
- const docSize = newDocLines.length
- throw new OError('blocking doc update: doc is too large', {
- projectId,
- docId,
- docSize,
- })
- }
- const newHash = RedisManager._computeHash(newDocLines)
- const opVersions = appliedOps.map(op => op?.v)
- logger.debug(
- {
- docId,
- version: newVersion,
- hash: newHash,
- opVersions,
- },
- 'updating doc in redis'
- )
- // record bytes sent to redis in update
- metrics.summary('redis.docLines', newDocLines.length, {
- status: 'update',
- })
- const jsonRanges = RedisManager._serializeRanges(ranges)
- if (jsonRanges && jsonRanges.indexOf('\u0000') !== -1) {
- // this check was added to catch memory corruption in JSON.stringify
- throw new OError('null bytes found in ranges', { docId })
- }
- // Make sure that this MULTI operation only operates on doc
- // specific keys, i.e. keys that have the doc id in curly braces.
- // The curly braces identify a hash key for Redis and ensures that
- // the MULTI's operations are all done on the same node in a
- // cluster environment.
- const multi = rclient.multi()
- multi.mset({
- [keys.docLines({ doc_id: docId })]: newDocLines,
- [keys.docVersion({ doc_id: docId })]: newVersion,
- [keys.docHash({ doc_id: docId })]: newHash,
- [keys.ranges({ doc_id: docId })]: jsonRanges,
- [keys.lastUpdatedAt({ doc_id: docId })]: Date.now(),
- [keys.lastUpdatedBy({ doc_id: docId })]: updateMeta && updateMeta.user_id,
- })
- multi.ltrim(
- keys.docOps({ doc_id: docId }),
- -RedisManager.DOC_OPS_MAX_LENGTH,
- -1
- ) // index 3
- // push the ops last so we can get the lengths at fixed index position 7
- if (jsonOps.length > 0) {
- multi.rpush(keys.docOps({ doc_id: docId }), ...jsonOps) // index 5
- // expire must come after rpush since before it will be a no-op if the list is empty
- multi.expire(keys.docOps({ doc_id: docId }), RedisManager.DOC_OPS_TTL) // index 6
- }
- // Set the unflushed timestamp to the current time if not set ("NX" flag).
- multi.set(keys.unflushedTime({ doc_id: docId }), Date.now(), 'NX')
- await multi.exec()
- },
- async renameDoc(projectId, docId, userId, update, projectHistoryId) {
- const { lines, version } = await RedisManager.getDoc(projectId, docId)
- if (lines != null && version != null) {
- if (!update.newPathname) {
- logger.warn(
- { projectId, docId, update },
- 'missing pathname in RedisManager.renameDoc'
- )
- metrics.inc('pathname', 1, {
- path: 'RedisManager.renameDoc',
- status: update.newPathname === '' ? 'zero-length' : 'undefined',
- })
- }
- await rclient.set(keys.pathname({ doc_id: docId }), update.newPathname)
- }
- },
- async clearUnflushedTime(docId) {
- await rclient.del(keys.unflushedTime({ doc_id: docId }))
- },
- async updateCommentState(docId, commentId, resolved) {
- if (resolved) {
- await rclient.sadd(keys.resolvedCommentIds({ doc_id: docId }), commentId)
- } else {
- await rclient.srem(keys.resolvedCommentIds({ doc_id: docId }), commentId)
- }
- },
- async getDocIdsInProject(projectId) {
- return await rclient.smembers(keys.docsInProject({ project_id: projectId }))
- },
- /**
- * Get lastupdatedat timestamps for an array of docIds
- */
- async getDocTimestamps(docIds) {
- const timestamps = []
- for (const docId of docIds) {
- const timestamp = await rclient.get(keys.lastUpdatedAt({ doc_id: docId }))
- timestamps.push(timestamp)
- }
- return timestamps
- },
- /**
- * Store the project id in a sorted set ordered by time with a random offset
- * to smooth out spikes
- */
- async queueFlushAndDeleteProject(projectId) {
- const SMOOTHING_OFFSET =
- Settings.smoothingOffset > 0
- ? Math.round(Settings.smoothingOffset * Math.random())
- : 0
- await rclient.zadd(
- keys.flushAndDeleteQueue(),
- Date.now() + SMOOTHING_OFFSET,
- projectId
- )
- },
- /**
- * Find the oldest queued flush that is before the cutoff time
- */
- async getNextProjectToFlushAndDelete(cutoffTime) {
- const projectsReady = await rclient.zrangebyscore(
- keys.flushAndDeleteQueue(),
- 0,
- cutoffTime,
- 'WITHSCORES',
- 'LIMIT',
- 0,
- 1
- )
- // return if no projects ready to be processed
- if (!projectsReady || projectsReady.length === 0) {
- return {}
- }
- // pop the oldest entry (get and remove in a multi)
- const multi = rclient.multi()
- // Poor man's version of ZPOPMIN, which is only available in Redis 5.
- multi.zrange(keys.flushAndDeleteQueue(), 0, 0, 'WITHSCORES')
- multi.zremrangebyrank(keys.flushAndDeleteQueue(), 0, 0)
- multi.zcard(keys.flushAndDeleteQueue()) // the total length of the queue (for metrics)
- const reply = await multi.exec()
- if (!reply || reply.length === 0) {
- return {}
- }
- const [key, timestamp] = reply[0]
- const queueLength = reply[2]
- return { projectId: key, flushTimestamp: timestamp, queueLength }
- },
- async setHistoryRangesSupportFlag(docId, historyRangesSupport) {
- if (historyRangesSupport) {
- await rclient.sadd(keys.historyRangesSupport(), docId)
- } else {
- await rclient.srem(keys.historyRangesSupport(), docId)
- }
- },
- /*
- * Record the timestamp of the update operation (if it doesn't already exist).
- * This is used for email notifications
- */
- async recordProjectNotificationTimestamp(projectId, timestamp) {
- await rclient.set(
- keys.projectNotificationTimestamp({ project_id: projectId }),
- timestamp,
- 'NX'
- )
- },
- async blockProject(projectId) {
- // Make sure that this MULTI operation only operates on project
- // specific keys, i.e. keys that have the project id in curly braces.
- // The curly braces identify a hash key for Redis and ensures that
- // the MULTI's operations are all done on the same node in a
- // cluster environment.
- const multi = rclient.multi()
- multi.setex(
- keys.projectBlock({ project_id: projectId }),
- PROJECT_BLOCK_TTL_SECS,
- '1'
- )
- multi.scard(keys.docsInProject({ project_id: projectId }))
- const reply = await multi.exec()
- const docsInProject = reply[1]
- if (docsInProject > 0) {
- // Too late to lock the project
- await rclient.del(keys.projectBlock({ project_id: projectId }))
- return false
- }
- return true
- },
- async unblockProject(projectId) {
- const reply = await rclient.del(
- keys.projectBlock({ project_id: projectId })
- )
- const wasBlocked = reply === 1
- return wasBlocked
- },
- _serializeRanges(ranges) {
- let jsonRanges = JSON.stringify(ranges)
- if (jsonRanges && jsonRanges.length > MAX_RANGES_SIZE) {
- throw new Error('ranges are too large')
- }
- if (jsonRanges === '{}') {
- // Most doc will have empty ranges so don't fill redis with lots of '{}' keys
- jsonRanges = null
- }
- return jsonRanges
- },
- _deserializeRanges(ranges) {
- if (ranges == null || ranges === '') {
- return {}
- } else {
- return JSON.parse(ranges)
- }
- },
- _computeHash(docLines) {
- // use sha1 checksum of doclines to detect data corruption.
- //
- // note: must specify 'utf8' encoding explicitly, as the default is
- // binary in node < v5
- return crypto.createHash('sha1').update(docLines, 'utf8').digest('hex')
- },
- async cleanupTestRedis() {
- await RedisWrapper.cleanupTestRedis(rclient)
- },
- }
- module.exports = {
- rclient,
- ...callbackifyAll(RedisManager, {
- multiResult: {
- getDoc: [
- 'lines',
- 'version',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'unflushedTime',
- 'lastUpdatedAt',
- 'lastUpdatedBy',
- 'historyRangesSupport',
- 'resolvedCommentIds',
- ],
- getNextProjectToFlushAndDelete: [
- 'projectId',
- 'flushTimestamp',
- 'queueLength',
- ],
- },
- }),
- promises: RedisManager,
- }
|