HistoryResourceWriter.js 18 KB

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