check_redis_mongo_sync_state.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. const fs = require('fs')
  2. const Path = require('path')
  3. const _ = require('lodash')
  4. const logger = require('@overleaf/logger')
  5. const OError = require('@overleaf/o-error')
  6. const LockManager = require('../app/js/LockManager')
  7. const PersistenceManager = require('../app/js/PersistenceManager')
  8. const ProjectFlusher = require('../app/js/ProjectFlusher')
  9. const ProjectManager = require('../app/js/ProjectManager')
  10. const RedisManager = require('../app/js/RedisManager')
  11. const Settings = require('@overleaf/settings')
  12. const AUTO_FIX_VERSION_MISMATCH =
  13. process.env.AUTO_FIX_VERSION_MISMATCH === 'true'
  14. const SCRIPT_LOG_LEVEL = process.env.SCRIPT_LOG_LEVEL || 'warn'
  15. const FLUSH_IN_SYNC_PROJECTS = process.env.FLUSH_IN_SYNC_PROJECTS === 'true'
  16. const FOLDER =
  17. process.env.FOLDER || '/tmp/overleaf-check-redis-mongo-sync-state'
  18. const LIMIT = parseInt(process.env.LIMIT || '1000', 10)
  19. const RETRIES = parseInt(process.env.RETRIES || '5', 10)
  20. const WRITE_CONTENT = process.env.WRITE_CONTENT === 'true'
  21. process.env.LOG_LEVEL = SCRIPT_LOG_LEVEL
  22. logger.initialize('check-redis-mongo-sync-state')
  23. const COMPARE_AND_SET =
  24. 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("set", KEYS[1], ARGV[2]) else return 0 end'
  25. /**
  26. * @typedef {Object} Doc
  27. * @property {number} version
  28. * @property {Array<string>} lines
  29. * @property {string} pathname
  30. * @property {Object} ranges
  31. */
  32. class TryAgainError extends Error {}
  33. /**
  34. * @param {string} docId
  35. * @param {Doc} redisDoc
  36. * @param {Doc} mongoDoc
  37. * @return {Promise<void>}
  38. */
  39. async function updateDocVersionInRedis(docId, redisDoc, mongoDoc) {
  40. const lockValue = await LockManager.promises.getLock(docId)
  41. try {
  42. const key = Settings.redis.documentupdater.key_schema.docVersion({
  43. doc_id: docId,
  44. })
  45. const numberOfKeys = 1
  46. const ok = await RedisManager.rclient.eval(
  47. COMPARE_AND_SET,
  48. numberOfKeys,
  49. key,
  50. redisDoc.version,
  51. mongoDoc.version
  52. )
  53. if (!ok) {
  54. throw new TryAgainError(
  55. 'document has been updated, aborting overwrite. Try again.'
  56. )
  57. }
  58. } finally {
  59. await LockManager.promises.releaseLock(docId, lockValue)
  60. }
  61. }
  62. /**
  63. * @param {string} projectId
  64. * @param {string} docId
  65. * @return {Promise<boolean>}
  66. */
  67. async function processDoc(projectId, docId) {
  68. const redisDoc = /** @type Doc */ await RedisManager.promises.getDoc(
  69. projectId,
  70. docId
  71. )
  72. const mongoDoc = /** @type Doc */ await PersistenceManager.promises.getDoc(
  73. projectId,
  74. docId
  75. )
  76. if (mongoDoc.version < redisDoc.version) {
  77. // mongo is behind, we can flush to mongo when all docs are processed.
  78. return false
  79. }
  80. mongoDoc.snapshot = mongoDoc.lines.join('\n')
  81. redisDoc.snapshot = redisDoc.lines.join('\n')
  82. if (!mongoDoc.ranges) mongoDoc.ranges = {}
  83. if (!redisDoc.ranges) redisDoc.ranges = {}
  84. const sameLines = mongoDoc.snapshot === redisDoc.snapshot
  85. const sameRanges = _.isEqual(mongoDoc.ranges, redisDoc.ranges)
  86. if (sameLines && sameRanges) {
  87. if (mongoDoc.version > redisDoc.version) {
  88. // mongo is ahead, technically out of sync, but practically the content is identical
  89. if (AUTO_FIX_VERSION_MISMATCH) {
  90. console.log(
  91. `Fixing out of sync doc version for doc ${docId} in project ${projectId}: mongo=${mongoDoc.version} > redis=${redisDoc.version}`
  92. )
  93. await updateDocVersionInRedis(docId, redisDoc, mongoDoc)
  94. return false
  95. } else {
  96. console.error(
  97. `Detected out of sync redis and mongo version for doc ${docId} in project ${projectId}, auto-fixable via AUTO_FIX_VERSION_MISMATCH=true`
  98. )
  99. return true
  100. }
  101. } else {
  102. // same lines, same ranges, same version
  103. return false
  104. }
  105. }
  106. const dir = Path.join(FOLDER, projectId, docId)
  107. console.error(
  108. `Detected out of sync redis and mongo content for doc ${docId} in project ${projectId}`
  109. )
  110. if (!WRITE_CONTENT) return true
  111. console.log(`pathname: ${mongoDoc.pathname}`)
  112. console.log(`mongo version: ${mongoDoc.version}`)
  113. console.log(`redis version: ${redisDoc.version}`)
  114. await fs.promises.mkdir(dir, { recursive: true })
  115. if (sameLines) {
  116. console.log('mongo lines match redis lines')
  117. } else {
  118. console.log(
  119. `mongo lines and redis lines out of sync, writing content into ${dir}`
  120. )
  121. await fs.promises.writeFile(
  122. Path.join(dir, 'mongo-snapshot.txt'),
  123. mongoDoc.snapshot
  124. )
  125. await fs.promises.writeFile(
  126. Path.join(dir, 'redis-snapshot.txt'),
  127. redisDoc.snapshot
  128. )
  129. }
  130. if (sameRanges) {
  131. console.log('mongo ranges match redis ranges')
  132. } else {
  133. console.log(
  134. `mongo ranges and redis ranges out of sync, writing content into ${dir}`
  135. )
  136. await fs.promises.writeFile(
  137. Path.join(dir, 'mongo-ranges.json'),
  138. JSON.stringify(mongoDoc.ranges)
  139. )
  140. await fs.promises.writeFile(
  141. Path.join(dir, 'redis-ranges.json'),
  142. JSON.stringify(redisDoc.ranges)
  143. )
  144. }
  145. console.log('---')
  146. return true
  147. }
  148. /**
  149. * @param {string} projectId
  150. * @return {Promise<number>}
  151. */
  152. async function processProject(projectId) {
  153. const docIds = await RedisManager.promises.getDocIdsInProject(projectId)
  154. let outOfSync = 0
  155. for (const docId of docIds) {
  156. let lastErr
  157. for (let i = 0; i <= RETRIES; i++) {
  158. try {
  159. if (await processDoc(projectId, docId)) {
  160. outOfSync++
  161. }
  162. break
  163. } catch (err) {
  164. lastErr = err
  165. }
  166. }
  167. if (lastErr) {
  168. throw OError.tag(lastErr, 'process doc', { docId })
  169. }
  170. }
  171. if (outOfSync === 0 && FLUSH_IN_SYNC_PROJECTS) {
  172. try {
  173. await ProjectManager.promises.flushAndDeleteProjectWithLocks(
  174. projectId,
  175. {}
  176. )
  177. } catch (err) {
  178. throw OError.tag(err, 'flush project with only in-sync docs')
  179. }
  180. }
  181. return outOfSync
  182. }
  183. /**
  184. * @param {Set<string>} processed
  185. * @param {Set<string>} outOfSync
  186. * @return {Promise<{perIterationOutOfSync: number, done: boolean}>}
  187. */
  188. async function scanOnce(processed, outOfSync) {
  189. const projectIds = await ProjectFlusher.promises.flushAllProjects({
  190. limit: LIMIT,
  191. dryRun: true,
  192. })
  193. let perIterationOutOfSync = 0
  194. for (const projectId of projectIds) {
  195. if (processed.has(projectId)) continue
  196. processed.add(projectId)
  197. let perProjectOutOfSync = 0
  198. try {
  199. perProjectOutOfSync = await processProject(projectId)
  200. } catch (err) {
  201. throw OError.tag(err, 'process project', { projectId })
  202. }
  203. perIterationOutOfSync += perProjectOutOfSync
  204. if (perProjectOutOfSync > 0) {
  205. outOfSync.add(projectId)
  206. }
  207. }
  208. return { perIterationOutOfSync, done: projectIds.length < LIMIT }
  209. }
  210. /**
  211. * @return {Promise<number>}
  212. */
  213. async function main() {
  214. if (!WRITE_CONTENT) {
  215. console.warn()
  216. console.warn(
  217. ` Use WRITE_CONTENT=true to write the content of out of sync docs to FOLDER=${FOLDER}`
  218. )
  219. console.warn()
  220. } else {
  221. console.log(
  222. `Writing content for projects with out of sync docs into FOLDER=${FOLDER}`
  223. )
  224. await fs.promises.mkdir(FOLDER, { recursive: true })
  225. const existing = await fs.promises.readdir(FOLDER)
  226. if (existing.length > 0) {
  227. console.warn()
  228. console.warn(
  229. ` Found existing entries in FOLDER=${FOLDER}. Please delete or move these before running the script again.`
  230. )
  231. console.warn()
  232. return 101
  233. }
  234. }
  235. if (LIMIT < 100) {
  236. console.warn()
  237. console.warn(
  238. ` Using small LIMIT=${LIMIT}, this can take a while to SCAN in a large redis database.`
  239. )
  240. console.warn()
  241. }
  242. const processed = new Set()
  243. const outOfSyncProjects = new Set()
  244. let totalOutOfSyncDocs = 0
  245. while (true) {
  246. const before = processed.size
  247. const { perIterationOutOfSync, done } = await scanOnce(
  248. processed,
  249. outOfSyncProjects
  250. )
  251. totalOutOfSyncDocs += perIterationOutOfSync
  252. console.log(`Processed ${processed.size} projects`)
  253. console.log(
  254. `Found ${
  255. outOfSyncProjects.size
  256. } projects with ${totalOutOfSyncDocs} out of sync docs: ${JSON.stringify(
  257. Array.from(outOfSyncProjects)
  258. )}`
  259. )
  260. if (done) {
  261. console.log('Finished iterating all projects in redis')
  262. break
  263. }
  264. if (processed.size === before) {
  265. console.error(
  266. `Found too many un-flushed projects (LIMIT=${LIMIT}). Please fix the reported projects first, then try again.`
  267. )
  268. if (!FLUSH_IN_SYNC_PROJECTS) {
  269. console.error(
  270. 'Use FLUSH_IN_SYNC_PROJECTS=true to flush projects that have been checked.'
  271. )
  272. }
  273. return 2
  274. }
  275. }
  276. return totalOutOfSyncDocs > 0 ? 1 : 0
  277. }
  278. main()
  279. .then(code => {
  280. process.exit(code)
  281. })
  282. .catch(error => {
  283. console.error(OError.getFullStack(error))
  284. console.error(OError.getFullInfo(error))
  285. process.exit(1)
  286. })