recover_doc_versions.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. const fsPromises = require('node:fs/promises')
  2. const { ObjectId } = require('mongodb')
  3. const BPromise = require('bluebird')
  4. const logger = require('@overleaf/logger')
  5. const Settings = require('@overleaf/settings')
  6. const rclient = require('@overleaf/redis-wrapper').createClient(
  7. Settings.redis.documentupdater
  8. )
  9. const mongodb = require('../lib/mongodb')
  10. const { chunkStore } = require('..')
  11. const Events = require('node:events')
  12. // Silence warning.
  13. Events.setMaxListeners(20)
  14. const BATCH_SIZE = 1000
  15. const OPTIONS = {
  16. concurrency: parseInt(process.env.DOC_VERSION_RECOVERY_CONCURRENCY, 10) || 20,
  17. force: process.env.DOC_VERSION_RECOVERY_FORCE === 'true',
  18. 'skip-history-failures':
  19. process.env.DOC_VERSION_RECOVERY_SKIP_HISTORY_FAILURES === 'true',
  20. 'resyncs-needed-file': process.env.DOC_VERSION_RECOVERY_RESYNCS_NEEDED_FILE,
  21. }
  22. const db = {
  23. deletedProjects: mongodb.db.collection('deletedProjects'),
  24. docs: mongodb.db.collection('docs'),
  25. migrations: mongodb.db.collection('migrations'),
  26. projects: mongodb.db.collection('projects'),
  27. }
  28. const BAD_MIGRATION_NAME =
  29. '20231219081700_move_doc_versions_from_docops_to_docs'
  30. const RECOVERY_FILES_502 = [
  31. '/var/lib/overleaf/data/history/doc-version-recovery-resyncs.log',
  32. '/var/lib/overleaf/data/history/doc-version-recovery-resyncs.log.done',
  33. ]
  34. let loggingChain = Promise.resolve()
  35. const projectIdsThatNeedResyncing = []
  36. const unflushedDocIds = new Set()
  37. async function flushLogQueue() {
  38. const logPath = OPTIONS['resyncs-needed-file']
  39. loggingChain = loggingChain.then(async () => {
  40. const batch = projectIdsThatNeedResyncing.splice(0)
  41. if (batch.length === 0) return
  42. try {
  43. await fsPromises.appendFile(logPath, batch.join('\n') + '\n')
  44. } catch (err) {
  45. projectIdsThatNeedResyncing.push(...batch)
  46. logger.err({ err, logPath, batch }, 'Failed to write to log file')
  47. }
  48. })
  49. await loggingChain
  50. }
  51. async function recordProjectNeedsResync(projectId) {
  52. if (OPTIONS['resyncs-needed-file']) {
  53. projectIdsThatNeedResyncing.push(projectId)
  54. await flushLogQueue()
  55. } else {
  56. console.log(`Project ${projectId} needs a hard resync.`)
  57. }
  58. }
  59. async function main() {
  60. const recovery502Ran = await did502RecoveryRun()
  61. await getUnflushedDocIds()
  62. const badMigration = await db.migrations.findOne({ name: BAD_MIGRATION_NAME })
  63. if (unflushedDocIds.size > 0 && !recovery502Ran && badMigration != null) {
  64. // Tell customers that they need to flush
  65. console.log(`
  66. --------------------------------------------------------------------
  67. Detected unflushed changes while recovering doc versions.
  68. Please go back to version 5.0.1 and follow the recovery procedure
  69. for flushing document updates:
  70. https://github.com/overleaf/overleaf/wiki/Doc-version-recovery
  71. --------------------------------------------------------------------`)
  72. process.exit(1)
  73. }
  74. if (OPTIONS.force || recovery502Ran || badMigration != null) {
  75. console.warn('Need to recover doc versions. This will take a while.')
  76. await runRecovery()
  77. await db.migrations.deleteOne({ name: BAD_MIGRATION_NAME })
  78. await delete502RecoveryFiles()
  79. }
  80. console.log('Done.')
  81. }
  82. async function did502RecoveryRun() {
  83. for (const file of RECOVERY_FILES_502) {
  84. try {
  85. await fsPromises.stat(file)
  86. return true
  87. } catch (err) {
  88. // file doesn't exist. continue
  89. }
  90. }
  91. return false
  92. }
  93. async function delete502RecoveryFiles() {
  94. for (const file of RECOVERY_FILES_502) {
  95. try {
  96. await fsPromises.rename(file, file.replace('.log', '-5.0.2.log'))
  97. } catch (err) {
  98. // file doesn't exist. continue
  99. }
  100. }
  101. }
  102. async function runRecovery() {
  103. let batch = []
  104. const summary = {
  105. ignored: 0,
  106. skipped: 0,
  107. deletedUpdatedMongo: 0,
  108. deletedUpdatedRedis: 0,
  109. deletedUpdatedBoth: 0,
  110. deletedIgnored: 0,
  111. updatedMongo: 0,
  112. updatedRedis: 0,
  113. updatedBoth: 0,
  114. }
  115. const processBatchAndLogProgress = async () => {
  116. try {
  117. await BPromise.map(batch, project => processProject(project, summary), {
  118. concurrency: OPTIONS.concurrency,
  119. })
  120. } finally {
  121. console.log(`${summary.updatedRedis} projects updated in Redis`)
  122. console.log(`${summary.updatedMongo} projects updated in Mongo`)
  123. console.log(
  124. `${summary.updatedBoth} projects updated in both Mongo and Redis`
  125. )
  126. console.log(`${summary.ignored} projects had good versions`)
  127. console.log(
  128. `${summary.deletedUpdatedMongo} deleted projects updated in Mongo`
  129. )
  130. console.log(
  131. `${summary.deletedUpdatedRedis} deleted projects updated in Redis`
  132. )
  133. console.log(
  134. `${summary.deletedUpdatedBoth} deleted projects updated in both Mongo and Redis`
  135. )
  136. console.log(
  137. `${summary.deletedIgnored} deleted projects had good versions`
  138. )
  139. console.log(`${summary.skipped} projects skipped`)
  140. }
  141. batch = []
  142. }
  143. await printDBStats()
  144. await initResyncsNeededFile()
  145. for await (const project of getProjects()) {
  146. batch.push(project)
  147. if (batch.length >= BATCH_SIZE) {
  148. await processBatchAndLogProgress()
  149. }
  150. }
  151. for await (const deletedProject of getDeletedProjects()) {
  152. const project = deletedProject.project
  153. project.isDeleted = true
  154. batch.push(project)
  155. if (batch.length >= BATCH_SIZE) {
  156. await processBatchAndLogProgress()
  157. }
  158. }
  159. if (batch.length > 0) {
  160. await processBatchAndLogProgress()
  161. }
  162. await backfillMissingVersions()
  163. }
  164. async function getUnflushedDocIds() {
  165. const batchSize = 1000
  166. let cursor = '0'
  167. do {
  168. const [newCursor, keys] = await rclient.scan(
  169. cursor,
  170. 'MATCH',
  171. Settings.redis.documentupdater.key_schema.docVersion({ doc_id: '*' }),
  172. 'COUNT',
  173. batchSize
  174. )
  175. for (const key of keys) {
  176. unflushedDocIds.add(key.slice('DocVersion:'.length))
  177. }
  178. cursor = newCursor
  179. } while (cursor !== '0')
  180. }
  181. async function printDBStats() {
  182. const projects = await db.projects.estimatedDocumentCount()
  183. const deletedProjects = await db.deletedProjects.countDocuments()
  184. const docs = await db.docs.estimatedDocumentCount()
  185. console.log(
  186. `Need to check ${projects} projects and up-to ${deletedProjects} deleted projects with a total of ${docs} docs.`
  187. )
  188. }
  189. async function initResyncsNeededFile() {
  190. const logPath = OPTIONS['resyncs-needed-file']
  191. if (logPath) {
  192. await fsPromises.writeFile(logPath, '')
  193. await fsPromises.rm(`${logPath}.done`, { force: true })
  194. }
  195. }
  196. function getProjects() {
  197. return db.projects.find({}, { projection: { _id: 1, overleaf: 1 } })
  198. }
  199. function getDeletedProjects() {
  200. return db.deletedProjects.find(
  201. { 'project.overleaf.history.id': { $exists: true } },
  202. { projection: { 'project._id': 1, 'project.overleaf': 1 } }
  203. )
  204. }
  205. async function processProject(project, summary) {
  206. const projectId = project._id.toString()
  207. let updatedMongo = false
  208. let updatedRedis = false
  209. try {
  210. const historyDocVersions = await getHistoryDocVersions(project)
  211. for (const { docId, version } of historyDocVersions) {
  212. const update = await fixDocVersion(docId, version)
  213. if (update != null) {
  214. if (update.in === 'mongo') {
  215. updatedMongo = true
  216. } else if (update.in === 'redis') {
  217. updatedRedis = true
  218. }
  219. }
  220. }
  221. if (project.isDeleted) {
  222. if (updatedMongo && updatedRedis) {
  223. summary.deletedUpdatedBoth += 1
  224. } else if (updatedMongo) {
  225. summary.deletedUpdatedMongo += 1
  226. } else if (updatedRedis) {
  227. summary.deletedUpdatedRedis += 1
  228. } else {
  229. summary.deletedIgnored += 1
  230. }
  231. } else {
  232. await recordProjectNeedsResync(projectId)
  233. if (updatedMongo && updatedRedis) {
  234. summary.updatedBoth += 1
  235. } else if (updatedMongo) {
  236. summary.updatedMongo += 1
  237. } else if (updatedRedis) {
  238. summary.updatedRedis += 1
  239. } else {
  240. summary.ignored += 1
  241. }
  242. }
  243. } catch (err) {
  244. logger.error({ err, projectId }, 'Failed to process project')
  245. if (OPTIONS['skip-history-failures']) {
  246. summary.skipped += 1
  247. } else {
  248. throw err
  249. }
  250. }
  251. }
  252. async function getHistoryDocVersions(project) {
  253. const historyId = project.overleaf.history.id
  254. const chunk = await chunkStore.loadLatest(historyId, { persistedOnly: true })
  255. if (chunk == null) {
  256. return []
  257. }
  258. const snapshot = chunk.getSnapshot()
  259. const changes = chunk.getChanges()
  260. snapshot.applyAll(changes)
  261. const v2DocVersions = snapshot.getV2DocVersions()
  262. if (v2DocVersions == null) {
  263. return []
  264. }
  265. return Object.entries(v2DocVersions.data).map(([docId, versionInfo]) => ({
  266. docId,
  267. version: versionInfo.v,
  268. }))
  269. }
  270. async function fixDocVersion(docId, historyVersion) {
  271. const redisVersion = await getRedisDocVersion(docId)
  272. if (redisVersion != null && historyVersion >= redisVersion) {
  273. await setRedisDocVersion(docId, historyVersion + 1)
  274. return {
  275. in: 'redis',
  276. previousVersion: redisVersion,
  277. newVersion: historyVersion + 1,
  278. }
  279. } else {
  280. const docBeforeUpdate = await db.docs.findOneAndUpdate(
  281. {
  282. _id: new ObjectId(docId),
  283. $or: [
  284. { version: { $lte: historyVersion } },
  285. { version: { $exists: false } },
  286. ],
  287. },
  288. { $set: { version: historyVersion + 1 } },
  289. { projection: { _id: 1, version: 1 } }
  290. )
  291. if (docBeforeUpdate != null) {
  292. return {
  293. in: 'mongo',
  294. previousVersion: docBeforeUpdate.version,
  295. newVersion: historyVersion + 1,
  296. }
  297. } else {
  298. return null
  299. }
  300. }
  301. }
  302. async function getRedisDocVersion(docId) {
  303. if (!unflushedDocIds.has(docId)) {
  304. return null
  305. }
  306. const result = await rclient.get(
  307. Settings.redis.documentupdater.key_schema.docVersion({ doc_id: docId })
  308. )
  309. if (result == null) {
  310. return null
  311. }
  312. return parseInt(result, 10)
  313. }
  314. async function setRedisDocVersion(docId, version) {
  315. const multi = rclient.multi()
  316. multi.set(
  317. Settings.redis.documentupdater.key_schema.docVersion({ doc_id: docId }),
  318. version
  319. )
  320. multi.set(`UnflushedTime:{${docId}}`, Date.now(), 'NX')
  321. await multi.exec()
  322. }
  323. /**
  324. * Set all remaining versions to 0
  325. */
  326. async function backfillMissingVersions() {
  327. console.log('Defaulting version to 0 for remaining docs.')
  328. await db.docs.updateMany(
  329. { version: { $exists: false } },
  330. { $set: { version: 0 } }
  331. )
  332. }
  333. main()
  334. .finally(async () => {
  335. console.log('Flushing log queue.')
  336. await flushLogQueue()
  337. })
  338. .then(() => {
  339. process.exit(0)
  340. })
  341. .catch(err => {
  342. console.error(err)
  343. process.exit(1)
  344. })