[web] allow admins to clone projects with ranges and entire history (#32739)

* [web] add consistent aria-label to editing/reviewing toggle

* [docstore] add endpoint for getting all docs with ranges

* [history-v1] fix schema of chunkId when deleting old history chunk

* [web] skip duplicate project lookup for resolving rootDocPath

* [web] ignore new limits for root doc path when making debug copy

* [web] allow admins to clone projects with ranges and entire history

* [web] fix tests

* [history-v1] re-order params for cloning project

* [web] fix duplicate import of logger after merge

* [project-history] re-order params for cloning project history metadata

GitOrigin-RevId: 7fa35b4f90885dd453150a348d491ba0ec8de412
This commit is contained in:
Jakob Ackermann
2026-04-15 08:05:49 +00:00
committed by Copybot
parent 917d2700c8
commit 5a6c066847
25 changed files with 786 additions and 32 deletions
+75
View File
@@ -163,6 +163,80 @@ class MeteredStream extends Transform {
} }
} }
class IncrementalResponse {
#res
#ac
#timeout
#logger
#label
#info
constructor({ res, timeout, label, info, logger }) {
this.#res = res
this.#logger = logger
this.#label = label
this.#info = info
this.#ac = new AbortController()
this.#timeout = setTimeout(() => {
this.#logger.warn({ ...this.#info, timeout }, `${this.#label}: aborting`)
this.sendUpdate(
`error: ${label}: aborting after ${this.#humanReadableTimeout(timeout)}`
)
this.#ac.abort()
}, timeout)
}
signal() {
return this.#ac.signal
}
end() {
this.#ac.abort()
clearTimeout(this.#timeout)
try {
this.#res.end()
} catch {
try {
this.#res.destroy()
} catch {}
}
}
sendUpdate(msg) {
try {
this.#res.write(msg + '\n')
} catch (err) {
this.#ac.abort()
this.#logger.warn(
{ err, ...this.#info },
`${this.#label}: failed to send progress update`
)
}
}
fail(err) {
const aborted = this.#ac.signal.aborted
this.#ac.abort()
if (!aborted) {
this.#logger.err({ err, ...this.#info }, `${this.#label}: error`)
this.sendUpdate(`error: ${this.#label}`)
}
this.end()
}
#humanReadableTimeout(timeout) {
let ms = timeout
const minutes = Math.floor(ms / 60_000)
ms -= minutes * 60_000
const seconds = Math.floor(ms / 1_000)
ms -= seconds * 1_000
let t = ''
if (minutes) t += `${minutes}min`
if (seconds) t += `${seconds}s`
if (ms) t += `${ms}ms`
return t
}
}
// Export our classes // Export our classes
module.exports = { module.exports = {
@@ -174,4 +248,5 @@ module.exports = {
MeteredStream, MeteredStream,
SizeExceededError, SizeExceededError,
AbortError, AbortError,
IncrementalResponse,
} }
+4
View File
@@ -51,6 +51,10 @@ app.param('doc_id', function (req, res, next, docId) {
app.get('/project/:project_id/doc-deleted', HttpController.getAllDeletedDocs) app.get('/project/:project_id/doc-deleted', HttpController.getAllDeletedDocs)
app.get('/project/:project_id/doc', HttpController.getAllDocs) app.get('/project/:project_id/doc', HttpController.getAllDocs)
app.get(
'/project/:project_id/doc-with-ranges',
HttpController.getAllDocsWithRanges
)
app.get('/project/:project_id/doc-versions', HttpController.getAllDocVersions) app.get('/project/:project_id/doc-versions', HttpController.getAllDocVersions)
app.get('/project/:project_id/ranges', HttpController.getAllRanges) app.get('/project/:project_id/ranges', HttpController.getAllRanges)
app.get( app.get(
@@ -58,6 +58,24 @@ async function getAllDocs(req, res) {
res.json(docViews) res.json(docViews)
} }
async function getAllDocsWithRanges(req, res) {
const { project_id: projectId } = req.params
logger.debug({ projectId }, 'getting all docs with ranges')
const docs = await DocManager.getAllNonDeletedDocs(projectId, {
lines: true,
rev: true,
ranges: true,
})
const docViews = _buildDocsArrayView(projectId, docs)
for (const docView of docViews) {
if (!docView.lines) {
logger.warn({ projectId, docId: docView._id }, 'missing doc lines')
docView.lines = []
}
}
res.json(docViews)
}
async function getAllDocVersions(req, res) { async function getAllDocVersions(req, res) {
const { project_id: projectId } = req.params const { project_id: projectId } = req.params
const docs = await DocManager.getAllDocVersions(projectId) const docs = await DocManager.getAllDocVersions(projectId)
@@ -248,6 +266,7 @@ export default {
isDocDeleted: expressify(isDocDeleted), isDocDeleted: expressify(isDocDeleted),
getRawDoc: expressify(getRawDoc), getRawDoc: expressify(getRawDoc),
getAllDocs: expressify(getAllDocs), getAllDocs: expressify(getAllDocs),
getAllDocsWithRanges: expressify(getAllDocsWithRanges),
getAllDeletedDocs: expressify(getAllDeletedDocs), getAllDeletedDocs: expressify(getAllDeletedDocs),
getAllRanges: expressify(getAllRanges), getAllRanges: expressify(getAllRanges),
getAllDocVersions: expressify(getAllDocVersions), getAllDocVersions: expressify(getAllDocVersions),
@@ -22,6 +22,7 @@ const {
HashCheckBlobStore, HashCheckBlobStore,
ProjectArchive, ProjectArchive,
zipStore, zipStore,
persistBuffer,
} = require('../../storage') } = require('../../storage')
const render = require('./render') const render = require('./render')
@@ -31,6 +32,7 @@ const StreamSizeLimit = require('./stream_size_limit')
const { getProjectBlobsBatch } = require('../../storage/lib/blob_store') const { getProjectBlobsBatch } = require('../../storage/lib/blob_store')
const assert = require('../../storage/lib/assert') const assert = require('../../storage/lib/assert')
const { getChunkMetadataForVersion } = require('../../storage/lib/chunk_store') const { getChunkMetadataForVersion } = require('../../storage/lib/chunk_store')
const { IncrementalResponse } = require('@overleaf/stream-utils')
const pipeline = promisify(Stream.pipeline) const pipeline = promisify(Stream.pipeline)
@@ -50,6 +52,62 @@ async function initializeProject(req, res, next) {
} }
} }
async function cloneProject(req, res) {
const {
body: { targetProjectId },
params: { project_id: sourceProjectId },
} = parseReq(req, schemas.cloneProject)
const incrResp = new IncrementalResponse({
res,
timeout: 10 * 60_000 - 5_000,
logger,
label: 'clone history in history-v1',
info: { targetProjectId, sourceProjectId },
})
const signal = incrResp.signal()
try {
try {
// Use the same limits importChanges, since these are passed to persistChanges
const farFuture = new Date()
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
const limits = {
maxChanges: 0,
minChangeTimestamp: farFuture,
maxChangeTimestamp: farFuture,
autoResync: true,
}
incrResp.sendUpdate('flushing redis buffer: pending')
await persistBuffer(sourceProjectId, limits)
incrResp.sendUpdate('flushing redis buffer: done')
} catch (err) {
incrResp.sendUpdate('failed to flush redis buffer')
logger.error(
{ err, targetProjectId, sourceProjectId },
'failed to persist buffer during clone'
)
}
await chunkStore.cloneProject(
sourceProjectId,
targetProjectId,
progress => {
if (signal.aborted) return
incrResp.sendUpdate(progress)
},
signal
)
if (!signal.aborted) {
incrResp.sendUpdate('cloning full project history data: done')
}
} catch (err) {
incrResp.fail(err)
} finally {
incrResp.end()
}
}
async function getLatestContent(req, res, next) { async function getLatestContent(req, res, next) {
const { params } = parseReq(req, schemas.getLatestContent) const { params } = parseReq(req, schemas.getLatestContent)
const projectId = params.project_id const projectId = params.project_id
@@ -510,6 +568,7 @@ async function getProjectBlobsStats(req, res) {
module.exports = { module.exports = {
initializeProject: expressify(initializeProject), initializeProject: expressify(initializeProject),
cloneProject: expressify(cloneProject),
getLatestContent: expressify(getLatestContent), getLatestContent: expressify(getLatestContent),
getContentAtVersion: expressify(getContentAtVersion), getContentAtVersion: expressify(getContentAtVersion),
getLatestHashedContent: expressify(getLatestHashedContent), getLatestHashedContent: expressify(getLatestHashedContent),
@@ -14,6 +14,12 @@ const {
router.post('/projects', handleBasicAuth, projectsController.initializeProject) router.post('/projects', handleBasicAuth, projectsController.initializeProject)
router.post(
'/projects/:project_id/clone',
handleBasicAuth,
projectsController.cloneProject
)
router.post( router.post(
'/projects/blob-stats', '/projects/blob-stats',
handleBasicAuth, handleBasicAuth,
+10
View File
@@ -72,6 +72,16 @@ const schemas = {
.optional(), .optional(),
}), }),
cloneProject: z.object({
body: z.object({
targetProjectId: z.string(),
}),
params: z.object({
project_id: z.string(),
}),
}),
getProjectBlobsStats: z.object({ getProjectBlobsStats: z.object({
body: z.object({ body: z.object({
projectIds: z.array(z.string()), projectIds: z.array(z.string()),
@@ -21,6 +21,7 @@ const streams = require('../streams')
const postgresBackend = require('./postgres') const postgresBackend = require('./postgres')
const mongoBackend = require('./mongo') const mongoBackend = require('./mongo')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const { promiseMapWithLimit } = require('@overleaf/promise-utils')
/** @import { Readable } from 'stream' */ /** @import { Readable } from 'stream' */
@@ -35,6 +36,25 @@ function makeProjectKey(projectId, hash) {
return `${projectKey.format(projectId)}/${hash.slice(0, 2)}/${hash.slice(2)}` return `${projectKey.format(projectId)}/${hash.slice(0, 2)}/${hash.slice(2)}`
} }
/**
* Copy the data structures for a given project.
* @param {string} sourceProjectId
* @param {string} targetProjectId
* @param {string} hash
*/
async function cloneBlob(sourceProjectId, targetProjectId, hash) {
const bucket = config.get('blobStore.projectBucket')
const dst = makeProjectKey(targetProjectId, hash)
const src = makeProjectKey(sourceProjectId, hash)
const info = { targetProjectId, sourceProjectId, hash }
logger.debug(info, 'cloneBlob started')
try {
await persistor.copyObject(bucket, src, dst)
} finally {
logger.debug(info, 'cloneBlob finished')
}
}
async function uploadBlob(projectId, blob, stream, opts = {}) { async function uploadBlob(projectId, blob, stream, opts = {}) {
const bucket = config.get('blobStore.projectBucket') const bucket = config.get('blobStore.projectBucket')
const key = makeProjectKey(projectId, blob.getHash()) const key = makeProjectKey(projectId, blob.getHash())
@@ -178,6 +198,21 @@ class BlobStore {
await this.backend.initialize(this.projectId) await this.backend.initialize(this.projectId)
} }
/**
* Set up the initial data structure for a given project
*/
async clone(sourceProjectId, onProgress, signal) {
const hashes = await this.backend.clone(sourceProjectId, this.projectId)
onProgress(`blobs-metadata-imported: ${hashes.length}`)
let done = 0
await promiseMapWithLimit(50, hashes, async hash => {
if (signal.aborted) return
await cloneBlob(sourceProjectId, this.projectId, hash)
done++
onProgress(`blobs-copied: ${done}`)
})
}
/** /**
* Write a blob, if one does not already exist, with the given UTF-8 encoded * Write a blob, if one does not already exist, with the given UTF-8 encoded
* string content. * string content.
@@ -46,6 +46,64 @@ async function initialize(projectId) {
} }
} }
/**
* Copy the data structures for a given project.
* @param {string} sourceProjectId
* @param {string} targetProjectId
*/
async function clone(sourceProjectId, targetProjectId) {
assert.mongoId(targetProjectId, 'bad target projectId')
assert.mongoId(sourceProjectId, 'bad source projectId')
const result = await mongodb.blobs.findOne({
_id: new ObjectId(sourceProjectId),
})
if (!result || !('blobs' in result)) {
throw new Error('missing blobs for source project')
}
const blobHashes = []
for (const bucket of Object.values(result.blobs)) {
for (const record of bucket) {
blobHashes.push(record.h.toString('hex'))
}
}
await mongodb.blobs.updateOne(
{ _id: new ObjectId(targetProjectId) },
{ $set: { blobs: result.blobs } }
)
const minShardedId = makeShardedId(sourceProjectId, '0')
const maxShardedId = makeShardedId(sourceProjectId, 'f')
// @ts-ignore We are using a custom _id here.
const sharded = mongodb.shardedBlobs.find({
_id: { $gte: minShardedId, $lte: maxShardedId },
})
const newShards = [] // gather up-to 16 shards
for await (const shardedRecord of sharded) {
if (shardedRecord.blobs == null) {
continue
}
// Schema of shard id: <projectId>0<shard id: hex>
const shard = shardedRecord._id.toString('hex').slice(25)
const newId = makeShardedId(targetProjectId, shard)
newShards.push({
...shardedRecord,
_id: newId,
})
for (const bucket of Object.values(shardedRecord.blobs)) {
for (const record of bucket) {
blobHashes.push(record.h.toString('hex'))
}
}
}
if (newShards.length > 0) {
// @ts-ignore We are using a custom _id here.
await mongodb.shardedBlobs.insertMany(newShards)
}
return blobHashes
}
/** /**
* Return blob metadata for the given project and hash. * Return blob metadata for the given project and hash.
* @param {string} projectId * @param {string} projectId
@@ -428,6 +486,7 @@ function recordToBlob(record) {
module.exports = { module.exports = {
initialize, initialize,
clone,
findBlob, findBlob,
findBlobs, findBlobs,
getProjectBlobs, getProjectBlobs,
@@ -9,6 +9,32 @@ async function initialize(projectId) {
// Nothing to do for Postgres // Nothing to do for Postgres
} }
/**
* Copy the data structures for a given project.
* @param {string} sourceProjectId
* @param {string} targetProjectId
*/
async function clone(sourceProjectId, targetProjectId) {
assert.postgresId(targetProjectId, 'bad target projectId')
assert.postgresId(sourceProjectId, 'bad source projectId')
const result = await knex.raw(
`INSERT INTO project_blobs (
project_id, hash_bytes, byte_length, string_length
)
SELECT ?, hash_bytes, byte_length, string_length
FROM project_blobs
WHERE project_id = ?
RETURNING hash_bytes`,
[parseInt(targetProjectId, 10), parseInt(sourceProjectId, 10)]
)
const blobHashes = []
for (const row of result.rows) {
blobHashes.push(row.hash_bytes.toString('hex'))
}
return blobHashes
}
/** /**
* Return blob metadata for the given project and hash * Return blob metadata for the given project and hash
*/ */
@@ -152,6 +178,7 @@ function hashFromBuffer(buffer) {
module.exports = { module.exports = {
initialize, initialize,
clone,
findBlob, findBlob,
findBlobs, findBlobs,
getProjectBlobs, getProjectBlobs,
@@ -38,6 +38,7 @@ const {
ChunkVersionConflictError, ChunkVersionConflictError,
VersionOutOfBoundsError, VersionOutOfBoundsError,
} = require('./errors') } = require('./errors')
const { promiseMapWithLimit } = require('@overleaf/promise-utils')
/** /**
* @import { Change } from 'overleaf-editor-core' * @import { Change } from 'overleaf-editor-core'
@@ -78,6 +79,71 @@ async function initializeProject(projectId, snapshot) {
return projectId return projectId
} }
/**
* Clone the project data.
* @param {string} sourceProjectId
* @param {string} targetProjectId
* @param {(string) => void} onProgress
* @param {AbortSignal} signal
*/
async function cloneProject(
sourceProjectId,
targetProjectId,
onProgress,
signal
) {
assert.projectId(targetProjectId, 'bad target projectId')
assert.projectId(sourceProjectId, 'bad source projectId')
onProgress('existing history: checking')
const backend = getBackend(targetProjectId)
const chunkRecord = await backend.getLatestChunk(targetProjectId)
if (!chunkRecord) {
onProgress('existing history: not found, aborting')
throw new OError('target project is not initialized yet')
}
if (chunkRecord?.endVersion > 0) {
onProgress('existing history: found changes, aborting')
throw new AlreadyInitialized(targetProjectId)
}
onProgress('existing history: deleting empty chunk')
await backend.deleteChunk(targetProjectId, chunkRecord.id)
onProgress('existing history: deleted empty chunk')
async function cloneBlobs() {
onProgress('cloning blobs metadata: pending')
const blobStore = new BlobStore(targetProjectId)
await blobStore.clone(sourceProjectId, onProgress, signal)
onProgress('cloning blobs metadata: done')
}
async function cloneChunks() {
onProgress('cloning chunks metadata: pending')
const chunkIds = await backend.clone(sourceProjectId, targetProjectId)
onProgress(`chunks-metadata-imported: ${chunkIds.size}`)
let done = 0
await promiseMapWithLimit(
50,
Array.from(chunkIds.entries()),
async ([sourceChunkId, targetChunkId]) => {
if (signal.aborted) return
await historyStore.cloneChunk(
sourceProjectId,
sourceChunkId,
targetProjectId,
targetChunkId
)
done++
onProgress(`chunks-copied: ${done}`)
}
)
onProgress('cloning chunks metadata: done')
}
await Promise.all([cloneBlobs(), cloneChunks()])
}
/** /**
* Load the blobs referenced in the given history * Load the blobs referenced in the given history
*/ */
@@ -619,6 +685,7 @@ class AlreadyInitialized extends OError {
module.exports = { module.exports = {
getBackend, getBackend,
initializeProject, initializeProject,
cloneProject,
loadLatest, loadLatest,
getLatestChunkMetadata, getLatestChunkMetadata,
loadAtVersion, loadAtVersion,
@@ -136,6 +136,43 @@ async function getProjectChunks(projectId) {
return await cursor.map(chunkFromRecord).toArray() return await cursor.map(chunkFromRecord).toArray()
} }
/**
* Copy the data structures for a given project.
* @param {string} sourceProjectId
* @param {string} targetProjectId
*/
async function clone(sourceProjectId, targetProjectId) {
assert.mongoId(targetProjectId, 'bad target projectId')
assert.mongoId(sourceProjectId, 'bad source projectId')
const cursor = mongodb.chunks.find(
{
projectId: new ObjectId(sourceProjectId),
state: { $in: ['active', 'closed'] },
},
{ projection: { projectId: 0 } }
)
const chunkIds = new Map()
const batch = []
async function flushBatch() {
await mongodb.chunks.insertMany(batch)
batch.length = 0
}
for await (const chunk of cursor) {
const newChunkId = new ObjectId()
chunkIds.set(chunk._id.toString(), newChunkId.toString())
batch.push({
...chunk,
_id: newChunkId,
projectId: new ObjectId(targetProjectId),
})
if (batch.length > 100) await flushBatch()
}
if (batch.length > 0) await flushBatch()
return chunkIds
}
/** /**
* Insert a pending chunk before sending it to object storage. * Insert a pending chunk before sending it to object storage.
*/ */
@@ -477,6 +514,7 @@ function chunkFromRecord(record) {
} }
module.exports = { module.exports = {
clone,
getLatestChunk, getLatestChunk,
getChunkForVersion, getChunkForVersion,
getChunkForTimestamp, getChunkForTimestamp,
@@ -135,6 +135,47 @@ async function getProjectChunks(projectId) {
.orderBy('end_version') .orderBy('end_version')
return records.map(chunkFromRecord) return records.map(chunkFromRecord)
} }
/**
* Copy the data structures for a given project.
* @param {string} sourceProjectId
* @param {string} targetProjectId
*/
async function clone(sourceProjectId, targetProjectId) {
assert.postgresId(targetProjectId, 'bad target projectId')
assert.postgresId(sourceProjectId, 'bad source projectId')
const cursor = knex('chunks')
.select()
.where('doc_id', parseInt(sourceProjectId, 10))
.stream()
const chunkIds = new Map()
const batch = []
async function flushBatch() {
const newIds = await knex.raw(
"SELECT nextval('chunks_id_seq'::regclass)::integer AS chunk_id FROM generate_series(1, ?)",
batch.length
)
const newRecords = []
for (const [i, chunk] of batch.entries()) {
const newId = newIds.rows[i].chunk_id
chunkIds.set(chunk.id.toString(), newId.toString())
newRecords.push({
...chunk,
id: newId,
doc_id: parseInt(targetProjectId, 10),
})
}
await knex('chunks').insert(newRecords)
batch.length = 0
}
for await (const chunk of cursor) {
batch.push(chunk)
if (batch.length > 100) await flushBatch()
}
if (batch.length > 0) await flushBatch()
return chunkIds
}
/** /**
* Insert a pending chunk before sending it to object storage. * Insert a pending chunk before sending it to object storage.
@@ -330,7 +371,7 @@ async function _closeChunk(tx, projectId, chunkId) {
*/ */
async function deleteChunk(projectId, chunkId) { async function deleteChunk(projectId, chunkId) {
assert.postgresId(projectId, 'bad projectId') assert.postgresId(projectId, 'bad projectId')
assert.integer(chunkId, 'bad chunkId') assert.chunkId(chunkId, 'bad chunkId')
await _deleteChunks(knex, { await _deleteChunks(knex, {
doc_id: parseInt(projectId, 10), doc_id: parseInt(projectId, 10),
@@ -422,6 +463,7 @@ async function resolveHistoryIdToMongoProjectId(projectId) {
} }
module.exports = { module.exports = {
clone,
getLatestChunk, getLatestChunk,
getChunkForVersion, getChunkForVersion,
getChunkForTimestamp, getChunkForTimestamp,
@@ -176,6 +176,46 @@ class HistoryStore {
} }
} }
/**
* Compress and store a {@link History}.
*
* @param {string} sourceProjectId
* @param {string} sourceChunkId
* @param {string} targetProjectId
* @param {string} targetChunkId
*/
async cloneChunk(
sourceProjectId,
sourceChunkId,
targetProjectId,
targetChunkId
) {
assert.projectId(targetProjectId, 'bad target projectId')
assert.projectId(sourceProjectId, 'bad source projectId')
assert.chunkId(targetChunkId, 'bad chunkId')
assert.chunkId(sourceChunkId, 'bad chunkId')
const dstKey = getKey(targetProjectId, targetChunkId)
const srcKey = getKey(sourceProjectId, sourceChunkId)
const info = {
targetProjectId,
sourceProjectId,
sourceChunkId,
targetChunkId,
srcKey,
dstKey,
}
logger.debug(info, 'cloneChunk started')
try {
await this.#persistor.copyObject(this.#bucket, srcKey, dstKey)
} catch (err) {
throw new StoreError(sourceProjectId, sourceChunkId, err)
} finally {
logger.debug(info, 'cloneChunk finished')
}
}
/** /**
* Delete multiple chunks from bucket. Expects an Array of objects with * Delete multiple chunks from bucket. Expects an Array of objects with
* projectId and chunkId properties * projectId and chunkId properties
@@ -86,6 +86,23 @@ async function recordSyncStart(projectId) {
) )
} }
/**
* @param {string} sourceProjectId
* @param {string} targetProjectId
* @return {Promise<void>}
*/
async function cloneFailure(sourceProjectId, targetProjectId) {
const failure = await db.projectHistoryFailures.findOne(
{ project_id: sourceProjectId.toString() },
{ projection: { _id: 0, project_id: 0 } }
)
if (!failure) return
await db.projectHistoryFailures.insertOne({
...failure,
project_id: targetProjectId.toString(),
})
}
/** /**
* @param projectId * @param projectId
*/ */
@@ -238,6 +255,7 @@ async function getFailures() {
const getFailedProjectsCb = callbackify(getFailedProjects) const getFailedProjectsCb = callbackify(getFailedProjects)
const getFailureRecordCb = callbackify(getFailureRecord) const getFailureRecordCb = callbackify(getFailureRecord)
const getFailuresCb = callbackify(getFailures) const getFailuresCb = callbackify(getFailures)
const cloneFailureCb = callbackify(cloneFailure)
const getLastFailureCb = callbackify(getLastFailure) const getLastFailureCb = callbackify(getLastFailure)
const recordCb = callbackify(record) const recordCb = callbackify(record)
const clearErrorCb = callbackify(clearError) const clearErrorCb = callbackify(clearError)
@@ -245,6 +263,7 @@ const recordSyncStartCb = callbackify(recordSyncStart)
const setForceDebugCb = callbackify(setForceDebug) const setForceDebugCb = callbackify(setForceDebug)
export { export {
cloneFailureCb as cloneFailure,
getFailedProjectsCb as getFailedProjects, getFailedProjectsCb as getFailedProjects,
getFailureRecordCb as getFailureRecord, getFailureRecordCb as getFailureRecord,
getLastFailureCb as getLastFailure, getLastFailureCb as getLastFailure,
@@ -257,6 +276,7 @@ export {
export const promises = { export const promises = {
getFailedProjects, getFailedProjects,
cloneFailure,
getFailureRecord, getFailureRecord,
getLastFailure, getLastFailure,
getFailures, getFailures,
@@ -17,7 +17,7 @@ import * as Errors from './Errors.js'
import * as LocalFileWriter from './LocalFileWriter.js' import * as LocalFileWriter from './LocalFileWriter.js'
import * as HashManager from './HashManager.js' import * as HashManager from './HashManager.js'
import * as HistoryBlobTranslator from './HistoryBlobTranslator.js' import * as HistoryBlobTranslator from './HistoryBlobTranslator.js'
import { promisifyMultiResult } from '@overleaf/promise-utils' import { callbackify, promisifyMultiResult } from '@overleaf/promise-utils'
const HTTP_REQUEST_TIMEOUT = Settings.overleaf.history.requestTimeout const HTTP_REQUEST_TIMEOUT = Settings.overleaf.history.requestTimeout
@@ -531,6 +531,18 @@ export function initializeProject(historyId, callback) {
) )
} }
async function _cloneProject(sourceProjectId, targetProjectId, signal) {
return await fetchStream(
`${Settings.overleaf.history.host}/projects/${sourceProjectId}/clone`,
{
method: 'POST',
json: { targetProjectId },
...getHistoryFetchOptions(),
signal,
}
)
}
export function deleteProject(projectId, callback) { export function deleteProject(projectId, callback) {
_requestHistoryService( _requestHistoryService(
{ method: 'DELETE', path: `projects/${projectId}` }, { method: 'DELETE', path: `projects/${projectId}` },
@@ -623,6 +635,8 @@ function _requestHistoryService(options, callback) {
}) })
} }
export const cloneProject = callbackify(_cloneProject)
export const promises = { export const promises = {
/** @type {(projectId: string, historyId: string) => Promise<{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}>} */ /** @type {(projectId: string, historyId: string) => Promise<{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}>} */
getMostRecentChunk: promisify(getMostRecentChunk), getMostRecentChunk: promisify(getMostRecentChunk),
@@ -640,4 +654,5 @@ export const promises = {
createBlobForUpdate: promisify(createBlobForUpdate), createBlobForUpdate: promisify(createBlobForUpdate),
initializeProject: promisify(initializeProject), initializeProject: promisify(initializeProject),
deleteProject: promisify(deleteProject), deleteProject: promisify(deleteProject),
cloneProject: _cloneProject,
} }
@@ -14,12 +14,114 @@ import * as LabelsManager from './LabelsManager.js'
import * as HistoryApiManager from './HistoryApiManager.js' import * as HistoryApiManager from './HistoryApiManager.js'
import * as RetryManager from './RetryManager.js' import * as RetryManager from './RetryManager.js'
import * as FlushManager from './FlushManager.js' import * as FlushManager from './FlushManager.js'
import { pipeline } from 'node:stream' import Stream, { pipeline } from 'node:stream'
import { fetchNothing, RequestFailedError } from '@overleaf/fetch-utils' import { fetchNothing, RequestFailedError } from '@overleaf/fetch-utils'
import { z, zz, parseReq } from '@overleaf/validation-tools' import { z, zz, parseReq } from '@overleaf/validation-tools'
import { IncrementalResponse } from '@overleaf/stream-utils'
const ONE_DAY_IN_SECONDS = 24 * 60 * 60 const ONE_DAY_IN_SECONDS = 24 * 60 * 60
const cloneProjectSchema = z.object({
body: z.object({
targetProjectId: z.string(),
}),
params: z.object({
project_id: z.string(),
}),
})
export function cloneProject(req, res) {
const {
params: { project_id: sourceProjectId },
body: { targetProjectId },
} = parseReq(req, cloneProjectSchema)
const incrResp = new IncrementalResponse({
res,
timeout: 10 * 60_000 - 5_000,
logger,
label: 'clone history in project-history',
info: { targetProjectId, sourceProjectId },
})
WebApiManager.getHistoryId(targetProjectId, (err, targetHistoryId) => {
if (err) return incrResp.fail(OError.tag(err, 'get target historyId'))
WebApiManager.getHistoryId(sourceProjectId, (err, sourceHistoryId) => {
if (err) return incrResp.fail(OError.tag(err, 'get source historyId'))
incrResp.sendUpdate('cloning full project history data: pending')
HistoryStoreManager.cloneProject(
sourceHistoryId.toString(),
targetHistoryId.toString(),
incrResp.signal(),
(err, stream) => {
if (err) {
incrResp.fail(OError.tag(err, 'clone history-v1 data'))
return
}
// aborted. pipeline() would throw.
if (res.destroyed) {
stream.destroy()
incrResp.fail(new Error('request aborted'))
return
}
// The stream.pipeline callback API does not support options.
Stream.promises.pipeline(stream, res, { end: false }).then(
() => {
incrResp.sendUpdate('clone labels: pending')
LabelsManager.cloneLabels(
sourceProjectId,
targetProjectId,
err => {
if (err) {
incrResp.fail(OError.tag(err, 'clone labels'))
return
}
incrResp.sendUpdate('clone labels: done')
incrResp.sendUpdate('clone resync state: pending')
SyncManager.cloneResyncState(
sourceProjectId,
targetProjectId,
err => {
if (err) {
incrResp.fail(OError.tag(err, 'clone resync state'))
return
}
incrResp.sendUpdate('clone resync state: done')
incrResp.sendUpdate('clone failure record: pending')
ErrorRecorder.cloneFailure(
sourceProjectId,
targetProjectId,
err => {
if (err) {
incrResp.fail(OError.tag(err, 'clone failure'))
return
}
incrResp.sendUpdate('clone failure record: done')
incrResp.sendUpdate('done')
incrResp.end()
}
)
}
)
}
)
},
err => {
incrResp.fail(OError.tag(err, 'stream history-v1 response'))
}
)
}
)
})
})
}
const getProjectBlobSchema = z.object({ const getProjectBlobSchema = z.object({
params: z.object({ params: z.object({
history_id: zz.objectId().or(z.coerce.number()), history_id: zz.objectId().or(z.coerce.number()),
@@ -4,6 +4,26 @@ import * as HistoryStoreManager from './HistoryStoreManager.js'
import * as UpdatesProcessor from './UpdatesProcessor.js' import * as UpdatesProcessor from './UpdatesProcessor.js'
import * as WebApiManager from './WebApiManager.js' import * as WebApiManager from './WebApiManager.js'
export function cloneLabels(sourceProjectId, targetProjectId, callback) {
db.projectHistoryLabels
.find({ project_id: new ObjectId(sourceProjectId) })
.project({ _id: 0, project_id: 0 })
.toArray((err, labels) => {
if (err) return callback(OError.tag(err))
if (labels.length === 0) return callback()
db.projectHistoryLabels.insertMany(
labels.map(label => ({
...label,
project_id: new ObjectId(targetProjectId),
})),
err => {
if (err) return callback(OError.tag(err))
callback()
}
)
})
}
export function getLabels(projectId, callback) { export function getLabels(projectId, callback) {
_toObjectId(projectId, function (error, projectId) { _toObjectId(projectId, function (error, projectId) {
if (error) { if (error) {
@@ -83,6 +83,8 @@ export function initialize(app) {
app.get('/project/:history_id/blob/:hash', HttpController.getProjectBlob) app.get('/project/:history_id/blob/:hash', HttpController.getProjectBlob)
app.post('/project/:project_id/clone', HttpController.cloneProject)
app.get('/status/failures', HttpController.getFailures) app.get('/status/failures', HttpController.getFailures)
app.get('/status/queue', HttpController.getQueueCounts) app.get('/status/queue', HttpController.getQueueCounts)
@@ -109,6 +109,23 @@ async function startResyncWithoutLock(projectId, options) {
await setResyncState(projectId, syncState) await setResyncState(projectId, syncState)
} }
/**
* @param {string} sourceProjectId
* @param {string} targetProjectId
* @return {Promise<void>}
*/
async function cloneResyncState(sourceProjectId, targetProjectId) {
const rawSyncState = await db.projectHistorySyncState.findOne(
{ project_id: new ObjectId(sourceProjectId) },
{ projection: { _id: 0, project_id: 0 } }
)
if (!rawSyncState) return
await db.projectHistorySyncState.insertOne({
...rawSyncState,
project_id: new ObjectId(targetProjectId),
})
}
/** /**
* @param {string} projectId * @param {string} projectId
* @return {Promise<SyncState>} * @return {Promise<SyncState>}
@@ -1329,6 +1346,7 @@ function trackingDirectivesEqual(a, b) {
// EXPORTS // EXPORTS
const cloneResyncStateCb = callbackify(cloneResyncState)
const getResyncStateCb = callbackify(getResyncState) const getResyncStateCb = callbackify(getResyncState)
const startResyncCb = callbackify(startResync) const startResyncCb = callbackify(startResync)
const startResyncWithoutLockCb = callbackify(startResyncWithoutLock) const startResyncWithoutLockCb = callbackify(startResyncWithoutLock)
@@ -1373,6 +1391,7 @@ const expandSyncUpdatesCb = (
} }
export { export {
cloneResyncStateCb as cloneResyncState,
getResyncStateCb as getResyncState, getResyncStateCb as getResyncState,
startResyncCb as startResync, startResyncCb as startResync,
startResyncWithoutLockCb as startResyncWithoutLock, startResyncWithoutLockCb as startResyncWithoutLock,
@@ -1384,6 +1403,7 @@ export {
} }
export const promises = { export const promises = {
cloneResyncState,
getResyncState, getResyncState,
startResync, startResync,
startResyncWithoutLock, startResyncWithoutLock,
@@ -82,6 +82,29 @@ async function getAllDocs(projectId) {
} }
} }
/**
* @param {string} projectId
*/
async function getAllDocsWithRanges(projectId) {
const url = new URL(settings.apis.docstore.url)
url.pathname = path.posix.join(
'project',
projectId.toString(),
'doc-with-ranges'
)
try {
return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
} catch (error) {
if (error instanceof RequestFailedError) {
throw new OError('docstore api responded with non-success code', {
projectId,
status: error.response.status,
})
}
throw error
}
}
/** /**
* *
* @param {string|ObjectId} projectId * @param {string|ObjectId} projectId
@@ -395,6 +418,7 @@ export default {
deleteDoc, deleteDoc,
getAllDocVersions, getAllDocVersions,
getAllDocs, getAllDocs,
getAllDocsWithRanges,
getAllDeletedDocs, getAllDeletedDocs,
getAllRanges, getAllRanges,
getDoc, getDoc,
@@ -2,6 +2,7 @@ import { callbackify } from 'node:util'
import { import {
fetchJson, fetchJson,
fetchNothing, fetchNothing,
fetchStream,
fetchStreamWithResponse, fetchStreamWithResponse,
RequestFailedError, RequestFailedError,
} from '@overleaf/fetch-utils' } from '@overleaf/fetch-utils'
@@ -59,6 +60,17 @@ async function initializeProject(projectId) {
return historyId return historyId
} }
async function cloneProject(sourceProjectId, targetProjectId) {
return await fetchStream(
`${settings.apis.project_history.url}/project/${sourceProjectId}/clone`,
{
method: 'POST',
json: { targetProjectId },
signal: AbortSignal.timeout(10 * 60_000),
}
)
}
async function flushProject(projectId) { async function flushProject(projectId) {
try { try {
await fetchNothing( await fetchNothing(
@@ -460,6 +472,7 @@ export default {
getChanges: callbackify(getChanges), getChanges: callbackify(getChanges),
promises: { promises: {
initializeProject, initializeProject,
cloneProject,
flushProject, flushProject,
resyncProject, resyncProject,
deleteProject, deleteProject,
@@ -263,12 +263,17 @@ const _ProjectController = {
res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete
metrics.inc('cloned-project') metrics.inc('cloned-project')
const projectId = req.params.Project_id const projectId = req.params.Project_id
const { projectName, isDebugCopy, tags } = req.body let { projectName, isDebugCopy, cloneHistory, cloneRanges, tags } = req.body
const currentUser = SessionManager.getSessionUser(req.session)
if (!hasAdminAccess(currentUser)) {
isDebugCopy = false
cloneHistory = false
cloneRanges = false
}
logger.debug({ projectId, projectName, isDebugCopy }, 'cloning project') logger.debug({ projectId, projectName, isDebugCopy }, 'cloning project')
if (!SessionManager.isUserLoggedIn(req.session)) { if (!SessionManager.isUserLoggedIn(req.session)) {
return res.json({ redir: '/register' }) return res.json({ redir: '/register' })
} }
const currentUser = SessionManager.getSessionUser(req.session)
const { first_name: firstName, last_name: lastName, email } = currentUser const { first_name: firstName, last_name: lastName, email } = currentUser
try { try {
const project = await ProjectDuplicator.promises.duplicate( const project = await ProjectDuplicator.promises.duplicate(
@@ -276,7 +281,7 @@ const _ProjectController = {
projectId, projectId,
projectName, projectName,
tags, tags,
isDebugCopy { isDebugCopy, cloneHistory, cloneRanges }
) )
ProjectAuditLogHandler.addEntryIfManagedInBackground( ProjectAuditLogHandler.addEntryIfManagedInBackground(
projectId, projectId,
@@ -38,7 +38,11 @@ async function duplicate(
originalProjectId, originalProjectId,
newProjectName, newProjectName,
tags = [], tags = [],
isDebugCopy opts = {
isDebugCopy: false,
cloneHistory: false,
cloneRanges: false,
}
) { ) {
await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId) await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
const originalProject = await ProjectGetter.promises.getProject( const originalProject = await ProjectGetter.promises.getProject(
@@ -54,7 +58,7 @@ async function duplicate(
} }
) )
const { path: rootDocPath } = await ProjectLocator.promises.findRootDoc({ const { path: rootDocPath } = await ProjectLocator.promises.findRootDoc({
project_id: originalProjectId, project: originalProject,
}) })
const originalEntries = _getFolderEntries(originalProject.rootFolder[0]) const originalEntries = _getFolderEntries(originalProject.rootFolder[0])
@@ -69,7 +73,7 @@ async function duplicate(
}) })
const attributes = {} const attributes = {}
if (isDebugCopy) { if (opts.isDebugCopy) {
attributes.isDebugCopyOf = originalProjectId attributes.isDebugCopyOf = originalProjectId
// - Create new tag on owner._id if it doesn't already exist // - Create new tag on owner._id if it doesn't already exist
const debugTag = await TagsHandler.promises.createTag( const debugTag = await TagsHandler.promises.createTag(
@@ -101,6 +105,18 @@ async function duplicate(
// remove any leading or trailing spaces // remove any leading or trailing spaces
newProjectName = newProjectName.trim() newProjectName = newProjectName.trim()
if (
opts.cloneHistory &&
typeof originalProject.overleaf?.history?.id === 'number'
) {
// Obtain an old history id. We want to store the data in the same DB.
const newHistoryId = parseInt(
await HistoryManager.promises.initializeProject(),
10
)
attributes.overleaf = { history: { id: newHistoryId } }
}
// Now create the new project, cleaning it up on failure if necessary // Now create the new project, cleaning it up on failure if necessary
const newProject = await ProjectCreationHandler.promises.createBlankProject( const newProject = await ProjectCreationHandler.promises.createBlankProject(
owner._id, owner._id,
@@ -129,8 +145,18 @@ async function duplicate(
originalProject.compiler originalProject.compiler
) )
const [docEntries, fileEntries] = await Promise.all([ const [docEntries, fileEntries] = await Promise.all([
_copyDocs(originalEntries.docEntries, originalProject, newProject), _copyDocs(
_copyFiles(originalEntries.fileEntries, originalProject, newProject), originalEntries.docEntries,
originalProject,
newProject,
opts.cloneRanges
),
_copyFiles(
originalEntries.fileEntries,
originalProject,
newProject,
opts.cloneHistory
),
]) ])
const projectVersion = const projectVersion =
await ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure( await ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure(
@@ -139,17 +165,23 @@ async function duplicate(
fileEntries fileEntries
) )
// Silently ignore the rootDoc in case it's not valid per the new limits. // Silently ignore the rootDoc in case it's not valid per the new limits.
// Ignore the new limits in case we are creating a debug copoy.
if ( if (
rootDocPath && rootDocPath &&
ProjectEntityUpdateHandler.isPathValidForRootDoc(rootDocPath.fileSystem) (ProjectEntityUpdateHandler.isPathValidForRootDoc(
rootDocPath.fileSystem
) ||
opts.isDebugCopy)
) { ) {
await _setRootDoc(newProject._id, rootDocPath.fileSystem) await _setRootDoc(newProject._id, rootDocPath.fileSystem)
} }
await _notifyDocumentUpdater(newProject, owner._id, { if (!opts.cloneHistory) {
newFiles: fileEntries, await _notifyDocumentUpdater(newProject, owner._id, {
newDocs: docEntries, newFiles: fileEntries,
newProject: { version: projectVersion }, newDocs: docEntries,
}) newProject: { version: projectVersion },
})
}
await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id) await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id)
if (tags?.length > 0) { if (tags?.length > 0) {
@@ -218,33 +250,50 @@ function _getFolderEntries(folder, folderPath = '/') {
return { docEntries, fileEntries } return { docEntries, fileEntries }
} }
async function _copyDocs(sourceEntries, sourceProject, targetProject) { async function _copyDocs(
const docLinesById = await _getDocLinesForProject(sourceProject._id) sourceEntries,
sourceProject,
targetProject,
cloneRanges
) {
const docsById = await _getDocContentForProject(
sourceProject._id,
cloneRanges
)
const targetEntries = [] const targetEntries = []
for (const sourceEntry of sourceEntries) { for (const sourceEntry of sourceEntries) {
const sourceDoc = sourceEntry.doc const sourceDoc = sourceEntry.doc
const path = sourceEntry.path const path = sourceEntry.path
const doc = new Doc({ name: sourceDoc.name }) const doc = new Doc({ name: sourceDoc.name })
const docLines = docLinesById.get(sourceDoc._id.toString()) const { lines, ranges } = docsById.get(sourceDoc._id.toString())
await DocstoreManager.promises.updateDoc( await DocstoreManager.promises.updateDoc(
targetProject._id.toString(), targetProject._id.toString(),
doc._id.toString(), doc._id.toString(),
docLines, lines,
0, 0,
{} ranges || {}
) )
targetEntries.push({ doc, path, docLines: docLines.join('\n') }) targetEntries.push({ doc, path, docLines: lines.join('\n') })
} }
return targetEntries return targetEntries
} }
async function _getDocLinesForProject(projectId) { async function _getDocContentForProject(projectId, cloneRanges) {
const docs = await DocstoreManager.promises.getAllDocs(projectId) let docs
const docLinesById = new Map(docs.map(doc => [doc._id, doc.lines])) if (cloneRanges) {
return docLinesById docs = await DocstoreManager.promises.getAllDocsWithRanges(projectId)
} else {
docs = await DocstoreManager.promises.getAllDocs(projectId)
}
return new Map(docs.map(doc => [doc._id, doc]))
} }
async function _copyFiles(sourceEntries, sourceProject, targetProject) { async function _copyFiles(
sourceEntries,
sourceProject,
targetProject,
cloneHistory
) {
const sourceHistoryId = sourceProject.overleaf?.history?.id const sourceHistoryId = sourceProject.overleaf?.history?.id
const targetHistoryId = targetProject.overleaf?.history?.id const targetHistoryId = targetProject.overleaf?.history?.id
if (!sourceHistoryId) { if (!sourceHistoryId) {
@@ -268,6 +317,10 @@ async function _copyFiles(sourceEntries, sourceProject, targetProject) {
file.linkedFileData = sourceFile.linkedFileData file.linkedFileData = sourceFile.linkedFileData
file.created = sourceFile.created file.created = sourceFile.created
} }
if (cloneHistory) {
// All blobs will be cloned in bulk. Do not clone each individually.
return { createdBlob: true, file, path }
}
try { try {
await HistoryManager.promises.copyBlob( await HistoryManager.promises.copyBlob(
sourceHistoryId, sourceHistoryId,
@@ -185,11 +185,10 @@ const ModeSwitcherToggleButtonContent = forwardRef<
onClick(event) onClick(event)
}} }}
aria-expanded={ariaExpanded} aria-expanded={ariaExpanded}
aria-label={label}
> >
<MaterialIcon type={iconType} /> <MaterialIcon type={iconType} />
<div className="review-mode-switcher-toggle-label" aria-label={label}> <div className="review-mode-switcher-toggle-label">{label}</div>
{label}
</div>
<MaterialIcon type="keyboard_arrow_down" /> <MaterialIcon type="keyboard_arrow_down" />
</button> </button>
) )
@@ -190,7 +190,7 @@ function fetchJSON<T>(
}) })
} }
async function parseResponseBody(response: Response) { export async function parseResponseBody(response: Response) {
const contentType = response.headers.get('Content-Type') const contentType = response.headers.get('Content-Type')
if (!contentType) { if (!contentType) {