From f5a89cc38f2e52918a0f42820f49c521a8354441 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Fri, 11 Apr 2025 15:33:01 +0100 Subject: [PATCH] Add a script for finding projects missing a history id in Redis GitOrigin-RevId: 3606e82c8e0fc8e1118cd6ce1981610ec20b73af --- .../flush_projects_with_no_history_id.js | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 services/document-updater/scripts/flush_projects_with_no_history_id.js diff --git a/services/document-updater/scripts/flush_projects_with_no_history_id.js b/services/document-updater/scripts/flush_projects_with_no_history_id.js new file mode 100644 index 0000000000..cf3440725d --- /dev/null +++ b/services/document-updater/scripts/flush_projects_with_no_history_id.js @@ -0,0 +1,199 @@ +// @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', + }, +}) + +const batchSize = parseInt(args.batchSize, 10) + +/** + * @typedef {import('ioredis').Redis} Redis + */ + +/** + * + * @param {string} key + * @return {string|void} + */ +function extractDocId(key) { + const matches = key.match(/ProjectHistoryId:\{(.*?)\}/) + 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 historyIds = await node.mget( + docIds.map(docId => docUpdaterKeys.projectHistoryId({ doc_id: docId })) + ) + + const results = [] + + for (const index in docIds) { + const historyId = historyIds[index] + const docId = docIds[index] + if (!historyId) { + try { + const { projectId, historyId } = await getHistoryId(docId) + results.push({ projectId, historyId, docId }) + } 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) { + logger.debug({ ...update }, 'Flushing project') + if (commit) { + await rclient.set( + docUpdaterKeys.projectHistoryId({ doc_id: '*' }), + update.historyId + ) + await ProjectManager.promises.flushAndDeleteProjectWithLocks( + update.projectId, + {} + ) + } + } +} + +/** + * + * @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.projectHistoryId({ 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)) + })