HistoryResourceWriter.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. // @ts-check
  2. import logger from '@overleaf/logger'
  3. import zlib from 'node:zlib'
  4. import Settings from '@overleaf/settings'
  5. import Path from 'node:path'
  6. import fs from 'node:fs'
  7. import CLSICacheHandler from './CLSICacheHandler.js'
  8. import Errors from './Errors.js'
  9. import { callbackify, promisify } from 'node:util'
  10. import {
  11. AddFileOperation,
  12. Change,
  13. EditFileOperation,
  14. File,
  15. MoveFileOperation,
  16. Snapshot,
  17. } from 'overleaf-editor-core'
  18. import { fetchString, RequestFailedError } from '@overleaf/fetch-utils'
  19. import { setTimeout } from 'node:timers/promises'
  20. import ResourceWriter from './ResourceWriter.js'
  21. import UrlCache from './UrlCache.js'
  22. import OError from '@overleaf/o-error'
  23. import ClsiMetrics from './Metrics.js'
  24. import { promiseMapSettledWithLimit } from '@overleaf/promise-utils'
  25. const gzip = promisify(zlib.gzip)
  26. const gunzip = promisify(zlib.gunzip)
  27. export const clearCacheCb = callbackify(clearCache)
  28. /**
  29. * @param {string} projectId
  30. * @param {string} userId
  31. * @return {Promise<void>}
  32. */
  33. export async function clearCache(projectId, userId) {
  34. const { dir } = snapshotPath(projectId, userId)
  35. try {
  36. await fs.promises.rm(dir, { recursive: true, force: true })
  37. } catch (err) {
  38. if (isENOENT(err)) return
  39. logger.warn(
  40. { err, projectId, userId },
  41. 'compile from cache: failed to clear history cache'
  42. )
  43. }
  44. }
  45. /**
  46. * @param {string} projectId
  47. * @param {string} userId
  48. * @return {{ dir: string, path: string, resyncPath: string }}
  49. */
  50. function snapshotPath(projectId, userId) {
  51. const dir = Path.join(
  52. Settings.path.clsiCacheDir,
  53. userId ? `${projectId}-${userId}` : projectId
  54. )
  55. const path = Path.join(dir, 'history.json.gz')
  56. const resyncPath = Path.join(dir, 'history-resync.json.gz')
  57. return { dir, path, resyncPath }
  58. }
  59. /**
  60. * @param {unknown} err
  61. * @return {boolean}
  62. */
  63. function isENOENT(err) {
  64. return err instanceof Error && 'code' in err && err.code === 'ENOENT'
  65. }
  66. /**
  67. * @param {string} projectId
  68. * @param {string} userId
  69. * @param {number} remoteBaseVersion
  70. * @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number}>}
  71. */
  72. async function loadSnapshot(projectId, userId, remoteBaseVersion) {
  73. const { path, resyncPath } = snapshotPath(projectId, userId)
  74. let maxLocalBaseVersion = -1
  75. for (const candidate of [path, resyncPath]) {
  76. try {
  77. const fullSync = candidate === resyncPath
  78. return await loadSnapshotFromFile(candidate, remoteBaseVersion, fullSync)
  79. } catch (err) {
  80. if (err instanceof Errors.MissingUpdatesError) {
  81. maxLocalBaseVersion = Math.max(
  82. maxLocalBaseVersion,
  83. err.info.baseHistoryVersion
  84. )
  85. } else if (!isENOENT(err)) {
  86. logger.warn(
  87. { err, projectId, userId },
  88. 'compile from cache: cannot read history from disk'
  89. )
  90. }
  91. }
  92. }
  93. try {
  94. return await loadSnapshotFromClsiCache(projectId, userId, remoteBaseVersion)
  95. } catch (err) {
  96. if (err instanceof Errors.MissingUpdatesError) {
  97. maxLocalBaseVersion = Math.max(
  98. maxLocalBaseVersion,
  99. err.info.baseHistoryVersion
  100. )
  101. } else if (!isENOENT(err)) {
  102. logger.warn(
  103. { err, projectId, userId },
  104. 'compile from cache: cannot download from clsi-cache'
  105. )
  106. }
  107. }
  108. throw new Errors.MissingUpdatesError('needs more updates', {
  109. baseHistoryVersion: maxLocalBaseVersion,
  110. })
  111. }
  112. /**
  113. * @param {string} projectId
  114. * @param {string} userId
  115. * @param {number} remoteBaseVersion
  116. * @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number}>}
  117. */
  118. async function loadSnapshotFromClsiCache(projectId, userId, remoteBaseVersion) {
  119. const { dir, resyncPath } = snapshotPath(projectId, userId)
  120. await fs.promises.mkdir(dir, { recursive: true })
  121. const ok = await CLSICacheHandler.downloadHistorySnapshot(
  122. projectId,
  123. userId,
  124. dir
  125. )
  126. if (!ok) {
  127. throw new Errors.MissingUpdatesError('needs full sync', {
  128. baseHistoryVersion: -1,
  129. })
  130. }
  131. logger.debug(
  132. { projectId, userId },
  133. 'compile from cache: restored history from clsi-cache'
  134. )
  135. return await loadSnapshotFromFile(resyncPath, remoteBaseVersion, true)
  136. }
  137. /**
  138. * @param {string} path
  139. * @param {number} remoteBaseVersion
  140. * @param {boolean} fullSync
  141. * @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], localBaseVersion: number, fullSync: boolean}>}
  142. */
  143. async function loadSnapshotFromFile(path, remoteBaseVersion, fullSync) {
  144. let blob = await fs.promises.readFile(path)
  145. blob = await gunzip(blob)
  146. const { rawSnapshot, globalBlobs, localBaseVersion } = JSON.parse(
  147. blob.toString('utf-8')
  148. )
  149. if (localBaseVersion < remoteBaseVersion) {
  150. throw new Errors.MissingUpdatesError('missing updates', {
  151. baseHistoryVersion: localBaseVersion,
  152. })
  153. }
  154. return { rawSnapshot, globalBlobs, localBaseVersion, fullSync }
  155. }
  156. /**
  157. * @param {string} projectId
  158. * @param {string} userId
  159. * @param {Snapshot} snapshot
  160. * @param {number} localBaseVersion
  161. * @param {string[]} globalBlobs
  162. * @return {Promise<void>}
  163. */
  164. async function saveSnapshot(
  165. projectId,
  166. userId,
  167. snapshot,
  168. localBaseVersion,
  169. globalBlobs
  170. ) {
  171. const { dir, path } = snapshotPath(projectId, userId)
  172. await fs.promises.mkdir(dir, { recursive: true })
  173. const tmp = path + '~'
  174. await fs.promises.writeFile(
  175. tmp,
  176. await gzip(
  177. JSON.stringify({
  178. globalBlobs,
  179. localBaseVersion,
  180. rawSnapshot: snapshot.toRaw(),
  181. })
  182. ),
  183. { flag: 'wx' }
  184. )
  185. await fs.promises.rename(tmp, path)
  186. }
  187. /**
  188. * @param {string} projectId
  189. * @param {string} userId
  190. * @return {Promise<void>}
  191. */
  192. async function deleteResyncSnapshot(projectId, userId) {
  193. const { resyncPath } = snapshotPath(projectId, userId)
  194. try {
  195. await fs.promises.unlink(resyncPath)
  196. } catch (err) {
  197. if (!isENOENT(err)) {
  198. logger.warn(
  199. { err, projectId, userId },
  200. 'compile from cache: failed to clear history-resync.json.gz'
  201. )
  202. }
  203. }
  204. }
  205. /**
  206. * @param {string} compileDir
  207. * @param {string} subDir
  208. * @param {Map<string, boolean>} entries
  209. * @return {Promise<Map<string, boolean>>}
  210. */
  211. async function discoverExistingEntries(
  212. compileDir,
  213. subDir = '.',
  214. entries = new Map()
  215. ) {
  216. const dirents = await fs.promises.readdir(Path.join(compileDir, subDir), {
  217. withFileTypes: true,
  218. })
  219. for (const dirent of dirents) {
  220. const path = Path.join(subDir, dirent.name)
  221. if (dirent.isDirectory()) {
  222. await discoverExistingEntries(compileDir, path, entries)
  223. } else if (dirent.isFile()) {
  224. entries.set(path, false)
  225. } else if (
  226. dirent.isSymbolicLink() ||
  227. dirent.isFIFO() ||
  228. dirent.isSocket()
  229. ) {
  230. // should not happen, delete right away
  231. logger.warn(
  232. { compileDir, subDir, dirent },
  233. 'compile from cache: found blocked dirent'
  234. )
  235. await fs.promises.unlink(Path.join(compileDir, path))
  236. } else {
  237. throw new OError('unexpected dir entry', { compileDir, subDir, dirent })
  238. }
  239. }
  240. entries.set(subDir, true)
  241. return entries
  242. }
  243. /**
  244. * @param {string} compileDir
  245. * @param {Snapshot} snapshot
  246. * @param {Map<string, boolean>} entriesDepthFirst
  247. */
  248. async function removeExtraneousEntries(
  249. compileDir,
  250. snapshot,
  251. entriesDepthFirst
  252. ) {
  253. const keepFolders = new Set(['.'])
  254. for (const [path, isDir] of entriesDepthFirst) {
  255. const shouldBeFile = !!snapshot.getFile(path)
  256. if (isDir) {
  257. if (!shouldBeFile) {
  258. // directory can stay directory
  259. if (keepFolders.has(path)) {
  260. // folder is still in use
  261. keepFolders.add(Path.dirname(path))
  262. } else {
  263. // empty folder
  264. await fs.promises.rmdir(Path.join(compileDir, path))
  265. entriesDepthFirst.delete(path)
  266. }
  267. continue
  268. }
  269. // a folder turned into a file
  270. // before: foo/bar.txt/baz.txt
  271. // ^^^^^^^ folder
  272. // now: foo/bar.txt
  273. // ^^^^^^^ file
  274. const needle = path + '/'
  275. for (const [child, childIsDir] of entriesDepthFirst) {
  276. if (!child.startsWith(needle)) continue
  277. if (childIsDir) {
  278. await fs.promises.rmdir(Path.join(compileDir, child))
  279. } else {
  280. await fs.promises.unlink(Path.join(compileDir, child))
  281. }
  282. entriesDepthFirst.delete(child)
  283. }
  284. await fs.promises.rmdir(Path.join(compileDir, path))
  285. entriesDepthFirst.delete(path)
  286. continue
  287. }
  288. if (shouldBeFile || !ResourceWriter.isExtraneousFile(path)) {
  289. // resource or cached file
  290. keepFolders.add(Path.dirname(path))
  291. continue
  292. }
  293. await fs.promises.unlink(Path.join(compileDir, path))
  294. entriesDepthFirst.delete(path)
  295. }
  296. }
  297. /**
  298. * @param {string} compileDir
  299. * @param {string} path
  300. * @param {Map<string, boolean>} entriesDepthFirst
  301. */
  302. async function ensureHasParentFolder(compileDir, path, entriesDepthFirst) {
  303. const parentFolderPath = Path.dirname(path)
  304. if (entriesDepthFirst.has(parentFolderPath)) return
  305. await ensureHasParentFolder(compileDir, parentFolderPath, entriesDepthFirst)
  306. await fs.promises.mkdir(Path.join(compileDir, parentFolderPath))
  307. entriesDepthFirst.set(parentFolderPath, true)
  308. }
  309. /**
  310. * @param {import('overleaf-editor-core/lib/types.js').RawOperation[][]} raw
  311. * @return {Change[]}
  312. */
  313. function changesFromRawChangeOperations(raw) {
  314. return raw.map(o => Change.mustFromRaw({ operations: o, timestamp: '0' }))
  315. }
  316. /**
  317. * @param {string} projectId
  318. * @param {string} userId
  319. * @param {Object} request
  320. * @param {string} compileDir
  321. * @param {Record<string, number>} timings
  322. * @return {Promise<{baseHistoryVersion: number, resourceList: {path: string}[]}>}
  323. */
  324. export async function syncResourcesToDisk(
  325. projectId,
  326. userId,
  327. request,
  328. compileDir,
  329. timings
  330. ) {
  331. const remoteBaseVersion = request.baseHistoryVersion
  332. let rawSnapshot, globalBlobs, localBaseVersion, source
  333. let fullSync = true
  334. try {
  335. ;({ rawSnapshot, globalBlobs, fullSync, localBaseVersion } =
  336. await loadSnapshot(projectId, userId, remoteBaseVersion))
  337. source = fullSync ? 'clsi-cache' : 'local'
  338. logger.debug(
  339. { projectId, userId, localBaseVersion, remoteBaseVersion },
  340. 'compile from cache: using existing snapshot'
  341. )
  342. } catch (err) {
  343. if (!request.rawSnapshot) throw err
  344. if (!(err instanceof Errors.MissingUpdatesError)) {
  345. logger.warn(
  346. { err, projectId, userId },
  347. 'compile from cache: bad local history state during full resync'
  348. )
  349. }
  350. logger.debug(
  351. { projectId, userId },
  352. 'compile from cache: using incoming snapshot'
  353. )
  354. source = 'remote'
  355. localBaseVersion = remoteBaseVersion
  356. rawSnapshot = request.rawSnapshot
  357. globalBlobs = []
  358. }
  359. globalBlobs = Array.from(new Set(globalBlobs.concat(request.globalBlobs)))
  360. const snapshot = Snapshot.fromRaw(rawSnapshot)
  361. const changes = changesFromRawChangeOperations(
  362. request.rawChangeOperations.slice(localBaseVersion - remoteBaseVersion)
  363. )
  364. const applyAllStart = performance.now()
  365. snapshot.applyAll(changes)
  366. timings.snapshotApplyAll = Math.ceil(performance.now() - applyAllStart)
  367. if (!ClsiMetrics.shouldSkipMetrics(request)) {
  368. ClsiMetrics.snapshotApplyAllDurationSeconds.observe(
  369. { group: request.compileGroup, source },
  370. timings.snapshotApplyAll / 1_000
  371. )
  372. }
  373. const entriesDepthFirst = await discoverExistingEntries(compileDir)
  374. await removeExtraneousEntries(compileDir, snapshot, entriesDepthFirst)
  375. const changedPaths = []
  376. if (fullSync) {
  377. changedPaths.push(...snapshot.getFilePathnames())
  378. logger.debug({ projectId, userId }, 'compile from cache: full sync')
  379. } else {
  380. const dedupe = new Set()
  381. for (const change of changes) {
  382. for (const operation of change.getOperations()) {
  383. if (operation instanceof AddFileOperation) {
  384. dedupe.add(operation.pathname)
  385. } else if (operation instanceof MoveFileOperation) {
  386. dedupe.add(operation.pathname)
  387. if (!operation.isRemoveFile()) dedupe.add(operation.newPathname)
  388. } else if (operation instanceof EditFileOperation) {
  389. dedupe.add(operation.pathname)
  390. }
  391. }
  392. }
  393. // Restore deleted files
  394. for (const path of snapshot.getFilePathnames()) {
  395. if (!entriesDepthFirst.has(path)) dedupe.add(path)
  396. }
  397. changedPaths.push(...dedupe)
  398. logger.debug(
  399. { projectId, userId, changedPaths },
  400. 'compile from cache: incremental sync'
  401. )
  402. }
  403. const blobStore = new BlobStore(request.historyId, globalBlobs)
  404. const loadEagerStart = performance.now()
  405. await snapshot.loadFiles('eager', blobStore)
  406. timings.snapshotLoadEager = Math.ceil(performance.now() - loadEagerStart)
  407. if (!ClsiMetrics.shouldSkipMetrics(request)) {
  408. ClsiMetrics.snapshotLoadEagerDurationSeconds.observe(
  409. { group: request.compileGroup, source },
  410. timings.snapshotLoadEager / 1_000
  411. )
  412. }
  413. for (const path of changedPaths) {
  414. const file = snapshot.getFile(path)
  415. if (!file) continue // deleted, handled by removeExtraneousEntries
  416. await ensureHasParentFolder(compileDir, path, entriesDepthFirst)
  417. }
  418. let createCacheFolder
  419. // Use Promise.allSettled to ensure that all writes have stopped when we exit.
  420. const allDone = await promiseMapSettledWithLimit(
  421. Settings.parallelFileDownloads,
  422. changedPaths,
  423. async path => {
  424. const file = snapshot.getFile(path)
  425. if (!file) return // deleted, handled by removeExtraneousEntries
  426. const content = file.getContent({ filterTrackedDeletes: true })
  427. if (typeof content === 'string') {
  428. await fs.promises.writeFile(
  429. Path.join(compileDir, path),
  430. content,
  431. 'utf-8'
  432. )
  433. } else {
  434. const hash = file.getHash()
  435. if (!hash) {
  436. throw new OError('unexpected file without content and hash', { path })
  437. }
  438. const fallbackURL = null // no fallback
  439. const lastModified = new Date(0) // content is static
  440. if (!createCacheFolder) {
  441. createCacheFolder = UrlCache.promises.createProjectDir(projectId)
  442. }
  443. await createCacheFolder
  444. await UrlCache.promises.downloadUrlToFile(
  445. projectId,
  446. blobStore.getBlobURL(hash).href,
  447. fallbackURL,
  448. Path.join(compileDir, path),
  449. lastModified
  450. )
  451. }
  452. }
  453. )
  454. for (const [idx, result] of allDone.entries()) {
  455. if (result.status === 'fulfilled') continue
  456. const path = changedPaths[idx]
  457. throw OError.tag(result.reason, 'write failed', { path })
  458. }
  459. const baseHistoryVersion = localBaseVersion + changes.length
  460. if (fullSync || changes.length) {
  461. await saveSnapshot(
  462. projectId,
  463. userId,
  464. snapshot,
  465. baseHistoryVersion,
  466. globalBlobs
  467. )
  468. }
  469. if (fullSync) {
  470. await deleteResyncSnapshot(projectId, userId)
  471. }
  472. return {
  473. baseHistoryVersion,
  474. resourceList: snapshot.getFilePathnames().map(path => ({ path })),
  475. }
  476. }
  477. class BlobStore {
  478. /** @type {string} */
  479. #historyId
  480. /** @type {string[]} */
  481. #globalBlobs
  482. /**
  483. * @param {string} historyId
  484. * @param {string[]} globalBlobs
  485. */
  486. constructor(historyId, globalBlobs) {
  487. this.#historyId = historyId
  488. this.#globalBlobs = globalBlobs
  489. }
  490. /**
  491. * @param {string} hash
  492. * @return {URL}
  493. */
  494. getBlobURL(hash) {
  495. const u = new URL(Settings.apis.filestore.url)
  496. if (this.#globalBlobs.includes(hash)) {
  497. u.pathname = `/history/global/hash/${hash}`
  498. } else {
  499. u.pathname = `/history/project/${this.#historyId}/hash/${hash}`
  500. }
  501. return u
  502. }
  503. /**
  504. * @param {string} hash
  505. * @return {Promise<string>}
  506. */
  507. async getString(hash) {
  508. if (hash === File.EMPTY_FILE_HASH) return ''
  509. const u = this.getBlobURL(hash)
  510. let remainingAttempts = 3
  511. while (true) {
  512. try {
  513. return await fetchString(u, { signal: AbortSignal.timeout(3_000) })
  514. } catch (err) {
  515. if (err instanceof RequestFailedError && err.response.status === 404) {
  516. throw new Errors.NotFoundError()
  517. }
  518. remainingAttempts--
  519. if (remainingAttempts <= 0) throw err
  520. logger.warn(
  521. { err, url: u.href, remainingAttempts },
  522. 'compile from cache: history blob download failed'
  523. )
  524. await setTimeout(100)
  525. }
  526. }
  527. }
  528. /**
  529. * @param {string} hash
  530. * @return {Promise<any>}
  531. */
  532. async getObject(hash) {
  533. const string = await this.getString(hash)
  534. return JSON.parse(string)
  535. }
  536. }