check_redis_mongo_sync_state.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  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 Errors = require('../app/js/Errors')
  7. const LockManager = require('../app/js/LockManager')
  8. const PersistenceManager = require('../app/js/PersistenceManager')
  9. const ProjectFlusher = require('../app/js/ProjectFlusher')
  10. const ProjectManager = require('../app/js/ProjectManager')
  11. const RedisManager = require('../app/js/RedisManager')
  12. const Settings = require('@overleaf/settings')
  13. const request = require('requestretry').defaults({
  14. maxAttempts: 2,
  15. retryDelay: 10,
  16. })
  17. const AUTO_FIX_VERSION_MISMATCH =
  18. process.env.AUTO_FIX_VERSION_MISMATCH === 'true'
  19. const AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA =
  20. process.env.AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA === 'true'
  21. const SCRIPT_LOG_LEVEL = process.env.SCRIPT_LOG_LEVEL || 'warn'
  22. const FLUSH_IN_SYNC_PROJECTS = process.env.FLUSH_IN_SYNC_PROJECTS === 'true'
  23. const FOLDER =
  24. process.env.FOLDER || '/tmp/overleaf-check-redis-mongo-sync-state'
  25. const LIMIT = parseInt(process.env.LIMIT || '1000', 10)
  26. const RETRIES = parseInt(process.env.RETRIES || '5', 10)
  27. const WRITE_CONTENT = process.env.WRITE_CONTENT === 'true'
  28. process.env.LOG_LEVEL = SCRIPT_LOG_LEVEL
  29. logger.initialize('check-redis-mongo-sync-state')
  30. const COMPARE_AND_SET =
  31. 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("set", KEYS[1], ARGV[2]) else return 0 end'
  32. /**
  33. * @typedef {Object} Doc
  34. * @property {number} version
  35. * @property {Array<string>} lines
  36. * @property {string} pathname
  37. * @property {Object} ranges
  38. * @property {boolean} [partiallyDeleted]
  39. */
  40. class TryAgainError extends Error {}
  41. /**
  42. * @param {string} docId
  43. * @param {Doc} redisDoc
  44. * @param {Doc} mongoDoc
  45. * @return {Promise<void>}
  46. */
  47. async function updateDocVersionInRedis(docId, redisDoc, mongoDoc) {
  48. const lockValue = await LockManager.promises.getLock(docId)
  49. try {
  50. const key = Settings.redis.documentupdater.key_schema.docVersion({
  51. doc_id: docId,
  52. })
  53. const numberOfKeys = 1
  54. const ok = await RedisManager.rclient.eval(
  55. COMPARE_AND_SET,
  56. numberOfKeys,
  57. key,
  58. redisDoc.version,
  59. mongoDoc.version
  60. )
  61. if (!ok) {
  62. throw new TryAgainError(
  63. 'document has been updated, aborting overwrite. Try again.'
  64. )
  65. }
  66. } finally {
  67. await LockManager.promises.releaseLock(docId, lockValue)
  68. }
  69. }
  70. async function fixPartiallyDeletedDocMetadata(projectId, docId, pathname) {
  71. await new Promise((resolve, reject) => {
  72. request(
  73. {
  74. method: 'PATCH',
  75. url: `http://${process.env.DOCSTORE_HOST || '127.0.0.1'}:3016/project/${projectId}/doc/${docId}`,
  76. timeout: 60 * 1000,
  77. json: {
  78. name: Path.basename(pathname),
  79. deleted: true,
  80. deletedAt: new Date(),
  81. },
  82. },
  83. (err, res, body) => {
  84. if (err) return reject(err)
  85. const { statusCode } = res
  86. if (statusCode !== 204) {
  87. return reject(
  88. new OError('patch request to docstore failed', {
  89. statusCode,
  90. body,
  91. })
  92. )
  93. }
  94. resolve()
  95. }
  96. )
  97. })
  98. }
  99. async function getDocFromMongo(projectId, docId) {
  100. try {
  101. return await PersistenceManager.promises.getDoc(projectId, docId)
  102. } catch (err) {
  103. if (!(err instanceof Errors.NotFoundError)) {
  104. throw err
  105. }
  106. }
  107. const docstoreDoc = await new Promise((resolve, reject) => {
  108. request(
  109. {
  110. url: `http://${process.env.DOCSTORE_HOST || '127.0.0.1'}:3016/project/${projectId}/doc/${docId}/peek`,
  111. timeout: 60 * 1000,
  112. json: true,
  113. },
  114. (err, res, body) => {
  115. if (err) return reject(err)
  116. const { statusCode } = res
  117. if (statusCode !== 200) {
  118. return reject(
  119. new OError('fallback request to docstore failed', {
  120. statusCode,
  121. body,
  122. })
  123. )
  124. }
  125. resolve(body)
  126. }
  127. )
  128. })
  129. const deletedDocName = await new Promise((resolve, reject) => {
  130. request(
  131. {
  132. url: `http://${process.env.DOCSTORE_HOST || '127.0.0.1'}:3016/project/${projectId}/doc-deleted`,
  133. timeout: 60 * 1000,
  134. json: true,
  135. },
  136. (err, res, body) => {
  137. if (err) return reject(err)
  138. const { statusCode } = res
  139. if (statusCode !== 200) {
  140. return reject(
  141. new OError('list deleted docs request to docstore failed', {
  142. statusCode,
  143. body,
  144. })
  145. )
  146. }
  147. resolve(body.find(doc => doc._id === docId)?.name)
  148. }
  149. )
  150. })
  151. if (docstoreDoc.deleted && deletedDocName) {
  152. return {
  153. ...docstoreDoc,
  154. pathname: deletedDocName,
  155. }
  156. }
  157. return {
  158. ...docstoreDoc,
  159. pathname: `/partially-deleted-doc-with-unknown-name-and-id-${docId}.txt`,
  160. partiallyDeleted: true,
  161. }
  162. }
  163. /**
  164. * @param {string} projectId
  165. * @param {string} docId
  166. * @return {Promise<boolean>}
  167. */
  168. async function processDoc(projectId, docId) {
  169. const redisDoc = /** @type Doc */ await RedisManager.promises.getDoc(
  170. projectId,
  171. docId
  172. )
  173. const mongoDoc = /** @type Doc */ await getDocFromMongo(projectId, docId)
  174. if (mongoDoc.partiallyDeleted) {
  175. if (AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA) {
  176. console.log(
  177. `Found partially deleted doc ${docId} in project ${projectId}: fixing metadata`
  178. )
  179. await fixPartiallyDeletedDocMetadata(projectId, docId, redisDoc.pathname)
  180. } else {
  181. console.log(
  182. `Found partially deleted doc ${docId} in project ${projectId}: use AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA=true to fix metadata`
  183. )
  184. }
  185. }
  186. if (mongoDoc.version < redisDoc.version) {
  187. // mongo is behind, we can flush to mongo when all docs are processed.
  188. return false
  189. }
  190. mongoDoc.snapshot = mongoDoc.lines.join('\n')
  191. redisDoc.snapshot = redisDoc.lines.join('\n')
  192. if (!mongoDoc.ranges) mongoDoc.ranges = {}
  193. if (!redisDoc.ranges) redisDoc.ranges = {}
  194. const sameLines = mongoDoc.snapshot === redisDoc.snapshot
  195. const sameRanges = _.isEqual(mongoDoc.ranges, redisDoc.ranges)
  196. if (sameLines && sameRanges) {
  197. if (mongoDoc.version > redisDoc.version) {
  198. // mongo is ahead, technically out of sync, but practically the content is identical
  199. if (AUTO_FIX_VERSION_MISMATCH) {
  200. console.log(
  201. `Fixing out of sync doc version for doc ${docId} in project ${projectId}: mongo=${mongoDoc.version} > redis=${redisDoc.version}`
  202. )
  203. await updateDocVersionInRedis(docId, redisDoc, mongoDoc)
  204. return false
  205. } else {
  206. console.error(
  207. `Detected out of sync redis and mongo version for doc ${docId} in project ${projectId}, auto-fixable via AUTO_FIX_VERSION_MISMATCH=true`
  208. )
  209. return true
  210. }
  211. } else {
  212. // same lines, same ranges, same version
  213. return false
  214. }
  215. }
  216. const dir = Path.join(FOLDER, projectId, docId)
  217. console.error(
  218. `Detected out of sync redis and mongo content for doc ${docId} in project ${projectId}`
  219. )
  220. if (!WRITE_CONTENT) return true
  221. console.log(`pathname: ${mongoDoc.pathname}`)
  222. if (mongoDoc.pathname !== redisDoc.pathname) {
  223. console.log(`pathname redis: ${redisDoc.pathname}`)
  224. }
  225. console.log(`mongo version: ${mongoDoc.version}`)
  226. console.log(`redis version: ${redisDoc.version}`)
  227. await fs.promises.mkdir(dir, { recursive: true })
  228. if (sameLines) {
  229. console.log('mongo lines match redis lines')
  230. } else {
  231. console.log(
  232. `mongo lines and redis lines out of sync, writing content into ${dir}`
  233. )
  234. await fs.promises.writeFile(
  235. Path.join(dir, 'mongo-snapshot.txt'),
  236. mongoDoc.snapshot
  237. )
  238. await fs.promises.writeFile(
  239. Path.join(dir, 'redis-snapshot.txt'),
  240. redisDoc.snapshot
  241. )
  242. }
  243. if (sameRanges) {
  244. console.log('mongo ranges match redis ranges')
  245. } else {
  246. console.log(
  247. `mongo ranges and redis ranges out of sync, writing content into ${dir}`
  248. )
  249. await fs.promises.writeFile(
  250. Path.join(dir, 'mongo-ranges.json'),
  251. JSON.stringify(mongoDoc.ranges)
  252. )
  253. await fs.promises.writeFile(
  254. Path.join(dir, 'redis-ranges.json'),
  255. JSON.stringify(redisDoc.ranges)
  256. )
  257. }
  258. console.log('---')
  259. return true
  260. }
  261. /**
  262. * @param {string} projectId
  263. * @return {Promise<number>}
  264. */
  265. async function processProject(projectId) {
  266. const docIds = await RedisManager.promises.getDocIdsInProject(projectId)
  267. let outOfSync = 0
  268. for (const docId of docIds) {
  269. let lastErr
  270. for (let i = 0; i <= RETRIES; i++) {
  271. try {
  272. if (await processDoc(projectId, docId)) {
  273. outOfSync++
  274. }
  275. break
  276. } catch (err) {
  277. lastErr = err
  278. }
  279. }
  280. if (lastErr) {
  281. throw OError.tag(lastErr, 'process doc', { docId })
  282. }
  283. }
  284. if (outOfSync === 0 && FLUSH_IN_SYNC_PROJECTS) {
  285. try {
  286. await ProjectManager.promises.flushAndDeleteProjectWithLocks(
  287. projectId,
  288. {}
  289. )
  290. } catch (err) {
  291. throw OError.tag(err, 'flush project with only in-sync docs')
  292. }
  293. }
  294. return outOfSync
  295. }
  296. /**
  297. * @param {Set<string>} processed
  298. * @param {Set<string>} outOfSync
  299. * @return {Promise<{perIterationOutOfSync: number, done: boolean}>}
  300. */
  301. async function scanOnce(processed, outOfSync) {
  302. const projectIds = await ProjectFlusher.promises.flushAllProjects({
  303. limit: LIMIT,
  304. dryRun: true,
  305. })
  306. let perIterationOutOfSync = 0
  307. for (const projectId of projectIds) {
  308. if (processed.has(projectId)) continue
  309. processed.add(projectId)
  310. let perProjectOutOfSync = 0
  311. try {
  312. perProjectOutOfSync = await processProject(projectId)
  313. } catch (err) {
  314. throw OError.tag(err, 'process project', { projectId })
  315. }
  316. perIterationOutOfSync += perProjectOutOfSync
  317. if (perProjectOutOfSync > 0) {
  318. outOfSync.add(projectId)
  319. }
  320. }
  321. return { perIterationOutOfSync, done: projectIds.length < LIMIT }
  322. }
  323. /**
  324. * @return {Promise<number>}
  325. */
  326. async function main() {
  327. if (!WRITE_CONTENT) {
  328. console.warn()
  329. console.warn(
  330. ` Use WRITE_CONTENT=true to write the content of out of sync docs to FOLDER=${FOLDER}`
  331. )
  332. console.warn()
  333. } else {
  334. console.log(
  335. `Writing content for projects with out of sync docs into FOLDER=${FOLDER}`
  336. )
  337. await fs.promises.mkdir(FOLDER, { recursive: true })
  338. const existing = await fs.promises.readdir(FOLDER)
  339. if (existing.length > 0) {
  340. console.warn()
  341. console.warn(
  342. ` Found existing entries in FOLDER=${FOLDER}. Please delete or move these before running the script again.`
  343. )
  344. console.warn()
  345. return 101
  346. }
  347. }
  348. if (LIMIT < 100) {
  349. console.warn()
  350. console.warn(
  351. ` Using small LIMIT=${LIMIT}, this can take a while to SCAN in a large redis database.`
  352. )
  353. console.warn()
  354. }
  355. const processed = new Set()
  356. const outOfSyncProjects = new Set()
  357. let totalOutOfSyncDocs = 0
  358. while (true) {
  359. const before = processed.size
  360. const { perIterationOutOfSync, done } = await scanOnce(
  361. processed,
  362. outOfSyncProjects
  363. )
  364. totalOutOfSyncDocs += perIterationOutOfSync
  365. console.log(`Processed ${processed.size} projects`)
  366. console.log(
  367. `Found ${
  368. outOfSyncProjects.size
  369. } projects with ${totalOutOfSyncDocs} out of sync docs: ${JSON.stringify(
  370. Array.from(outOfSyncProjects)
  371. )}`
  372. )
  373. if (done) {
  374. console.log('Finished iterating all projects in redis')
  375. break
  376. }
  377. if (processed.size === before) {
  378. console.error(
  379. `Found too many un-flushed projects (LIMIT=${LIMIT}). Please fix the reported projects first, then try again.`
  380. )
  381. if (!FLUSH_IN_SYNC_PROJECTS) {
  382. console.error(
  383. 'Use FLUSH_IN_SYNC_PROJECTS=true to flush projects that have been checked.'
  384. )
  385. }
  386. return 2
  387. }
  388. }
  389. return totalOutOfSyncDocs > 0 ? 1 : 0
  390. }
  391. main()
  392. .then(code => {
  393. process.exit(code)
  394. })
  395. .catch(error => {
  396. console.error(OError.getFullStack(error))
  397. console.error(OError.getFullInfo(error))
  398. process.exit(1)
  399. })