bulk_resync_file_fix_up.mjs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. // @ts-check
  2. import Events from 'node:events'
  3. import { setTimeout } from 'node:timers/promises'
  4. import readline from 'node:readline'
  5. import fs from 'node:fs'
  6. import minimist from 'minimist'
  7. import { ObjectId } from 'mongodb'
  8. import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
  9. import logger from '@overleaf/logger'
  10. import Metrics from '@overleaf/metrics'
  11. import OError from '@overleaf/o-error'
  12. import { promiseMapWithLimit } from '@overleaf/promise-utils'
  13. import { db, mongoClient } from '../app/js/mongodb.js'
  14. import * as HistoryStoreManager from '../app/js/HistoryStoreManager.js'
  15. import * as RedisManager from '../app/js/RedisManager.js'
  16. import * as SyncManager from '../app/js/SyncManager.js'
  17. import * as UpdatesProcessor from '../app/js/UpdatesProcessor.js'
  18. import { NeedFullProjectStructureResyncError } from '../app/js/Errors.js'
  19. import * as ErrorRecorder from '../app/js/ErrorRecorder.js'
  20. // Silence warning.
  21. Events.setMaxListeners(20)
  22. // Enable caching for ObjectId.toString()
  23. ObjectId.cacheHexString = true
  24. const READ_CONCURRENCY = parseInt(process.env.READ_CONCURRENCY || '100', 10)
  25. const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY || '10', 10)
  26. const FLUSH_RETRIES = parseInt(process.env.FLUSH_RETRIES || '20', 10)
  27. // Relevant dates:
  28. // - 2024-12-19, start of event-hold removal in filestore bucket -> objects older than 24h are (soft-)deleted.
  29. // - 2024-12-23, copy operation skipped in filestore when cloning project -> objects not created on clone.
  30. // - 2025-01-24, no more filestore reads allowed in project-history -> no more empty files in history for 404s
  31. const FILESTORE_SOFT_DELETE_START = new Date('2024-12-19T00:00:00Z')
  32. const FILESTORE_READ_OFF = new Date('2025-01-24T15:00:00Z')
  33. const argv = minimist(process.argv.slice(2), {
  34. string: ['logs', 'log-latency'],
  35. })
  36. const LOG_LATENCY = argv['log-latency'] === 'true'
  37. let gracefulShutdownInitiated = false
  38. process.on('SIGINT', handleSignal)
  39. process.on('SIGTERM', handleSignal)
  40. function handleSignal() {
  41. gracefulShutdownInitiated = true
  42. console.warn('graceful shutdown initiated, draining queue')
  43. }
  44. const STATS = {
  45. processedLines: 0,
  46. success: 0,
  47. changed: 0,
  48. failure: 0,
  49. skipped: 0,
  50. checkFailure: 0,
  51. }
  52. function logStats() {
  53. console.log(
  54. JSON.stringify({
  55. time: new Date(),
  56. gracefulShutdownInitiated,
  57. ...STATS,
  58. })
  59. )
  60. }
  61. const logInterval = setInterval(logStats, 10_000)
  62. /**
  63. * @typedef {Object} FileRef
  64. * @property {ObjectId} _id
  65. * @property {any} linkedFileData
  66. */
  67. /**
  68. * @typedef {Object} Folder
  69. * @property {Array<Folder>} folders
  70. * @property {Array<FileRef>} fileRefs
  71. */
  72. /**
  73. * @typedef {Object} Project
  74. * @property {ObjectId} _id
  75. * @property {Date} lastUpdated
  76. * @property {Array<Folder>} rootFolder
  77. * @property {{history: {id: (number|string)}}} overleaf
  78. */
  79. /**
  80. * @param {Folder} folder
  81. * @return {boolean}
  82. */
  83. function checkFileTreeNeedsResync(folder) {
  84. if (!folder) return false
  85. if (Array.isArray(folder.fileRefs)) {
  86. for (const fileRef of folder.fileRefs) {
  87. if (fileRef.linkedFileData) return true
  88. if (fileRef._id.getTimestamp() > FILESTORE_SOFT_DELETE_START) return true
  89. }
  90. }
  91. if (Array.isArray(folder.folders)) {
  92. for (const child of folder.folders) {
  93. if (checkFileTreeNeedsResync(child)) return true
  94. }
  95. }
  96. return false
  97. }
  98. /**
  99. * @param {string} projectId
  100. * @param {string} historyId
  101. * @return {Promise<Date>}
  102. */
  103. async function getLastEndTimestamp(projectId, historyId) {
  104. const raw = await HistoryStoreManager.promises.getMostRecentVersionRaw(
  105. projectId,
  106. historyId,
  107. { readOnly: true }
  108. )
  109. if (!raw) throw new Error('bug: history not initialized')
  110. return raw.endTimestamp
  111. }
  112. /** @type {Record<string, (project: Project) => Promise<boolean>>} */
  113. const conditions = {
  114. // cheap: in-memory mongo lookup
  115. 'updated after filestore soft-delete': async function (project) {
  116. return project.lastUpdated > FILESTORE_SOFT_DELETE_START
  117. },
  118. // cheap: in-memory mongo lookup
  119. 'file-tree requires re-sync': async function (project) {
  120. return checkFileTreeNeedsResync(project.rootFolder?.[0])
  121. },
  122. // moderate: GET from Redis
  123. 'has pending operations': async function (project) {
  124. const n = await RedisManager.promises.countUnprocessedUpdates(
  125. project._id.toString()
  126. )
  127. return n > 0
  128. },
  129. // expensive: GET from Mongo/Postgres via history-v1 HTTP API call
  130. 'has been flushed after filestore soft-delete': async function (project) {
  131. // Resyncs started after soft-deleting can trigger 404s and result in empty files.
  132. const endTimestamp = await getLastEndTimestamp(
  133. project._id.toString(),
  134. project.overleaf.history.id.toString()
  135. )
  136. return endTimestamp > FILESTORE_SOFT_DELETE_START
  137. },
  138. }
  139. /**
  140. * @param {Project} project
  141. * @return {Promise<{projectId: string, historyId: string} | null>}
  142. */
  143. async function checkProject(project) {
  144. if (gracefulShutdownInitiated) return null
  145. if (project._id.getTimestamp() > FILESTORE_READ_OFF) {
  146. STATS.skipped++ // Project created after all bugs were fixed.
  147. return null
  148. }
  149. const projectId = project._id.toString()
  150. const historyId = project.overleaf.history.id.toString()
  151. for (const [condition, check] of Object.entries(conditions)) {
  152. try {
  153. if (await check(project)) return { projectId, historyId }
  154. } catch (err) {
  155. logger.err({ projectId, condition, err }, 'failed to check project')
  156. STATS.checkFailure++
  157. return null
  158. }
  159. }
  160. STATS.skipped++
  161. return null
  162. }
  163. /**
  164. * @param {string} projectId
  165. * @param {string} historyId
  166. * @return {Promise<void>}
  167. */
  168. async function processProject(projectId, historyId) {
  169. if (gracefulShutdownInitiated) return
  170. const t0 = performance.now()
  171. try {
  172. await tryProcessProject(projectId, historyId)
  173. const latency = performance.now() - t0
  174. if (LOG_LATENCY) {
  175. logger.info({ projectId, historyId, latency }, 'processed project')
  176. }
  177. STATS.success++
  178. } catch (err) {
  179. logger.err({ err, projectId, historyId }, 'failed to process project')
  180. STATS.failure++
  181. }
  182. }
  183. /**
  184. * @param {string} projectId
  185. * @return {Promise<void>}
  186. */
  187. async function flushWithRetries(projectId) {
  188. for (let attempt = 0; attempt < FLUSH_RETRIES; attempt++) {
  189. try {
  190. await UpdatesProcessor.promises.processUpdatesForProject(projectId)
  191. return
  192. } catch (err) {
  193. logger.warn(
  194. { projectId, err, attempt },
  195. 'failed to flush updates, trying again'
  196. )
  197. if (gracefulShutdownInitiated) throw err
  198. }
  199. }
  200. try {
  201. await UpdatesProcessor.promises.processUpdatesForProject(projectId)
  202. } catch (err) {
  203. // @ts-ignore err is Error
  204. throw new OError('failed to flush updates', {}, err)
  205. }
  206. }
  207. /**
  208. * @param {string} projectId
  209. * @param {string} historyId
  210. * @return {Promise<void>}
  211. */
  212. async function tryProcessProject(projectId, historyId) {
  213. await flushWithRetries(projectId)
  214. const start = new Date()
  215. let needsFullSync = false
  216. try {
  217. await UpdatesProcessor.promises.startResyncAndProcessUpdatesUnderLock(
  218. projectId,
  219. { resyncProjectStructureOnly: true }
  220. )
  221. } catch (err) {
  222. if (err instanceof NeedFullProjectStructureResyncError) {
  223. needsFullSync = true
  224. } else {
  225. throw err
  226. }
  227. }
  228. if (needsFullSync) {
  229. logger.warn(
  230. { projectId, historyId },
  231. 'structure only resync not sufficient, doing full soft resync'
  232. )
  233. await SyncManager.promises.startResync(projectId, {})
  234. await UpdatesProcessor.promises.processUpdatesForProject(projectId)
  235. STATS.changed++
  236. } else {
  237. const after = await getLastEndTimestamp(projectId, historyId)
  238. if (after > start) {
  239. STATS.changed++
  240. }
  241. }
  242. // Avoid db.projectHistorySyncState from growing for each project we resynced.
  243. // MongoDB collections cannot shrink on their own. In case of success, purge
  244. // the db entry created by this script right away.
  245. await SyncManager.promises.clearResyncStateIfAllAfter(projectId, start)
  246. }
  247. async function processBatch(projects) {
  248. const projectIds = (
  249. await promiseMapWithLimit(READ_CONCURRENCY, projects, checkProject)
  250. ).filter(id => !!id)
  251. await promiseMapWithLimit(WRITE_CONCURRENCY, projectIds, ids =>
  252. processProject(ids.projectId, ids.historyId)
  253. )
  254. if (gracefulShutdownInitiated) throw new Error('graceful shutdown triggered')
  255. }
  256. async function processProjectsFromLog() {
  257. const rl = readline.createInterface({
  258. input: fs.createReadStream(argv.logs),
  259. })
  260. for await (const line of rl) {
  261. if (gracefulShutdownInitiated) break
  262. STATS.processedLines++
  263. if (!line.startsWith('{')) continue
  264. const { projectId, historyId, msg } = JSON.parse(line)
  265. if (msg !== 'failed to process project') continue
  266. await processProject(projectId, historyId) // does try/catch with logging
  267. }
  268. }
  269. async function main() {
  270. if (argv.logs) {
  271. await processProjectsFromLog()
  272. return
  273. }
  274. await batchedUpdate(db.projects, {}, processBatch, {
  275. _id: 1,
  276. lastUpdated: 1,
  277. 'overleaf.history': 1,
  278. rootFolder: 1,
  279. })
  280. }
  281. try {
  282. try {
  283. await main()
  284. } finally {
  285. clearInterval(logInterval)
  286. logStats()
  287. Metrics.close()
  288. await mongoClient.close()
  289. // TODO(das7pad): graceful shutdown for redis. Refactor process.exit when done.
  290. }
  291. console.log('Done.')
  292. await setTimeout(1_000)
  293. if (STATS.failure) {
  294. process.exit(Math.min(STATS.failure, 99))
  295. } else {
  296. process.exit(0)
  297. }
  298. } catch (err) {
  299. logger.err({ err }, 'fatal error')
  300. await setTimeout(1_000)
  301. process.exit(100)
  302. }