Files
overleaf-cep/services/docstore/app/js/DocArchiveManager.js
Andrew Rumble a1f1ca2028 Merge pull request #29948 from overleaf/ar/docstore-conversion-to-esm
[docstore] conversion to esm

GitOrigin-RevId: 9d255047bd7ae25f2b0b38f3a721741e8a0b7ad8
2025-12-03 09:05:42 +00:00

233 lines
6.0 KiB
JavaScript

import MongoManager from './MongoManager.js'
import Errors from './Errors.js'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import crypto from 'node:crypto'
import { ReadableString } from '@overleaf/stream-utils'
import RangeManager from './RangeManager.js'
import PersistorManager from './PersistorManager.js'
import pMap from 'p-map'
import { streamToBuffer } from './StreamToBuffer.js'
import mongodb from 'mongodb-legacy'
const { BSON } = mongodb
const PARALLEL_JOBS = Settings.parallelArchiveJobs
const UN_ARCHIVE_BATCH_SIZE = Settings.unArchiveBatchSize
async function archiveAllDocs(projectId) {
if (!_isArchivingEnabled()) {
return
}
const docIds = await MongoManager.getNonArchivedProjectDocIds(projectId)
await pMap(docIds, docId => archiveDoc(projectId, docId), {
concurrency: PARALLEL_JOBS,
})
}
async function archiveDoc(projectId, docId) {
if (!_isArchivingEnabled()) {
return
}
const doc = await MongoManager.getDocForArchiving(projectId, docId)
if (!doc) {
// The doc wasn't found, it was already archived, or the lock couldn't be
// acquired. Since we don't know which it is, silently return.
return
}
logger.debug({ projectId, docId: doc._id }, 'sending doc to persistor')
const key = `${projectId}/${doc._id}`
if (doc.lines == null) {
throw new Error('doc has no lines')
}
RangeManager.fixCommentIds(doc)
// warn about any oversized docs already in mongo
const linesSize = BSON.calculateObjectSize(doc.lines || {})
const rangesSize = BSON.calculateObjectSize(doc.ranges || {})
if (
linesSize > Settings.max_doc_length ||
rangesSize > Settings.max_doc_length
) {
logger.warn(
{ projectId, docId: doc._id, linesSize, rangesSize },
'large doc found when archiving project'
)
}
const json = JSON.stringify({
lines: doc.lines,
ranges: doc.ranges,
rev: doc.rev,
schema_v: 1,
})
// this should never happen, but protects against memory-corruption errors that
// have happened in the past
if (json.indexOf('\u0000') > -1) {
const error = new Error('null bytes detected')
logger.err({ err: error, doc }, error.message)
throw error
}
const stream = new ReadableString(json)
if (Settings.docstore.backend === 's3') {
await PersistorManager.sendStream(Settings.docstore.bucket, key, stream)
} else {
await PersistorManager.sendStream(Settings.docstore.bucket, key, stream, {
sourceMd5: crypto.createHash('md5').update(json).digest('hex'),
})
}
await MongoManager.markDocAsArchived(projectId, docId, doc.rev)
}
async function unArchiveAllDocs(projectId) {
if (!_isArchivingEnabled()) {
return
}
while (true) {
let docs
if (Settings.docstore.keepSoftDeletedDocsArchived) {
docs = await MongoManager.getNonDeletedArchivedProjectDocs(
projectId,
UN_ARCHIVE_BATCH_SIZE
)
} else {
docs = await MongoManager.getArchivedProjectDocs(
projectId,
UN_ARCHIVE_BATCH_SIZE
)
}
if (!docs || docs.length === 0) {
break
}
await pMap(docs, doc => unarchiveDoc(projectId, doc._id), {
concurrency: PARALLEL_JOBS,
})
}
}
// get the doc from the PersistorManager without storing it in mongo
async function getDoc(projectId, docId) {
const key = `${projectId}/${docId}`
const stream = await PersistorManager.getObjectStream(
Settings.docstore.bucket,
key
)
let buffer
if (Settings.docstore.backend === 's3') {
stream.resume()
buffer = await streamToBuffer(projectId, docId, stream)
} else {
const sourceMd5 = await PersistorManager.getObjectMd5Hash(
Settings.docstore.bucket,
key
)
stream.resume()
buffer = await streamToBuffer(projectId, docId, stream)
const md5 = crypto.createHash('md5').update(buffer).digest('hex')
if (sourceMd5 !== md5) {
throw new Errors.Md5MismatchError('md5 mismatch when downloading doc', {
key,
sourceMd5,
md5,
})
}
}
return _deserializeArchivedDoc(buffer)
}
// get the doc and unarchive it to mongo
async function unarchiveDoc(projectId, docId) {
logger.debug({ projectId, docId }, 'getting doc from persistor')
const mongoDoc = await MongoManager.findDoc(projectId, docId, {
inS3: 1,
rev: 1,
})
if (!mongoDoc.inS3) {
// The doc is already unarchived
return
}
if (!_isArchivingEnabled()) {
throw new Error(
'found archived doc, but archiving backend is not configured'
)
}
const archivedDoc = await getDoc(projectId, docId)
if (archivedDoc.rev == null) {
// Older archived docs didn't have a rev. Assume that the rev of the
// archived doc is the rev that was stored in Mongo when we retrieved it
// earlier.
archivedDoc.rev = mongoDoc.rev
}
await MongoManager.restoreArchivedDoc(projectId, docId, archivedDoc)
}
async function destroyProject(projectId) {
const tasks = [MongoManager.destroyProject(projectId)]
if (_isArchivingEnabled()) {
tasks.push(
PersistorManager.deleteDirectory(Settings.docstore.bucket, projectId)
)
}
await Promise.all(tasks)
}
function _deserializeArchivedDoc(buffer) {
const doc = JSON.parse(buffer)
const result = {}
if (doc.schema_v === 1 && doc.lines != null) {
result.lines = doc.lines
if (doc.ranges != null) {
result.ranges = RangeManager.jsonRangesToMongo(doc.ranges)
}
} else if (Array.isArray(doc)) {
result.lines = doc
} else {
throw new Error("I don't understand the doc format in s3")
}
if (doc.rev != null) {
result.rev = doc.rev
}
return result
}
function _isArchivingEnabled() {
const backend = Settings.docstore.backend
if (!backend) {
return false
}
// The default backend is S3. If another backend is configured or the S3
// backend itself is correctly configured, then archiving is enabled.
if (backend === 's3' && Settings.docstore.s3 == null) {
return false
}
return true
}
export default {
archiveAllDocs,
archiveDoc,
unArchiveAllDocs,
unarchiveDoc,
destroyProject,
getDoc,
}