| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649 |
- // @ts-check
- import logger from '@overleaf/logger'
- import zlib from 'node:zlib'
- import Settings from '@overleaf/settings'
- import Path from 'node:path'
- import fs from 'node:fs'
- import CLSICacheHandler from './CLSICacheHandler.js'
- import Errors from './Errors.js'
- import { callbackify, promisify } from 'node:util'
- import {
- AddFileOperation,
- Change,
- EditFileOperation,
- File,
- MoveFileOperation,
- Snapshot,
- } from 'overleaf-editor-core'
- import { fetchString, RequestFailedError } from '@overleaf/fetch-utils'
- import { setTimeout } from 'node:timers/promises'
- import ResourceWriter from './ResourceWriter.js'
- import UrlCache from './UrlCache.js'
- import OError from '@overleaf/o-error'
- import ClsiMetrics from './Metrics.js'
- import { promiseMapSettledWithLimit } from '@overleaf/promise-utils'
- import Metrics from '@overleaf/metrics'
- import TikzManager from './TikzManager.js'
- import DraftModeManager from './DraftModeManager.js'
- const gzip = promisify(zlib.gzip)
- const gunzip = promisify(zlib.gunzip)
- export const clearCacheCb = callbackify(clearCache)
- /**
- * @param {string} projectId
- * @param {string} userId
- * @param {string} cacheKey
- * @return {Promise<void>}
- */
- export async function clearCache(projectId, userId, cacheKey) {
- const { dir } = snapshotPath(cacheKey)
- try {
- await fs.promises.rm(dir, { recursive: true, force: true })
- } catch (err) {
- if (isENOENT(err)) return
- logger.warn(
- { err, projectId, userId, cacheKey },
- 'compile from cache: failed to clear history cache'
- )
- }
- }
- /**
- * @param {string} cacheKey
- * @return {{ dir: string, path: string, resyncPath: string }}
- */
- function snapshotPath(cacheKey) {
- const dir = Path.join(Settings.path.clsiCacheDir, cacheKey)
- const path = Path.join(dir, 'history.json.gz')
- const resyncPath = Path.join(dir, 'history-resync.json.gz')
- return { dir, path, resyncPath }
- }
- /**
- * @param {unknown} err
- * @return {boolean}
- */
- function isENOENT(err) {
- return err instanceof Error && 'code' in err && err.code === 'ENOENT'
- }
- /**
- * @param {string} projectId
- * @param {string} userId
- * @param {string} cacheKey
- * @param {number} remoteBaseVersion
- * @param {boolean} populateClsiCache
- * @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number, dirty: string[]}>}
- */
- async function loadSnapshot(
- projectId,
- userId,
- cacheKey,
- remoteBaseVersion,
- populateClsiCache
- ) {
- const { path, resyncPath } = snapshotPath(cacheKey)
- let maxLocalBaseVersion = -1
- for (const candidate of [path, resyncPath]) {
- try {
- const fullSync = candidate === resyncPath
- return await loadSnapshotFromFile(candidate, remoteBaseVersion, fullSync)
- } catch (err) {
- if (err instanceof Errors.MissingUpdatesError) {
- maxLocalBaseVersion = Math.max(
- maxLocalBaseVersion,
- err.info.baseHistoryVersion
- )
- } else if (!isENOENT(err)) {
- logger.warn(
- { err, projectId, userId, cacheKey },
- 'compile from cache: cannot read history from disk'
- )
- }
- }
- }
- if (populateClsiCache) {
- try {
- return await loadSnapshotFromClsiCache(
- projectId,
- userId,
- cacheKey,
- remoteBaseVersion
- )
- } catch (err) {
- if (err instanceof Errors.MissingUpdatesError) {
- maxLocalBaseVersion = Math.max(
- maxLocalBaseVersion,
- err.info.baseHistoryVersion
- )
- } else if (!isENOENT(err)) {
- logger.warn(
- { err, projectId, userId, cacheKey },
- 'compile from cache: cannot download from clsi-cache'
- )
- }
- }
- }
- throw new Errors.MissingUpdatesError('needs more updates', {
- baseHistoryVersion: maxLocalBaseVersion,
- })
- }
- /**
- * @param {string} projectId
- * @param {string} userId
- * @param {string} cacheKey
- * @param {number} remoteBaseVersion
- * @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], fullSync: boolean,localBaseVersion: number, dirty: string[]}>}
- */
- async function loadSnapshotFromClsiCache(
- projectId,
- userId,
- cacheKey,
- remoteBaseVersion
- ) {
- const { dir, resyncPath } = snapshotPath(cacheKey)
- await fs.promises.mkdir(dir, { recursive: true })
- const ok = await CLSICacheHandler.downloadHistorySnapshot(
- projectId,
- userId,
- dir
- )
- if (!ok) {
- throw new Errors.MissingUpdatesError('needs full sync', {
- baseHistoryVersion: -1,
- })
- }
- logger.debug(
- { projectId, userId },
- 'compile from cache: restored history from clsi-cache'
- )
- return await loadSnapshotFromFile(resyncPath, remoteBaseVersion, true)
- }
- /**
- * @param {string} path
- * @param {number} remoteBaseVersion
- * @param {boolean} fullSync
- * @return {Promise<{rawSnapshot: import('overleaf-editor-core/lib/types.js').RawSnapshot, globalBlobs: string[], localBaseVersion: number, fullSync: boolean, dirty: string[]}>}
- */
- async function loadSnapshotFromFile(path, remoteBaseVersion, fullSync) {
- let blob = await fs.promises.readFile(path)
- blob = await gunzip(blob)
- const {
- rawSnapshot,
- globalBlobs,
- localBaseVersion,
- dirty = [], // added later, provide a default value.
- } = JSON.parse(blob.toString('utf-8'))
- if (localBaseVersion < remoteBaseVersion) {
- throw new Errors.MissingUpdatesError('missing updates', {
- baseHistoryVersion: localBaseVersion,
- })
- }
- return { rawSnapshot, globalBlobs, localBaseVersion, fullSync, dirty }
- }
- /**
- * @param {string} cacheKey
- * @param {Snapshot} snapshot
- * @param {number} localBaseVersion
- * @param {string[]} globalBlobs
- * @param {string[]} dirty
- * @return {Promise<void>}
- */
- async function saveSnapshot(
- cacheKey,
- snapshot,
- localBaseVersion,
- globalBlobs,
- dirty
- ) {
- const { dir, path } = snapshotPath(cacheKey)
- await fs.promises.mkdir(dir, { recursive: true })
- const tmp = path + '~'
- await fs.promises.writeFile(
- tmp,
- await gzip(
- JSON.stringify({
- globalBlobs,
- localBaseVersion,
- rawSnapshot: snapshot.toRaw(),
- dirty,
- }),
- // use cheapest gzip compression level
- { level: 1 }
- ),
- { flag: 'wx' }
- )
- await fs.promises.rename(tmp, path)
- }
- /**
- * @param {string} projectId
- * @param {string} userId
- * @param {string} cacheKey
- * @return {Promise<void>}
- */
- async function deleteResyncSnapshot(projectId, userId, cacheKey) {
- const { resyncPath } = snapshotPath(cacheKey)
- try {
- await fs.promises.unlink(resyncPath)
- } catch (err) {
- if (!isENOENT(err)) {
- logger.warn(
- { err, projectId, userId, cacheKey },
- 'compile from cache: failed to clear history-resync.json.gz'
- )
- }
- }
- }
- /**
- * @param {string} compileDir
- * @param {string} subDir
- * @param {Map<string, boolean>} entries
- * @return {Promise<Map<string, boolean>>}
- */
- async function discoverExistingEntries(
- compileDir,
- subDir = '.',
- entries = new Map()
- ) {
- const dirents = await fs.promises.readdir(Path.join(compileDir, subDir), {
- withFileTypes: true,
- })
- for (const dirent of dirents) {
- const path = Path.join(subDir, dirent.name)
- if (dirent.isDirectory()) {
- await discoverExistingEntries(compileDir, path, entries)
- } else if (dirent.isFile()) {
- entries.set(path, false)
- } else if (
- dirent.isSymbolicLink() ||
- dirent.isFIFO() ||
- dirent.isSocket()
- ) {
- // should not happen, delete right away
- logger.warn(
- { compileDir, subDir, dirent },
- 'compile from cache: found blocked dirent'
- )
- await fs.promises.unlink(Path.join(compileDir, path))
- } else {
- throw new OError('unexpected dir entry', { compileDir, subDir, dirent })
- }
- }
- entries.set(subDir, true)
- return entries
- }
- /**
- * @param {string} compileDir
- * @param {Snapshot} snapshot
- * @param {Map<string, boolean>} entriesDepthFirst
- */
- async function removeExtraneousEntries(
- compileDir,
- snapshot,
- entriesDepthFirst
- ) {
- const keepFolders = new Set(['.'])
- for (const [path, isDir] of entriesDepthFirst) {
- const shouldBeFile = !!snapshot.getFile(path)
- if (isDir) {
- if (!shouldBeFile) {
- // directory can stay directory
- if (keepFolders.has(path)) {
- // folder is still in use
- keepFolders.add(Path.dirname(path))
- } else {
- // empty folder
- await fs.promises.rmdir(Path.join(compileDir, path))
- entriesDepthFirst.delete(path)
- }
- continue
- }
- // a folder turned into a file
- // before: foo/bar.txt/baz.txt
- // ^^^^^^^ folder
- // now: foo/bar.txt
- // ^^^^^^^ file
- const needle = path + '/'
- for (const [child, childIsDir] of entriesDepthFirst) {
- if (!child.startsWith(needle)) continue
- if (childIsDir) {
- await fs.promises.rmdir(Path.join(compileDir, child))
- } else {
- await fs.promises.unlink(Path.join(compileDir, child))
- }
- entriesDepthFirst.delete(child)
- }
- await fs.promises.rmdir(Path.join(compileDir, path))
- entriesDepthFirst.delete(path)
- continue
- }
- if (shouldBeFile || !ResourceWriter.isExtraneousFile(path)) {
- // resource or cached file
- keepFolders.add(Path.dirname(path))
- continue
- }
- await fs.promises.unlink(Path.join(compileDir, path))
- entriesDepthFirst.delete(path)
- }
- }
- /**
- * @param {string} compileDir
- * @param {string} path
- * @param {Map<string, boolean>} entriesDepthFirst
- */
- async function ensureHasParentFolder(compileDir, path, entriesDepthFirst) {
- const parentFolderPath = Path.dirname(path)
- if (entriesDepthFirst.has(parentFolderPath)) return
- await ensureHasParentFolder(compileDir, parentFolderPath, entriesDepthFirst)
- await fs.promises.mkdir(Path.join(compileDir, parentFolderPath))
- entriesDepthFirst.set(parentFolderPath, true)
- }
- /**
- * @param {import('overleaf-editor-core/lib/types.js').RawOperation[][]} raw
- * @return {Change[]}
- */
- function changesFromRawChangeOperations(raw) {
- return raw.map(o => Change.mustFromRaw({ operations: o, timestamp: '0' }))
- }
- /**
- * @param {string} projectId
- * @param {string} userId
- * @param {Object} request
- * @param {string} compileDir
- * @param {Record<string, number>} timings
- * @return {Promise<{baseHistoryVersion: number, resourceList: {path: string}[]}>}
- */
- export async function syncResourcesToDisk(
- projectId,
- userId,
- request,
- compileDir,
- timings
- ) {
- // - logged in user: <project-id>-<user-id>
- // - anonymous user: <project-id>
- // - conversion job: <uuid>
- const cacheKey = Path.basename(compileDir)
- const remoteBaseVersion = request.baseHistoryVersion
- let rawSnapshot, globalBlobs, localBaseVersion, source, dirty, fullSync
- try {
- ;({ rawSnapshot, globalBlobs, fullSync, localBaseVersion, dirty } =
- await loadSnapshot(
- projectId,
- userId,
- cacheKey,
- remoteBaseVersion,
- request.populateClsiCache
- ))
- source = fullSync ? 'clsi-cache' : 'local'
- logger.debug(
- { projectId, userId, cacheKey, localBaseVersion, remoteBaseVersion },
- 'compile from cache: using existing snapshot'
- )
- } catch (err) {
- if (!request.rawSnapshot) throw err
- if (!(err instanceof Errors.MissingUpdatesError)) {
- logger.warn(
- { err, projectId, userId, cacheKey },
- 'compile from cache: bad local history state during full resync'
- )
- }
- logger.debug(
- { projectId, userId, cacheKey },
- 'compile from cache: using incoming snapshot'
- )
- source = 'remote'
- localBaseVersion = remoteBaseVersion
- rawSnapshot = request.rawSnapshot
- globalBlobs = []
- dirty = []
- fullSync = true
- }
- globalBlobs = Array.from(new Set(globalBlobs.concat(request.globalBlobs)))
- const snapshot = Snapshot.fromRaw(rawSnapshot)
- const changes = changesFromRawChangeOperations(
- request.rawChangeOperations.slice(localBaseVersion - remoteBaseVersion)
- )
- const applyAllStart = performance.now()
- snapshot.applyAll(changes)
- timings.snapshotApplyAll = Math.ceil(performance.now() - applyAllStart)
- if (!ClsiMetrics.shouldSkipMetrics(request)) {
- ClsiMetrics.snapshotApplyAllDurationSeconds.observe(
- { group: request.compileGroup, source },
- timings.snapshotApplyAll / 1_000
- )
- }
- const entriesDepthFirst = await discoverExistingEntries(compileDir)
- await removeExtraneousEntries(compileDir, snapshot, entriesDepthFirst)
- const changedPaths = []
- if (fullSync) {
- changedPaths.push(...snapshot.getFilePathnames())
- logger.debug(
- { projectId, userId, cacheKey },
- 'compile from cache: full sync'
- )
- } else {
- const dedupe = new Set(dirty)
- if (request.draft) {
- dedupe.add(request.rootResourcePath)
- }
- for (const change of changes) {
- for (const operation of change.getOperations()) {
- if (operation instanceof AddFileOperation) {
- dedupe.add(operation.pathname)
- } else if (operation instanceof MoveFileOperation) {
- dedupe.add(operation.pathname)
- if (!operation.isRemoveFile()) dedupe.add(operation.newPathname)
- } else if (operation instanceof EditFileOperation) {
- dedupe.add(operation.pathname)
- }
- }
- }
- // Restore deleted files
- for (const path of snapshot.getFilePathnames()) {
- if (!entriesDepthFirst.has(path)) dedupe.add(path)
- }
- changedPaths.push(...dedupe)
- logger.debug(
- { projectId, userId, cacheKey, changedPaths },
- 'compile from cache: incremental sync'
- )
- }
- const blobStore = new BlobStore(
- request.historyId,
- request.filestoreBlobPrefix,
- request.clsiPerfVariant,
- globalBlobs
- )
- const loadEagerStart = performance.now()
- await snapshot.loadFiles('eager', blobStore)
- timings.snapshotLoadEager = Math.ceil(performance.now() - loadEagerStart)
- if (!ClsiMetrics.shouldSkipMetrics(request)) {
- ClsiMetrics.snapshotLoadEagerDurationSeconds.observe(
- { group: request.compileGroup, source },
- timings.snapshotLoadEager / 1_000
- )
- }
- for (const path of changedPaths) {
- const file = snapshot.getFile(path)
- if (!file) continue // deleted, handled by removeExtraneousEntries
- await ensureHasParentFolder(compileDir, path, entriesDepthFirst)
- }
- const wasDirty = dirty.length > 0
- dirty = []
- let createCacheFolder
- // Use Promise.allSettled to ensure that all writes have stopped when we exit.
- const allDone = await promiseMapSettledWithLimit(
- Settings.parallelFileDownloads,
- changedPaths,
- async path => {
- const file = snapshot.getFile(path)
- if (!file) return // deleted, handled by removeExtraneousEntries
- let content = file.getContent({ filterTrackedDeletes: true })
- if (typeof content === 'string') {
- if (path === request.rootResourcePath) {
- if (request.draft) {
- content = DraftModeManager.PREFIX + content
- dirty.push(path)
- }
- await TikzManager.writeOutputFileIfNeeded(
- compileDir,
- snapshot,
- content
- )
- }
- await fs.promises.writeFile(
- Path.join(compileDir, path),
- content,
- 'utf-8'
- )
- } else {
- const hash = file.getHash()
- if (!hash) {
- throw new OError('unexpected file without content and hash', { path })
- }
- if (!createCacheFolder) {
- createCacheFolder = UrlCache.promises.createProjectDir(projectId)
- }
- await createCacheFolder
- const url = blobStore.getBlobURL(hash).href
- try {
- const fallbackURL = null // no fallback
- const lastModified = new Date(0) // content is static
- await UrlCache.promises.downloadUrlToFile(
- projectId,
- url,
- fallbackURL,
- Path.join(compileDir, path),
- lastModified
- )
- } catch (err) {
- logger.err(
- { err, projectId, path, resourceUrl: url },
- 'error downloading file for resources'
- )
- Metrics.inc('download-failed')
- }
- }
- }
- )
- for (const [idx, result] of allDone.entries()) {
- if (result.status === 'fulfilled') continue
- const path = changedPaths[idx]
- throw OError.tag(result.reason, 'write failed', { path })
- }
- const baseHistoryVersion = localBaseVersion + changes.length
- if (fullSync || changes.length || wasDirty || dirty.length) {
- await saveSnapshot(
- cacheKey,
- snapshot,
- baseHistoryVersion,
- globalBlobs,
- dirty
- )
- }
- if (fullSync) {
- await deleteResyncSnapshot(projectId, userId, cacheKey)
- }
- return {
- baseHistoryVersion,
- resourceList: snapshot.getFilePathnames().map(path => ({ path })),
- }
- }
- class BlobStore {
- /** @type {string} */
- #historyId
- /** @type {string[]} */
- #globalBlobs
- /** @type {string} */
- #filestoreBlobPrefix
- /** @type {string} */
- #clsiPerfVariant
- /**
- * @param {string} historyId
- * @param {string} filestoreBlobPrefix
- * @param {string} clsiPerfVariant
- * @param {string[]} globalBlobs
- */
- constructor(historyId, filestoreBlobPrefix, clsiPerfVariant, globalBlobs) {
- this.#historyId = historyId
- this.#filestoreBlobPrefix = filestoreBlobPrefix
- this.#clsiPerfVariant = clsiPerfVariant
- this.#globalBlobs = globalBlobs
- }
- /**
- * @param {string} hash
- * @return {URL}
- */
- getBlobURL(hash) {
- const u = new URL(Settings.apis.filestore.url)
- if (this.#filestoreBlobPrefix) {
- u.pathname = `${this.#filestoreBlobPrefix}/${hash}`
- } else if (this.#clsiPerfVariant) {
- u.host = Settings.apis.clsiPerf.host
- u.pathname = `/variant/${this.#clsiPerfVariant}/hash/${hash}`
- } else if (this.#globalBlobs.includes(hash)) {
- u.pathname = `/history/global/hash/${hash}`
- } else {
- u.pathname = `/history/project/${this.#historyId}/hash/${hash}`
- }
- return u
- }
- /**
- * @param {string} hash
- * @return {Promise<string>}
- */
- async getString(hash) {
- if (hash === File.EMPTY_FILE_HASH) return ''
- const u = this.getBlobURL(hash)
- let remainingAttempts = 3
- while (true) {
- try {
- return await fetchString(u, { signal: AbortSignal.timeout(3_000) })
- } catch (err) {
- if (err instanceof RequestFailedError && err.response.status === 404) {
- throw new Errors.NotFoundError()
- }
- remainingAttempts--
- if (remainingAttempts <= 0) throw err
- logger.warn(
- { err, url: u.href, remainingAttempts },
- 'compile from cache: history blob download failed'
- )
- await setTimeout(100)
- }
- }
- }
- /**
- * @param {string} hash
- * @return {Promise<any>}
- */
- async getObject(hash) {
- const string = await this.getString(hash)
- return JSON.parse(string)
- }
- }
|