HistoryResourceWriter.js 17 KB

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