pr_18065.patch 9.3 KB

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