[web] resync_projects: use the secondaries for all reads (#33684)

* [docstore] add useSecondary flag to projectHasRanges

The rev-check for unarchiving always consults with the primary.

Two extra changes:
- Add a projection argument to peekDoc in order to skip lines download
   from projectHasRanges.
- Add one retry to peekDoc to reduce chances of surfacing a rev-check
   violation.

* [web] resync_projects: use the secondaries for all reads

* [web] add default value for useSecondary

* [docstore] add default value for useSecondary

* [k8s] docstore: set MONGO_HAS_SECONDARIES=true

GitOrigin-RevId: f15ec4fdc1cabe74c1eab87bec85f28d6f7a587d
This commit is contained in:
Jakob Ackermann
2026-05-14 08:06:26 +00:00
committed by Copybot
parent ff53705bfa
commit 75a12dda17
7 changed files with 78 additions and 27 deletions
+40 -16
View File
@@ -77,15 +77,17 @@ const DocManager = {
}, },
// returns the doc without any version information // returns the doc without any version information
async _peekRawDoc(projectId, docId) { async _peekRawDoc(projectId, docId, projection, useSecondary) {
const doc = await MongoManager.findDoc(projectId, docId, { const doc = await MongoManager.findDoc(
lines: true, projectId,
rev: true, docId,
deleted: true, {
version: true, ...projection,
ranges: true, rev: true,
inS3: true, inS3: true,
}) },
useSecondary
)
if (doc == null) { if (doc == null) {
throw new Errors.NotFoundError( throw new Errors.NotFoundError(
@@ -97,6 +99,8 @@ const DocManager = {
// skip the unarchiving to mongo when getting a doc // skip the unarchiving to mongo when getting a doc
const archivedDoc = await DocArchive.getDoc(projectId, docId) const archivedDoc = await DocArchive.getDoc(projectId, docId)
Object.assign(doc, archivedDoc) Object.assign(doc, archivedDoc)
// Always use the primary for the rev-check.
await MongoManager.checkRevUnchanged(doc)
} }
return doc return doc
@@ -104,10 +108,21 @@ const DocManager = {
// get the doc from mongo if possible, or from the persistent store otherwise, // get the doc from mongo if possible, or from the persistent store otherwise,
// without unarchiving it (avoids unnecessary writes to mongo) // without unarchiving it (avoids unnecessary writes to mongo)
async peekDoc(projectId, docId) { async peekDoc(projectId, docId, projection, useSecondary = false) {
const doc = await DocManager._peekRawDoc(projectId, docId) try {
await MongoManager.checkRevUnchanged(doc) return await DocManager._peekRawDoc(
return doc projectId,
docId,
projection,
useSecondary
)
} catch (err) {
if (err instanceof Errors.DocModifiedError) {
// Try again once on rev mismatch. Always use the primary for retries.
return await DocManager._peekRawDoc(projectId, docId, projection, false)
}
throw err
}
}, },
async getDocLines(projectId, docId) { async getDocLines(projectId, docId) {
@@ -181,11 +196,20 @@ const DocManager = {
return Array.from(userIds) return Array.from(userIds)
}, },
async projectHasRanges(projectId) { async projectHasRanges(projectId, useSecondary) {
const docs = await MongoManager.getProjectsDocs(projectId, {}, { _id: 1 }) const docs = await MongoManager.getProjectsDocs(
projectId,
{ useSecondary },
{ _id: 1 }
)
const docIds = docs.map(doc => doc._id) const docIds = docs.map(doc => doc._id)
for (const docId of docIds) { for (const docId of docIds) {
const doc = await DocManager.peekDoc(projectId, docId) const doc = await DocManager.peekDoc(
projectId,
docId,
{ ranges: true },
useSecondary
)
if ( if (
(doc.ranges?.comments != null && doc.ranges.comments.length > 0) || (doc.ranges?.comments != null && doc.ranges.comments.length > 0) ||
(doc.ranges?.changes != null && doc.ranges.changes.length > 0) (doc.ranges?.changes != null && doc.ranges.changes.length > 0)
+13 -2
View File
@@ -22,7 +22,14 @@ async function getDoc(req, res) {
async function peekDoc(req, res) { async function peekDoc(req, res) {
const { doc_id: docId, project_id: projectId } = req.params const { doc_id: docId, project_id: projectId } = req.params
logger.debug({ projectId, docId }, 'peeking doc') logger.debug({ projectId, docId }, 'peeking doc')
const doc = await DocManager.peekDoc(projectId, docId) const doc = await DocManager.peekDoc(projectId, docId, {
deleted: true,
inS3: true,
lines: true,
ranges: true,
rev: 1,
version: true,
})
res.setHeader('x-doc-status', doc.inS3 ? 'archived' : 'active') res.setHeader('x-doc-status', doc.inS3 ? 'archived' : 'active')
res.json(_buildDocView(doc)) res.json(_buildDocView(doc))
} }
@@ -121,7 +128,11 @@ async function getTrackedChangesUserIds(req, res) {
async function projectHasRanges(req, res) { async function projectHasRanges(req, res) {
const { project_id: projectId } = req.params const { project_id: projectId } = req.params
const projectHasRanges = await DocManager.projectHasRanges(projectId) const useSecondary = req.query.useSecondary === 'true'
const projectHasRanges = await DocManager.projectHasRanges(
projectId,
useSecondary
)
res.json({ projectHasRanges }) res.json({ projectHasRanges })
} }
+8 -2
View File
@@ -7,13 +7,18 @@ const { db, ObjectId, BSON } = mongodb
const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs
async function findDoc(projectId, docId, projection) { function readPreference(useSecondary) {
if (useSecondary) return { readPreference: mongodb.READ_PREFERENCE_SECONDARY }
return {}
}
async function findDoc(projectId, docId, projection, useSecondary = false) {
const doc = await db.docs.findOne( const doc = await db.docs.findOne(
{ {
_id: new ObjectId(docId.toString()), _id: new ObjectId(docId.toString()),
project_id: new ObjectId(projectId.toString()), project_id: new ObjectId(projectId.toString()),
}, },
{ projection } { projection, ...readPreference(useSecondary) }
) )
if (doc && projection.version && !doc.version) { if (doc && projection.version && !doc.version) {
doc.version = 0 doc.version = 0
@@ -45,6 +50,7 @@ async function getProjectsDocs(projectId, options, projection) {
} }
const queryOptions = { const queryOptions = {
projection, projection,
...readPreference(options.useSecondary),
} }
if (options.limit) { if (options.limit) {
queryOptions.limit = options.limit queryOptions.limit = options.limit
+8 -1
View File
@@ -6,7 +6,7 @@ import Settings from '@overleaf/settings'
import MongoUtils from '@overleaf/mongo-utils' import MongoUtils from '@overleaf/mongo-utils'
import mongodb from 'mongodb-legacy' import mongodb from 'mongodb-legacy'
const { MongoClient, ObjectId, BSON } = mongodb const { MongoClient, ObjectId, BSON, ReadPreference } = mongodb
const mongoClient = new MongoClient(Settings.mongo.url, Settings.mongo.options) const mongoClient = new MongoClient(Settings.mongo.url, Settings.mongo.options)
const mongoDb = mongoClient.db() const mongoDb = mongoClient.db()
@@ -21,7 +21,14 @@ async function cleanupTestDatabase() {
await MongoUtils.cleanupTestDatabase(mongoClient) await MongoUtils.cleanupTestDatabase(mongoClient)
} }
const READ_PREFERENCE_PRIMARY = ReadPreference.primary.mode
const READ_PREFERENCE_SECONDARY = Settings.mongo.hasSecondaries
? ReadPreference.secondary.mode
: ReadPreference.secondaryPreferred.mode
export default { export default {
READ_PREFERENCE_PRIMARY,
READ_PREFERENCE_SECONDARY,
db, db,
mongoClient, mongoClient,
ObjectId, ObjectId,
@@ -17,6 +17,7 @@ const Settings = {
options: { options: {
monitorCommands: true, monitorCommands: true,
}, },
hasSecondaries: process.env.MONGO_HAS_SECONDARIES === 'true',
}, },
docstore: { docstore: {
@@ -323,10 +323,12 @@ async function updateDoc(
* Asks docstore whether any doc in the project has ranges * Asks docstore whether any doc in the project has ranges
* *
* @param {string} projectId * @param {string} projectId
* @param {boolean} useSecondary
*/ */
async function projectHasRanges(projectId) { async function projectHasRanges(projectId, useSecondary = false) {
const url = new URL(settings.apis.docstore.url) const url = new URL(settings.apis.docstore.url)
url.pathname = path.posix.join('project', projectId, 'has-ranges') url.pathname = path.posix.join('project', projectId, 'has-ranges')
if (useSecondary) url.searchParams.set('useSecondary', 'true')
try { try {
const body = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) }) const body = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
return body.projectHasRanges return body.projectHasRanges
@@ -3,7 +3,6 @@
import minimist from 'minimist' import minimist from 'minimist'
import { scriptRunner } from '../lib/ScriptRunner.mjs' import { scriptRunner } from '../lib/ScriptRunner.mjs'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import ProjectGetter from '../../app/src/Features/Project/ProjectGetter.mjs'
import { import {
db, db,
ObjectId, ObjectId,
@@ -268,7 +267,7 @@ async function hasHistoryMetadata(projectId) {
if (await hasLinkedFileData(projectId)) { if (await hasLinkedFileData(projectId)) {
return true return true
} }
if (await DocstoreManager.promises.projectHasRanges(projectId)) { if (await DocstoreManager.promises.projectHasRanges(projectId, true)) {
return true return true
} }
return false return false
@@ -296,10 +295,11 @@ async function hasHistoryMetadata(projectId) {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async function hasLinkedFileData(projectId) { async function hasLinkedFileData(projectId) {
const project = await ProjectGetter.promises.getProjectWithoutLock( const project = await db.projects.findOne(
projectId, { _id: new ObjectId(projectId) },
{ {
rootFolder: 1, projection: { rootFolder: 1 },
readPreference: READ_PREFERENCE_SECONDARY,
} }
) )
if (!project) { if (!project) {