[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:
Jakob Ackermann
2026-09-17 12:06:30 +00:00
committed by Copybot
co-authored by Claude Opus 5
parent 7608d738a6
commit 6caa5fc3a3
18 changed files with 646 additions and 71 deletions
@@ -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: [],
})
})
})
})