[web] make clsi-cache a premium feature (#25075)

* [web] stopOnFirstError=true does not conflict with =false locally

Allow stopOnFirstError to be enabled in the compile from cache and
disabled locally.
Compiles that passed with stopOnFirstError=true will also pass with
stopOnFirstError=false. The inverse does not hold, and we need to
recompile.

* [web] record event when using compile from cache

* [web] record event when falling back to clsi-cache

* [web] make clsi-cache a premium feature

* [k8s] clsi-cache: increase disk size for beta rollout

NOTE: As this is a premium feature and paid servers run in zones c+d, we
do not need to scale up clsi-cache in zone b for now.

* [web] enable full sampling of compile-result-backend events

* [web] fix frontend tests

* [web] be more verbose when determining access to clsi-cache feature

GitOrigin-RevId: 6fd663e16085187876eb225f7f33eeeaf69d2b2a
This commit is contained in:
Jakob Ackermann
2025-04-25 08:05:33 +00:00
committed by Copybot
parent 13270dee2d
commit 8ed650f57a
11 changed files with 61 additions and 19 deletions
@@ -3,6 +3,7 @@ const ClsiCacheHandler = require('./ClsiCacheHandler')
const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
const ProjectGetter = require('../Project/ProjectGetter')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const UserGetter = require('../User/UserGetter')
/**
* Get the most recent build and metadata
@@ -66,6 +67,10 @@ async function prepareClsiCache(
'copy-clsi-cache'
)
if (variant !== 'enabled') return
const features = await UserGetter.promises.getUserFeatures(userId)
if (features.compileGroup !== 'priority') return
const signal = AbortSignal.timeout(5_000)
let lastUpdated
let zone = 'b' // populate template data on zone b
@@ -784,8 +784,13 @@ function _finaliseRequest(projectId, options, project, docs, files) {
syncType: options.syncType,
syncState: options.syncState,
compileGroup: options.compileGroup,
compileFromClsiCache: options.compileFromClsiCache,
populateClsiCache: options.populateClsiCache,
// Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache.
compileFromClsiCache:
['alpha', 'priority'].includes(options.compileGroup) &&
options.compileFromClsiCache,
populateClsiCache:
['alpha', 'priority'].includes(options.compileGroup) &&
options.populateClsiCache,
enablePdfCaching:
(Settings.enablePdfCaching && options.enablePdfCaching) || false,
pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
@@ -203,14 +203,7 @@ module.exports = CompileController = {
pdfDownloadDomain += outputUrlPrefix
}
if (
limits &&
SplitTestHandler.getPercentile(
AnalyticsManager.getIdsFromSession(req.session).analyticsId,
'compile-result-backend',
'release'
) === 1
) {
if (limits) {
// For a compile request to be sent to clsi we need limits.
// If we get here without having the limits object populated, it is
// a reasonable assumption to make that nothing was compiled.
@@ -558,10 +558,11 @@ const _ProjectController = {
.catch(err =>
logger.error({ err }, 'failed to update split test info in session')
)
const ownerFeatures = await UserGetter.promises.getUserFeatures(
project.owner_ref
)
if (userId) {
const ownerFeatures = await UserGetter.promises.getUserFeatures(
project.owner_ref
)
const planLimit = ownerFeatures?.collaborators || 0
const namedEditors = project.collaberator_refs?.length || 0
const pendingEditors = project.pendingEditor_refs?.length || 0
@@ -798,6 +799,8 @@ const _ProjectController = {
bodyClasses: ['editor'],
project_id: project._id,
projectName: project.name,
projectOwnerHasPremiumOnPageLoad:
ownerFeatures?.compileGroup === 'priority',
user: {
id: userId,
email: user.email,
@@ -1,5 +1,6 @@
meta(name="ol-project_id" content=project_id)
meta(name="ol-projectName" content=projectName)
meta(name="ol-projectOwnerHasPremiumOnPageLoad" data-type="boolean" content=projectOwnerHasPremiumOnPageLoad)
meta(name="ol-userSettings" data-type="json" content=userSettings)
meta(name="ol-user" data-type="json" content=user)
meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
@@ -26,4 +26,8 @@ export const prefetchingEnabled = isFlagEnabled('pdf-caching-prefetching')
export const prefetchLargeEnabled = isFlagEnabled('pdf-caching-prefetch-large')
export const enablePdfCaching = isFlagEnabled('pdf-caching-mode')
export const trackPdfDownloadEnabled = isFlagEnabled('track-pdf-download')
export const useClsiCache = isFlagEnabled('fall-back-to-clsi-cache')
export const projectOwnerHasPremiumOnPageLoad = getMeta(
'ol-projectOwnerHasPremiumOnPageLoad'
)
export const fallBackToClsiCache =
projectOwnerHasPremiumOnPageLoad && isFlagEnabled('fall-back-to-clsi-cache')
@@ -8,11 +8,13 @@ import {
prefetchingEnabled,
prefetchLargeEnabled,
trackPdfDownloadEnabled,
useClsiCache,
fallBackToClsiCache,
} from './pdf-caching-flags'
import { isNetworkError } from '@/utils/is-network-error'
import { debugConsole } from '@/utils/debugging'
import { PDFJS } from './pdf-js'
import { sendMB } from '@/infrastructure/event-tracking'
import getMeta from '@/utils/meta'
// 30 seconds: The shutdown grace period of a clsi pre-emp instance.
const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000
@@ -22,6 +24,7 @@ export function generatePdfCachingTransportFactory() {
if (!enablePdfCaching && !trackPdfDownloadEnabled) {
return () => undefined
}
const projectId = getMeta('ol-project_id')
const usageScore = new Map()
const cachedUrls = new Map()
const metrics = Object.assign(getPdfCachingMetrics(), {
@@ -59,6 +62,7 @@ export function generatePdfCachingTransportFactory() {
this.handleFetchError = handleFetchError
this.abortController = abortController
this.startTime = performance.now()
this.sentEventFallbackToClsiCache = false
const params = new URL(url).searchParams
// drop no needed params
@@ -72,6 +76,7 @@ export function generatePdfCachingTransportFactory() {
}
requestDataRange(start, end) {
let recordFallbackToClsiCache = false
const abortSignal = this.abortController.signal
const getDebugInfo = () => ({
// Sentry does not serialize objects in twice nested objects.
@@ -115,7 +120,7 @@ export function generatePdfCachingTransportFactory() {
)
}
const canTryFromCache = err => {
if (!useClsiCache) return false
if (!fallBackToClsiCache) return false
if (!is404(err)) return false
return !usesCache(OError.getFullInfo(err).url)
}
@@ -146,6 +151,7 @@ export function generatePdfCachingTransportFactory() {
this.pdfFile.ranges = this.pdfFile.ranges.filter(r =>
cachedUrls.has(r.hash)
)
recordFallbackToClsiCache = true
return blob
})
.catch(err => {
@@ -177,6 +183,9 @@ export function generatePdfCachingTransportFactory() {
abortSignal,
canTryFromCache,
fallbackToCacheURL: getOutputPDFURLFromCache(),
recordFallbackToClsiCache: () => {
recordFallbackToClsiCache = true
},
})
.catch(err => {
if (abortSignal.aborted) return
@@ -228,6 +237,14 @@ export function generatePdfCachingTransportFactory() {
})
.then(blob => {
if (abortSignal.aborted) return
if (recordFallbackToClsiCache && !this.sentEventFallbackToClsiCache) {
// Record once per PDF preview. Technically we should record once per 90min (output cache age), but keep it simple for now.
this.sentEventFallbackToClsiCache = true
sendMB('fallback-to-clsi-cache', {
projectId,
ageMS: Math.ceil(performance.now() - this.startTime),
})
}
this.onDataRange(start, blob)
})
.catch(err => {
@@ -593,6 +593,7 @@ function skipPrefetched(chunks, prefetched, start, end) {
* @param {() => boolean} canTryFromCache
* @param {string} fallbackToCacheURL
* @param {Object} file
* @param {() => void} recordFallbackToClsiCache
*/
async function fetchChunk({
chunk,
@@ -604,6 +605,7 @@ async function fetchChunk({
canTryFromCache,
fallbackToCacheURL,
file,
recordFallbackToClsiCache,
}) {
const estimatedSize = Array.isArray(chunk)
? estimateSizeOfMultipartResponse(chunk)
@@ -667,6 +669,7 @@ async function fetchChunk({
try {
response = await fetchWithBrowserCacheFallback(url, init)
checkChunkResponse(response, estimatedSize, init)
recordFallbackToClsiCache()
} catch (err2) {
throw err1
}
@@ -824,6 +827,7 @@ class Timer {
* @param {AbortSignal} abortSignal
* @param {() => boolean} canTryFromCache
* @param {string} fallbackToCacheURL
* @param {() => void} recordFallbackToClsiCache
*/
export async function fetchRange({
url,
@@ -841,6 +845,7 @@ export async function fetchRange({
abortSignal,
canTryFromCache,
fallbackToCacheURL,
recordFallbackToClsiCache,
}) {
const timer = new Timer()
timer.startBlockingCompute()
@@ -985,6 +990,7 @@ export async function fetchRange({
canTryFromCache,
fallbackToCacheURL,
file,
recordFallbackToClsiCache,
})
timer.startBlockingCompute()
const boundary = getMultipartBoundary(response, chunk)
@@ -49,6 +49,7 @@ import { PdfFileDataList } from '@/features/pdf-preview/util/types'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { captureException } from '@/infrastructure/error-reporter'
import OError from '@overleaf/o-error'
import getMeta from '@/utils/meta'
type PdfFile = Record<string, any>
@@ -200,7 +201,8 @@ export const LocalCompileProvider: FC = ({ children }) => {
const [compiledOnce, setCompiledOnce] = useState(false)
// fetch initial compile response from cache
const [initialCompileFromCache, setInitialCompileFromCache] = useState(
isSplitTestEnabled('initial-compile-from-clsi-cache') &&
getMeta('ol-projectOwnerHasPremiumOnPageLoad') &&
isSplitTestEnabled('initial-compile-from-clsi-cache') &&
// Avoid fetching the initial compile from cache in PDF detach tab
role !== 'detached'
)
@@ -385,8 +387,10 @@ export const LocalCompileProvider: FC = ({ children }) => {
rootDocOverride === dataFromCache.rootDocId &&
dataFromCache.options.imageName === imageName &&
dataFromCache.options.compiler === compilerName &&
dataFromCache.options.stopOnFirstError === stopOnFirstError &&
dataFromCache.options.draft === draft
dataFromCache.options.draft === draft &&
// Allow stopOnFirstError to be enabled in the compile from cache and disabled locally.
// Compiles that passed with stopOnFirstError=true will also pass with stopOnFirstError=false. The inverse does not hold, and we need to recompile.
!!dataFromCache.options.stopOnFirstError >= stopOnFirstError
} catch (err) {
captureException(
OError.tag(err as unknown as Error, 'validate compile options', {
@@ -396,6 +400,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
}
if (settingsUpToDate) {
sendMB('compile-from-cache', { projectId })
setData(dataFromCache)
setCompiledOnce(true)
}
@@ -403,6 +408,7 @@ export const LocalCompileProvider: FC = ({ children }) => {
setInitialCompileFromCache(false)
setPendingInitialCompileFromCache(false)
}, [
projectId,
dataFromCache,
joinedOnce,
currentDocument,
+1
View File
@@ -183,6 +183,7 @@ export interface Meta {
'ol-project': any // TODO
'ol-projectHistoryBlobsEnabled': boolean
'ol-projectName': string
'ol-projectOwnerHasPremiumOnPageLoad': boolean
'ol-projectSyncSuccessMessage': string
'ol-projectTags': Tag[]
'ol-project_id': string
@@ -49,6 +49,7 @@ describe('<PdfPreview/>', function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'initial-compile-from-clsi-cache': 'enabled',
})
window.metaAttributesCache.set('ol-projectOwnerHasPremiumOnPageLoad', true)
cy.interceptEvents()
})