check_redis_mongo_sync_state.js 11 KB

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