// @ts-check const Settings = require('@overleaf/settings') const logger = require('@overleaf/logger') const RedisManager = require('../app/js/RedisManager') const minimist = require('minimist') const { db, ObjectId } = require('../app/js/mongodb') const ProjectManager = require('../app/js/ProjectManager') const OError = require('@overleaf/o-error') const docUpdaterKeys = Settings.redis.documentupdater.key_schema const rclient = RedisManager.rclient const { verbose, commit, ...args } = minimist(process.argv.slice(2), { boolean: ['verbose', 'commit'], string: ['batchSize'], default: { batchSize: '1000', }, }) logger.logger.level(verbose ? 'debug' : 'warn') const batchSize = parseInt(args.batchSize, 10) /** * @typedef {import('ioredis').Redis} Redis */ /** * * @param {string} key * @return {string|void} */ function extractDocId(key) { const matches = key.match(/DocVersion:\{(.*?)\}/) if (matches) { return matches[1] } } /** * * @param {string} docId * @return {Promise<{projectId: string, historyId: string}>} */ async function getHistoryId(docId) { const doc = await db.docs.findOne( { _id: new ObjectId(docId) }, { projection: { project_id: 1 }, readPreference: 'secondaryPreferred' } ) if (!doc) { throw new OError('Doc not present in mongo', { docId }) } const project = await db.projects.findOne( { _id: doc.project_id }, { projection: { 'overleaf.history': 1 }, readPreference: 'secondaryPreferred', } ) if (!project?.overleaf?.history?.id) { throw new OError('Project not present in mongo (or has no history id)', { docId, project, doc, }) } return { historyId: project?.overleaf?.history?.id, projectId: doc.project_id.toString(), } } /** * @typedef {Object} UpdateableDoc * @property {string} docId * @property {string} projectId * @property {string} historyId */ /** * * @param {Redis} node * @param {Array} docIds * @return {Promise>} */ async function findDocsWithMissingHistoryIds(node, docIds) { const fromRedis = await node.mget( docIds .map(docId => [ docUpdaterKeys.docVersion({ doc_id: docId }), docUpdaterKeys.projectHistoryId({ doc_id: docId }), docUpdaterKeys.lastUpdatedAt({ doc_id: docId }), docUpdaterKeys.unflushedTime({ doc_id: docId }), ]) .flat() ) const results = [] const nKeysPerDoc = 4 for (const [index, docId] of docIds.entries()) { const [docVersion, historyId, lastUpdatedAt, unflushedTime] = fromRedis.slice(index * nKeysPerDoc, index * nKeysPerDoc + nKeysPerDoc) if (!docVersion) { // Already removed from redis. continue } if (!historyId) { try { const { projectId, historyId } = await getHistoryId(docId) results.push({ projectId, historyId, docId, docVersion, lastUpdatedAt, unflushedTime, }) } catch (error) { logger.warn( { error }, 'Error gathering data for doc with missing history id' ) } } } return results } /** * * @param {Array} updates * @return {Promise} */ async function fixAndFlushProjects(updates) { for (const update of updates) { if (commit) { try { await rclient.set( docUpdaterKeys.projectHistoryId({ doc_id: update.docId }), update.historyId ) logger.debug({ ...update }, 'Set history id in redis') await ProjectManager.promises.flushAndDeleteProjectWithLocks( update.projectId, {} ) logger.debug({ ...update }, 'Flushed project') } catch (err) { logger.error({ err, ...update }, 'Error fixing and flushing project') } } else { logger.debug( { ...update }, 'Would have set history id in redis and flushed' ) } } } /** * * @param {Array} nodes * @param {number} batchSize * @return {Promise} */ async function scanNodes(nodes, batchSize = 1000) { let scanned = 0 for (const node of nodes) { const stream = node.scanStream({ match: docUpdaterKeys.docVersion({ doc_id: '*' }), count: batchSize, }) for await (const docKeys of stream) { if (docKeys.length === 0) { continue } stream.pause() scanned += docKeys.length const docIds = docKeys .map((/** @type {string} */ docKey) => extractDocId(docKey)) .filter(Boolean) try { const updates = await findDocsWithMissingHistoryIds(node, docIds) if (updates.length > 0) { logger.info({ updates }, 'Found doc(s) with missing history ids') await fixAndFlushProjects(updates) } } catch (error) { logger.error({ docKeys }, 'Error processing batch') } finally { stream.resume() } } logger.info({ scanned, server: node.serverInfo.role }, 'Scanned node') } } async function main({ batchSize }) { const nodes = (typeof rclient.nodes === 'function' ? rclient.nodes('master') : undefined) || [rclient] await scanNodes(nodes, batchSize) } let code = 0 main({ batchSize }) .then(() => { logger.info({}, 'done') }) .catch(error => { logger.error({ error }, 'error') code = 1 }) .finally(() => { rclient.quit().then(() => process.exit(code)) })