HistoryResourceWriter.js 18 KB

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