mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
[monorepo] read empty content from its hash, in a shared blob store base (#36831)
The hash of empty content is a property of the content rather than of anything stored: every project's empty file has it, whether or not a blob was ever written under it, and a file may reference it before anything has. Eight blob stores were each paying a lookup -- a database query, an HTTP request, a read of a cache that offline mode may be the only thing standing in for -- to be told what the hash already says, and clsi had already reinvented the answer on its own. overleaf-editor-core gains BlobStoreBase, which answers getString for that hash and delegates the rest to the fetchString its subclasses provide. getObject comes with it: four stores spelled out the same JSON.parse of getString, and a fifth wrapped it in a log line. Adopted by history-v1's BlobStore, BackupBlobStore, HashCheckBlobStore and recover_zip's RecoveryBlobStore, by project-history's BlobStore, by clsi's BlobStore, and by web's HistoryBlobStore, SimpleBlobStore and FrontendBlobStore. history-v1 keeps its own answers in getStream, getBlob and getBlobs, which no other store implements. Writing and copying a blob are untouched, so a project that wrote empty content still has a blob for it and a read answers the same either way. Two paths could not handle a project holding a tracked change or a comment at all. Loading such a file needs the ranges blob its rangesHash points at, and hashing one again needs to write it back, so a store missing getObject or putObject fails outright: - recover_zip died on `blobStore.getObject is not a function`, making every such project unrecoverable. The test covering it asserts a tracked delete is absent from the zip, which the script already asked for and never reached. - GET /projects/:project_id/latest/hashed_content returned 500 from the same cause, then would have hit a missing putObject in snapshot.store. HashCheckBlobStore becomes a class over the base and gains putObject. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> GitOrigin-RevId: c84f62465beff6cf77ffac26aa772984e1b554cb
This commit is contained in:
committed by
Copybot
co-authored by
Claude Opus 5
parent
7608d738a6
commit
6caa5fc3a3
@@ -1,4 +1,5 @@
|
||||
import { text } from 'node:stream/consumers'
|
||||
import { BlobStoreBase } from 'overleaf-editor-core'
|
||||
import HistoryManager from './HistoryManager.mjs'
|
||||
|
||||
/**
|
||||
@@ -8,11 +9,12 @@ import HistoryManager from './HistoryManager.mjs'
|
||||
* eagerly here -- needed for the ones with pending edit operations, whose content
|
||||
* is not in a single blob.
|
||||
*/
|
||||
class HistoryBlobStore {
|
||||
class HistoryBlobStore extends BlobStoreBase {
|
||||
/**
|
||||
* @param {string} historyId
|
||||
*/
|
||||
constructor(historyId) {
|
||||
super()
|
||||
this.historyId = historyId
|
||||
}
|
||||
|
||||
@@ -20,21 +22,13 @@ class HistoryBlobStore {
|
||||
* @param {string} hash
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getString(hash) {
|
||||
async fetchString(hash) {
|
||||
const { stream } = await HistoryManager.promises.requestBlob(
|
||||
this.historyId,
|
||||
hash
|
||||
)
|
||||
return await text(stream)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
async getObject(hash) {
|
||||
return JSON.parse(await this.getString(hash))
|
||||
}
|
||||
}
|
||||
|
||||
export default HistoryBlobStore
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import pLimit from 'p-limit'
|
||||
import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core'
|
||||
import {
|
||||
BlobStoreBase,
|
||||
Change,
|
||||
Chunk,
|
||||
Snapshot,
|
||||
File,
|
||||
} from 'overleaf-editor-core'
|
||||
import type { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
|
||||
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
import path from 'path-browserify'
|
||||
@@ -151,11 +157,13 @@ export class ProjectSnapshot {
|
||||
if (byteLength == null) {
|
||||
return null
|
||||
}
|
||||
let blobStoreOptions
|
||||
if (options?.maxSize != null && byteLength > options?.maxSize) {
|
||||
blobStoreOptions = { maxSize: options.maxSize }
|
||||
const blobStoreOptions = { maxSize: options.maxSize }
|
||||
// Straight to fetchString: a binary file large enough to be read with a size
|
||||
// limit is never the empty content the base class answers for.
|
||||
return await this.blobStore.fetchString(hash, blobStoreOptions)
|
||||
}
|
||||
return await this.blobStore.getString(hash, blobStoreOptions)
|
||||
return await this.blobStore.getString(hash)
|
||||
}
|
||||
|
||||
getDocs(): Map<string, File> {
|
||||
@@ -256,24 +264,20 @@ export class ProjectSnapshot {
|
||||
/**
|
||||
* Blob store that fetches blobs from the history service
|
||||
*/
|
||||
export class SimpleBlobStore {
|
||||
export class SimpleBlobStore extends BlobStoreBase {
|
||||
private projectId: string
|
||||
|
||||
constructor(projectId: string) {
|
||||
super()
|
||||
this.projectId = projectId
|
||||
}
|
||||
|
||||
async getString(
|
||||
async fetchString(
|
||||
hash: string,
|
||||
options?: { maxSize?: number }
|
||||
): Promise<string> {
|
||||
return await fetchBlob(this.projectId, hash, options)
|
||||
}
|
||||
|
||||
async getObject(hash: string) {
|
||||
const blob = await this.getString(hash)
|
||||
return JSON.parse(blob)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushHistory(projectId: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import { File } from 'overleaf-editor-core'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
|
||||
describe('ProjectSnapshot', function () {
|
||||
@@ -714,4 +715,90 @@ describe('ProjectSnapshot', function () {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blob with the hash of empty content', function () {
|
||||
// Nothing is stored under that hash, on the server or here, so the content is
|
||||
// answered from the hash rather than fetched. A new empty doc references it before
|
||||
// anything has written it, so a fetch would 404 and take the whole snapshot down.
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('loads an empty doc without fetching the blob', async function () {
|
||||
const emptyChunk = {
|
||||
history: {
|
||||
snapshot: { files: {} },
|
||||
changes: [
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'fresh.tex',
|
||||
file: { hash: File.EMPTY_FILE_HASH, stringLength: 0 },
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-01T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
startVersion: 0,
|
||||
}
|
||||
|
||||
fetchMock.post(`/project/${projectId}/flush`, 200)
|
||||
fetchMock.getOnce(`/project/${projectId}/latest/history`, {
|
||||
chunk: emptyChunk,
|
||||
})
|
||||
// Served only if the short circuit does not answer first, in which case the
|
||||
// content below is what comes back.
|
||||
fetchMock.get(
|
||||
`/project/${projectId}/blob/${File.EMPTY_FILE_HASH}`,
|
||||
'fetched from the server'
|
||||
)
|
||||
|
||||
await snapshot.refresh()
|
||||
|
||||
expect(snapshot.getDocPaths()).to.have.members(['fresh.tex'])
|
||||
expect(snapshot.getDocContents('fresh.tex')).to.equal('')
|
||||
})
|
||||
|
||||
it('applies edits made on top of an empty doc', async function () {
|
||||
const insertedText = '\\documentclass{article}\n'
|
||||
const emptyChunk = {
|
||||
history: {
|
||||
snapshot: { files: {} },
|
||||
changes: [
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'fresh.tex',
|
||||
file: { hash: File.EMPTY_FILE_HASH, stringLength: 0 },
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-01T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{ pathname: 'fresh.tex', textOperation: [insertedText] },
|
||||
],
|
||||
timestamp: '2025-01-01T12:01:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
startVersion: 0,
|
||||
}
|
||||
|
||||
fetchMock.post(`/project/${projectId}/flush`, 200)
|
||||
fetchMock.getOnce(`/project/${projectId}/latest/history`, {
|
||||
chunk: emptyChunk,
|
||||
})
|
||||
fetchMock.get(
|
||||
`/project/${projectId}/blob/${File.EMPTY_FILE_HASH}`,
|
||||
'fetched from the server'
|
||||
)
|
||||
|
||||
await snapshot.refresh()
|
||||
|
||||
expect(snapshot.getDocContents('fresh.tex')).to.equal(insertedText)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Blob } from 'overleaf-editor-core'
|
||||
|
||||
const modulePath = '../../../../app/src/Features/History/HistoryBlobStore.mjs'
|
||||
|
||||
const HASH = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678'
|
||||
|
||||
describe('HistoryBlobStore', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.HistoryManager = {
|
||||
promises: { requestBlob: sinon.stub() },
|
||||
}
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/History/HistoryManager.mjs',
|
||||
() => ({
|
||||
default: ctx.HistoryManager,
|
||||
})
|
||||
)
|
||||
|
||||
const HistoryBlobStore = (await import(modulePath)).default
|
||||
ctx.blobStore = new HistoryBlobStore('history-id')
|
||||
})
|
||||
|
||||
describe('getString', function () {
|
||||
it('reads the blob of the given hash', async function (ctx) {
|
||||
ctx.HistoryManager.promises.requestBlob.resolves({
|
||||
stream: Readable.from(['hello ', 'world']),
|
||||
})
|
||||
|
||||
expect(await ctx.blobStore.getString(HASH)).to.equal('hello world')
|
||||
expect(ctx.HistoryManager.promises.requestBlob).to.have.been.calledWith(
|
||||
'history-id',
|
||||
HASH
|
||||
)
|
||||
})
|
||||
|
||||
it('passes on errors from history', async function (ctx) {
|
||||
ctx.HistoryManager.promises.requestBlob.rejects(new Error('boom'))
|
||||
|
||||
await expect(ctx.blobStore.getString(HASH)).to.be.rejectedWith('boom')
|
||||
})
|
||||
|
||||
it('answers the hash of empty content without asking history', async function (ctx) {
|
||||
// Nothing is stored under it, so history-v1 answers it without a lookup of its
|
||||
// own: the round-trip to ask can only be slow or fail.
|
||||
ctx.HistoryManager.promises.requestBlob.resolves({
|
||||
stream: Readable.from(['fetched from history-v1']),
|
||||
})
|
||||
|
||||
expect(await ctx.blobStore.getString(Blob.EMPTY_HASH)).to.equal('')
|
||||
})
|
||||
|
||||
it('answers the hash of empty content when history is down', async function (ctx) {
|
||||
ctx.HistoryManager.promises.requestBlob.rejects(new Error('boom'))
|
||||
|
||||
expect(await ctx.blobStore.getString(Blob.EMPTY_HASH)).to.equal('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getObject', function () {
|
||||
it('parses the blob of the given hash as JSON', async function (ctx) {
|
||||
ctx.HistoryManager.promises.requestBlob.resolves({
|
||||
stream: Readable.from(['{"comments":[]}']),
|
||||
})
|
||||
|
||||
expect(await ctx.blobStore.getObject(HASH)).to.deep.equal({
|
||||
comments: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user