check_redis_mongo_sync_state.js 12 KB

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