HistoryResourceWriter.js 19 KB

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