mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-06 15:49:01 +02:00
Merge pull request #24768 from overleaf/bg-history-redis-buffer
test redis caching when loading latest chunk in history-v1 GitOrigin-RevId: f0ee09e5e9e1d7605e228913cb8539be4134e1f7
This commit is contained in:
@@ -5,6 +5,7 @@ exports.chunkStore = require('./lib/chunk_store')
|
||||
exports.historyStore = require('./lib/history_store').historyStore
|
||||
exports.knex = require('./lib/knex')
|
||||
exports.mongodb = require('./lib/mongodb')
|
||||
exports.redis = require('./lib/redis')
|
||||
exports.persistChanges = require('./lib/persist_changes')
|
||||
exports.persistor = require('./lib/persistor')
|
||||
exports.ProjectArchive = require('./lib/project_archive')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const OError = require('@overleaf/o-error')
|
||||
|
||||
const check = require('check-types')
|
||||
const { Blob } = require('overleaf-editor-core')
|
||||
|
||||
@@ -14,15 +16,23 @@ function transaction(transaction, message) {
|
||||
}
|
||||
|
||||
function blobHash(arg, message) {
|
||||
assert.match(arg, Blob.HEX_HASH_RX, message)
|
||||
try {
|
||||
assert.match(arg, Blob.HEX_HASH_RX, message)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, message, { arg })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk id is a string that contains either an integer (for projects stored in Postgres) or 24
|
||||
* A project id is a string that contains either an integer (for projects stored in Postgres) or 24
|
||||
* hex digits (for projects stored in Mongo)
|
||||
*/
|
||||
function projectId(arg, message) {
|
||||
assert.match(arg, PROJECT_ID_REGEXP, message)
|
||||
try {
|
||||
assert.match(arg, PROJECT_ID_REGEXP, message)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, message, { arg })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,16 +42,25 @@ function projectId(arg, message) {
|
||||
function chunkId(arg, message) {
|
||||
const valid = check.integer(arg) || check.match(arg, MONGO_ID_REGEXP)
|
||||
if (!valid) {
|
||||
throw new TypeError(message)
|
||||
const error = new TypeError(message)
|
||||
throw OError.tag(error, message, { arg })
|
||||
}
|
||||
}
|
||||
|
||||
function mongoId(arg, message) {
|
||||
assert.match(arg, MONGO_ID_REGEXP)
|
||||
try {
|
||||
assert.match(arg, MONGO_ID_REGEXP, message)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, message, { arg })
|
||||
}
|
||||
}
|
||||
|
||||
function postgresId(arg, message) {
|
||||
assert.match(arg, POSTGRES_ID_REGEXP, message)
|
||||
try {
|
||||
assert.match(arg, POSTGRES_ID_REGEXP, message)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, message, { arg })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -13,7 +13,7 @@ async function initialize(projectId) {
|
||||
* Return blob metadata for the given project and hash
|
||||
*/
|
||||
async function findBlob(projectId, hash) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
assert.blobHash(hash, 'bad hash')
|
||||
|
||||
@@ -35,7 +35,7 @@ async function findBlob(projectId, hash) {
|
||||
* @return {Promise.<Array.<Blob?>>} no guarantee on order
|
||||
*/
|
||||
async function findBlobs(projectId, hashes) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
assert.array(hashes, 'bad hashes: not array')
|
||||
hashes.forEach(function (hash) {
|
||||
@@ -57,7 +57,7 @@ async function findBlobs(projectId, hashes) {
|
||||
* Return metadata for all blobs in the given project
|
||||
*/
|
||||
async function getProjectBlobs(projectId) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
const records = await knex('project_blobs')
|
||||
@@ -103,7 +103,7 @@ async function getProjectBlobsBatch(projectIds) {
|
||||
* Add a blob's metadata to the blobs table after it has been uploaded.
|
||||
*/
|
||||
async function insertBlob(projectId, blob) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
await knex('project_blobs')
|
||||
@@ -116,7 +116,7 @@ async function insertBlob(projectId, blob) {
|
||||
* Deletes all blobs for a given project
|
||||
*/
|
||||
async function deleteBlobs(projectId) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
await knex('project_blobs').where('project_id', projectId).delete()
|
||||
|
||||
@@ -30,6 +30,7 @@ const { BlobStore } = require('../blob_store')
|
||||
const { historyStore } = require('../history_store')
|
||||
const mongoBackend = require('./mongo')
|
||||
const postgresBackend = require('./postgres')
|
||||
const redisBackend = require('./redis')
|
||||
const { ChunkVersionConflictError } = require('./errors')
|
||||
|
||||
const DEFAULT_DELETE_BATCH_SIZE = parseInt(config.get('maxDeleteKeys'), 10)
|
||||
@@ -104,13 +105,23 @@ async function loadLatestRaw(projectId, opts) {
|
||||
* @return {Promise.<Chunk>}
|
||||
*/
|
||||
async function loadLatest(projectId) {
|
||||
// Test out the redis caching backend - not in use yet
|
||||
const cachedChunk = await redisBackend.getCurrentChunk(projectId)
|
||||
const chunkRecord = await loadLatestRaw(projectId)
|
||||
const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id)
|
||||
const history = History.fromRaw(rawHistory)
|
||||
const blobStore = new BlobStore(projectId)
|
||||
const batchBlobStore = new BatchBlobStore(blobStore)
|
||||
await lazyLoadHistoryFiles(history, batchBlobStore)
|
||||
return new Chunk(history, chunkRecord.startVersion)
|
||||
const chunk = new Chunk(history, chunkRecord.startVersion)
|
||||
// if the cached chunk is no longer valid, update it
|
||||
const cachedChunkIsValid = redisBackend.checkCacheValidity(cachedChunk, chunk)
|
||||
if (!cachedChunkIsValid) {
|
||||
await redisBackend.setCurrentChunk(projectId, chunk)
|
||||
} else {
|
||||
await redisBackend.compareChunks(projectId, cachedChunk, chunk)
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ const DUPLICATE_KEY_ERROR_CODE = '23505'
|
||||
* @param {boolean} [opts.readOnly]
|
||||
*/
|
||||
async function getLatestChunk(projectId, opts = {}) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
const { readOnly = false } = opts
|
||||
|
||||
@@ -32,7 +32,7 @@ async function getLatestChunk(projectId, opts = {}) {
|
||||
* Get the metadata for the chunk that contains the given version.
|
||||
*/
|
||||
async function getChunkForVersion(projectId, version) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
const record = await knex('chunks')
|
||||
@@ -104,7 +104,7 @@ async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
|
||||
* the given timestamp.
|
||||
*/
|
||||
async function getChunkForTimestamp(projectId, timestamp) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
// This query will find the latest chunk after the timestamp (query orders
|
||||
@@ -148,7 +148,7 @@ function chunkFromRecord(record) {
|
||||
* Get all of a project's chunk ids
|
||||
*/
|
||||
async function getProjectChunkIds(projectId) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
const records = await knex('chunks').select('id').where('doc_id', projectId)
|
||||
@@ -159,7 +159,7 @@ async function getProjectChunkIds(projectId) {
|
||||
* Get all of a projects chunks directly
|
||||
*/
|
||||
async function getProjectChunks(projectId) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
const records = await knex('chunks')
|
||||
@@ -173,7 +173,7 @@ async function getProjectChunks(projectId) {
|
||||
* Insert a pending chunk before sending it to object storage.
|
||||
*/
|
||||
async function insertPendingChunk(projectId, chunk) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
const result = await knex.first(
|
||||
@@ -199,7 +199,7 @@ async function confirmCreate(
|
||||
chunkId,
|
||||
earliestChangeTimestamp
|
||||
) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
await knex.transaction(async tx => {
|
||||
@@ -221,7 +221,7 @@ async function confirmUpdate(
|
||||
newChunkId,
|
||||
earliestChangeTimestamp
|
||||
) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
await knex.transaction(async tx => {
|
||||
@@ -273,7 +273,7 @@ async function _insertChunk(tx, projectId, chunk, chunkId) {
|
||||
* @return {Promise}
|
||||
*/
|
||||
async function deleteChunk(projectId, chunkId) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
assert.integer(chunkId, 'bad chunkId')
|
||||
|
||||
@@ -284,7 +284,7 @@ async function deleteChunk(projectId, chunkId) {
|
||||
* Delete all of a project's chunks
|
||||
*/
|
||||
async function deleteProjectChunks(projectId) {
|
||||
assert.postgresId(projectId, `bad projectId ${projectId}`)
|
||||
assert.postgresId(projectId, 'bad projectId')
|
||||
projectId = parseInt(projectId, 10)
|
||||
|
||||
await knex.transaction(async tx => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const logger = require('@overleaf/logger')
|
||||
const redis = require('../redis')
|
||||
const rclient = redis.rclientHistory //
|
||||
const { Snapshot, Change, History, Chunk } = require('overleaf-editor-core')
|
||||
|
||||
const TEMPORARY_CACHE_LIFETIME = 300 // 5 minutes
|
||||
|
||||
const keySchema = {
|
||||
snapshot({ projectId }) {
|
||||
return `snapshot:{${projectId}}`
|
||||
},
|
||||
startVersion({ projectId }) {
|
||||
return `snapshot-version:{${projectId}}`
|
||||
},
|
||||
changes({ projectId }) {
|
||||
return `changes:{${projectId}}`
|
||||
},
|
||||
}
|
||||
|
||||
rclient.defineCommand('get_current_chunk', {
|
||||
numberOfKeys: 3,
|
||||
lua: `
|
||||
local startVersionValue = redis.call('GET', KEYS[2])
|
||||
if not startVersionValue then
|
||||
return nil -- this is a cache-miss
|
||||
end
|
||||
local snapshotValue = redis.call('GET', KEYS[1])
|
||||
local changesValues = redis.call('LRANGE', KEYS[3], 0, -1)
|
||||
return {snapshotValue, startVersionValue, changesValues}
|
||||
`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Retrieves the current chunk of project history from Redis storage
|
||||
* @param {string} projectId - The unique identifier of the project
|
||||
* @returns {Promise<Chunk|null>} A Promise that resolves to a Chunk object containing project history,
|
||||
* or null if retrieval fails
|
||||
* @throws {Error} If Redis operations fail
|
||||
*/
|
||||
async function getCurrentChunk(projectId) {
|
||||
try {
|
||||
const result = await rclient.get_current_chunk(
|
||||
keySchema.snapshot({ projectId }),
|
||||
keySchema.startVersion({ projectId }),
|
||||
keySchema.changes({ projectId })
|
||||
)
|
||||
if (!result) {
|
||||
return null // cache-miss
|
||||
}
|
||||
const snapshot = Snapshot.fromRaw(JSON.parse(result[0]))
|
||||
const startVersion = JSON.parse(result[1])
|
||||
const changes = result[2].map(c => Change.fromRaw(JSON.parse(c)))
|
||||
const history = new History(snapshot, changes)
|
||||
const chunk = new Chunk(history, startVersion)
|
||||
metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'success' })
|
||||
return chunk
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId }, 'error getting current chunk from redis')
|
||||
metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'error' })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
rclient.defineCommand('get_current_chunk_metadata', {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
local startVersionValue = redis.call('GET', KEYS[1])
|
||||
local changesCount = redis.call('LLEN', KEYS[2])
|
||||
return {startVersionValue, changesCount}
|
||||
`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Retrieves the current chunk metadata for a given project from Redis
|
||||
* @param {string} projectId - The ID of the project to get metadata for
|
||||
* @returns {Promise<Object|null>} Object containing startVersion and changesCount if found, null on error or cache miss
|
||||
* @property {number} startVersion - The starting version information
|
||||
* @property {number} changesCount - The number of changes in the chunk
|
||||
*/
|
||||
async function getCurrentChunkMetadata(projectId) {
|
||||
try {
|
||||
const result = await rclient.get_current_chunk_metadata(
|
||||
keySchema.startVersion({ projectId }),
|
||||
keySchema.changes({ projectId })
|
||||
)
|
||||
if (!result) {
|
||||
return null // cache-miss
|
||||
}
|
||||
const startVersion = JSON.parse(result[0])
|
||||
const changesCount = parseInt(result[1], 10)
|
||||
return { startVersion, changesCount }
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
rclient.defineCommand('set_current_chunk', {
|
||||
numberOfKeys: 3,
|
||||
lua: `
|
||||
local snapshotValue = ARGV[1]
|
||||
local startVersionValue = ARGV[2]
|
||||
redis.call('SETEX', KEYS[1], ${TEMPORARY_CACHE_LIFETIME}, snapshotValue)
|
||||
redis.call('SETEX', KEYS[2], ${TEMPORARY_CACHE_LIFETIME}, startVersionValue)
|
||||
redis.call('DEL', KEYS[3]) -- clear the old changes list
|
||||
if #ARGV >= 3 then
|
||||
redis.call('RPUSH', KEYS[3], unpack(ARGV, 3))
|
||||
redis.call('EXPIRE', KEYS[3], ${TEMPORARY_CACHE_LIFETIME})
|
||||
end
|
||||
`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Stores the current chunk of project history in Redis
|
||||
* @param {string} projectId - The ID of the project
|
||||
* @param {Chunk} chunk - The chunk object containing history data
|
||||
* @returns {Promise<*>} Returns the result of the Redis operation, or null if an error occurs
|
||||
* @throws {Error} May throw Redis-related errors which are caught internally
|
||||
*/
|
||||
async function setCurrentChunk(projectId, chunk) {
|
||||
try {
|
||||
const snapshotKey = keySchema.snapshot({ projectId })
|
||||
const startVersionKey = keySchema.startVersion({ projectId })
|
||||
const changesKey = keySchema.changes({ projectId })
|
||||
|
||||
const snapshot = chunk.history.snapshot
|
||||
const startVersion = chunk.startVersion
|
||||
const changes = chunk.history.changes
|
||||
|
||||
await rclient.set_current_chunk(
|
||||
snapshotKey,
|
||||
startVersionKey,
|
||||
changesKey,
|
||||
JSON.stringify(snapshot.toRaw()),
|
||||
startVersion,
|
||||
...changes.map(c => JSON.stringify(c.toRaw()))
|
||||
)
|
||||
metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'success' })
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, projectId, chunk },
|
||||
'error setting current chunk inredis'
|
||||
)
|
||||
metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'error' })
|
||||
return null // while testing we will suppress any errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a cached chunk's version metadata matches the current chunk's metadata
|
||||
* @param {Chunk}} cachedChunk - The chunk retrieved from cache
|
||||
* @param {Chunk} currentChunk - The current chunk to compare against
|
||||
* @returns {boolean} - Returns true if the chunks have matching start and end versions, false otherwise
|
||||
*/
|
||||
function checkCacheValidity(cachedChunk, currentChunk) {
|
||||
return Boolean(
|
||||
cachedChunk &&
|
||||
cachedChunk.getStartVersion() === currentChunk.getStartVersion() &&
|
||||
cachedChunk.getEndVersion() === currentChunk.getEndVersion()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two chunks for equality using stringified JSON comparison
|
||||
* @param {string} projectId - The ID of the project
|
||||
* @param {Chunk} cachedChunk - The cached chunk to compare
|
||||
* @param {Chunk} currentChunk - The current chunk to compare against
|
||||
* @returns {boolean} - Returns false if either chunk is null/undefined, otherwise returns the comparison result
|
||||
*/
|
||||
function compareChunks(projectId, cachedChunk, currentChunk) {
|
||||
if (!cachedChunk || !currentChunk) {
|
||||
return false
|
||||
}
|
||||
const identical = JSON.stringify(cachedChunk) === JSON.stringify(currentChunk)
|
||||
logger.error({ projectId }, 'chunk cache mismatch')
|
||||
metrics.inc('chunk_store.redis.compare_chunks', 1, {
|
||||
status: identical ? 'success' : 'fail',
|
||||
})
|
||||
return identical
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentChunk,
|
||||
setCurrentChunk,
|
||||
getCurrentChunkMetadata,
|
||||
checkCacheValidity,
|
||||
compareChunks,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const config = require('config')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
|
||||
const historyRedisOptions = config.get('redis.history')
|
||||
const rclientHistory = redis.createClient(historyRedisOptions)
|
||||
|
||||
const lockRedisOptions = config.get('redis.history')
|
||||
const rclientLock = redis.createClient(lockRedisOptions)
|
||||
|
||||
async function disconnect() {
|
||||
await Promise.all([rclientHistory.disconnect(), rclientLock.disconnect()])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rclientHistory,
|
||||
rclientLock,
|
||||
redis,
|
||||
disconnect,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
create,
|
||||
} from '../lib/chunk_store/index.js'
|
||||
import { client } from '../lib/mongodb.js'
|
||||
import redis from '../lib/redis.js'
|
||||
import knex from '../lib/knex.js'
|
||||
import { historyStore } from '../lib/history_store.js'
|
||||
import pLimit from 'p-limit'
|
||||
@@ -1091,5 +1092,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
.catch(err => {
|
||||
console.error('Error closing MongoDB connection:', err)
|
||||
})
|
||||
redis
|
||||
.disconnect()
|
||||
.then(() => {
|
||||
console.log('Redis connection closed')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error closing Redis connection:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import assert from '../lib/assert.js'
|
||||
import knex from '../lib/knex.js'
|
||||
import { client } from '../lib/mongodb.js'
|
||||
import redis from '../lib/redis.js'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import fs from 'node:fs'
|
||||
|
||||
@@ -23,6 +24,7 @@ async function gracefulShutdown() {
|
||||
console.log('Gracefully shutting down')
|
||||
await knex.destroy()
|
||||
await client.close()
|
||||
await redis.disconnect()
|
||||
await setTimeout(100)
|
||||
process.exit()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../lib/chunk_store/index.js'
|
||||
import { client } from '../lib/mongodb.js'
|
||||
import knex from '../lib/knex.js'
|
||||
import redis from '../lib/redis.js'
|
||||
import {
|
||||
loadGlobalBlobs,
|
||||
BlobStore,
|
||||
@@ -247,4 +248,7 @@ main()
|
||||
.finally(() => {
|
||||
knex.destroy().catch(err => console.error('Error closing Postgres:', err))
|
||||
client.close().catch(err => console.error('Error closing MongoDB:', err))
|
||||
redis
|
||||
.disconnect()
|
||||
.catch(err => console.error('Error disconnecting Redis:', err))
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
db,
|
||||
client,
|
||||
} from '../lib/mongodb.js'
|
||||
import redis from '../lib/redis.js'
|
||||
import commandLineArgs from 'command-line-args'
|
||||
import fs from 'node:fs'
|
||||
|
||||
@@ -146,4 +147,7 @@ main()
|
||||
console.error('Error closing Postgres connection:', err)
|
||||
})
|
||||
client.close().catch(err => console.error('Error closing MongoDB:', err))
|
||||
redis.disconnect().catch(err => {
|
||||
console.error('Error disconnecting Redis:', err)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import commandLineArgs from 'command-line-args'
|
||||
import { verifyProjectWithErrorContext } from '../lib/backupVerifier.mjs'
|
||||
import knex from '../lib/knex.js'
|
||||
import { client } from '../lib/mongodb.js'
|
||||
import redis from '../lib/redis.js'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import { loadGlobalBlobs } from '../lib/blob_store/index.js'
|
||||
|
||||
@@ -10,6 +11,7 @@ const { historyId } = commandLineArgs([{ name: 'historyId', type: String }])
|
||||
async function gracefulShutdown(code = process.exitCode) {
|
||||
await knex.destroy()
|
||||
await client.close()
|
||||
await redis.disconnect()
|
||||
await setTimeout(1_000)
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { loadGlobalBlobs } from '../lib/blob_store/index.js'
|
||||
import { getDatesBeforeRPO } from '../../backupVerifier/utils.mjs'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mongodb } from '../index.js'
|
||||
import redis from '../lib/redis.js'
|
||||
|
||||
logger.logger.level('fatal')
|
||||
|
||||
@@ -30,6 +31,7 @@ const usageMessage = [
|
||||
async function gracefulShutdown(code = process.exitCode) {
|
||||
await knex.destroy()
|
||||
await client.close()
|
||||
await redis.disconnect()
|
||||
await setTimeout(1_000)
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use strict'
|
||||
|
||||
const OError = require('@overleaf/o-error')
|
||||
const { expect } = require('chai')
|
||||
const assert = require('../../../../storage/lib/assert')
|
||||
|
||||
describe('assert', function () {
|
||||
describe('blobHash', function () {
|
||||
it('should not throw for valid blob hashes', function () {
|
||||
expect(() =>
|
||||
assert.blobHash(
|
||||
'aad321caf77ca6c5ab09e6c638c237705f93b001',
|
||||
'should be a blob hash'
|
||||
)
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should throw for invalid blob hashes', function () {
|
||||
try {
|
||||
assert.blobHash('invalid-hash', 'should be a blob hash')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a blob hash')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-hash' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for string integer blob hashes', function () {
|
||||
try {
|
||||
assert.blobHash('123', 'should be a blob hash')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a blob hash')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '123' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectId', function () {
|
||||
it('should not throw for valid mongo project ids', function () {
|
||||
expect(() =>
|
||||
assert.projectId('507f1f77bcf86cd799439011', 'should be a project id')
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should not throw for valid postgres project ids', function () {
|
||||
expect(() =>
|
||||
assert.projectId('123456789', 'should be a project id')
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should throw for invalid project ids', function () {
|
||||
try {
|
||||
assert.projectId('invalid-id', 'should be a project id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a project id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for non-numeric project ids', function () {
|
||||
try {
|
||||
assert.projectId('12345x', 'should be a project id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a project id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345x' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for postgres ids starting with 0', function () {
|
||||
try {
|
||||
assert.projectId('0123456', 'should be a project id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a project id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '0123456' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('chunkId', function () {
|
||||
it('should not throw for valid mongo chunk ids', function () {
|
||||
expect(() =>
|
||||
assert.chunkId('507f1f77bcf86cd799439011', 'should be a chunk id')
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should not throw for valid integer chunk ids', function () {
|
||||
expect(() =>
|
||||
assert.chunkId(123456789, 'should be a chunk id')
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should throw for invalid chunk ids', function () {
|
||||
try {
|
||||
assert.chunkId('invalid-id', 'should be a chunk id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a chunk id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for string integer chunk ids', function () {
|
||||
try {
|
||||
assert.chunkId('12345', 'should be a chunk id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a chunk id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('mongoId', function () {
|
||||
it('should not throw for valid mongo ids', function () {
|
||||
expect(() =>
|
||||
assert.mongoId('507f1f77bcf86cd799439011', 'should be a mongo id')
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should throw for invalid mongo ids', function () {
|
||||
try {
|
||||
assert.mongoId('invalid-id', 'should be a mongo id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a mongo id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for numeric mongo ids', function () {
|
||||
try {
|
||||
assert.mongoId('12345', 'should be a mongo id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a mongo id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for mongo ids that are too short', function () {
|
||||
try {
|
||||
assert.mongoId('507f1f77bcf86cd79943901', 'should be a mongo id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a mongo id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({
|
||||
arg: '507f1f77bcf86cd79943901',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for mongo ids that are too long', function () {
|
||||
try {
|
||||
assert.mongoId('507f1f77bcf86cd7994390111', 'should be a mongo id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a mongo id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({
|
||||
arg: '507f1f77bcf86cd7994390111',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('postgresId', function () {
|
||||
it('should not throw for valid postgres ids', function () {
|
||||
expect(() =>
|
||||
assert.postgresId('123456789', 'should be a postgres id')
|
||||
).to.not.throw()
|
||||
expect(() =>
|
||||
assert.postgresId('1', 'should be a postgres id')
|
||||
).to.not.throw()
|
||||
})
|
||||
|
||||
it('should throw for invalid postgres ids', function () {
|
||||
try {
|
||||
assert.postgresId('invalid-id', 'should be a postgres id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a postgres id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: 'invalid-id' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for postgres ids starting with 0', function () {
|
||||
try {
|
||||
assert.postgresId('0123456', 'should be a postgres id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a postgres id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '0123456' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw for postgres ids that are too long', function () {
|
||||
try {
|
||||
assert.postgresId('12345678901', 'should be a postgres id')
|
||||
expect.fail()
|
||||
} catch (error) {
|
||||
expect(error).to.be.instanceOf(TypeError)
|
||||
expect(error.message).to.equal('should be a postgres id')
|
||||
expect(OError.getFullInfo(error)).to.deep.equal({ arg: '12345678901' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('regex constants', function () {
|
||||
it('MONGO_ID_REGEXP should match valid mongo ids', function () {
|
||||
expect('507f1f77bcf86cd799439011').to.match(assert.MONGO_ID_REGEXP)
|
||||
expect('abcdef0123456789abcdef01').to.match(assert.MONGO_ID_REGEXP)
|
||||
})
|
||||
|
||||
it('MONGO_ID_REGEXP should not match invalid mongo ids', function () {
|
||||
expect('invalid-id').to.not.match(assert.MONGO_ID_REGEXP)
|
||||
expect('507f1f77bcf86cd79943901').to.not.match(assert.MONGO_ID_REGEXP) // too short
|
||||
expect('507f1f77bcf86cd7994390111').to.not.match(assert.MONGO_ID_REGEXP) // too long
|
||||
expect('507F1F77BCF86CD799439011').to.not.match(assert.MONGO_ID_REGEXP) // uppercase
|
||||
})
|
||||
|
||||
it('POSTGRES_ID_REGEXP should match valid postgres ids', function () {
|
||||
expect('123456789').to.match(assert.POSTGRES_ID_REGEXP)
|
||||
expect('1').to.match(assert.POSTGRES_ID_REGEXP)
|
||||
})
|
||||
|
||||
it('POSTGRES_ID_REGEXP should not match invalid postgres ids', function () {
|
||||
expect('invalid-id').to.not.match(assert.POSTGRES_ID_REGEXP)
|
||||
expect('0123456').to.not.match(assert.POSTGRES_ID_REGEXP) // starts with 0
|
||||
expect('12345678901').to.not.match(assert.POSTGRES_ID_REGEXP) // too long (> 10 digits)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,20 +8,20 @@ describe('BlobStore postgres backend', function () {
|
||||
const projectId = new ObjectId().toString()
|
||||
await expect(
|
||||
postgresBackend.insertBlob(projectId, 'hash', 123, 99)
|
||||
).to.be.rejectedWith(`bad projectId ${projectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
})
|
||||
|
||||
it('deleteBlobs rejects when called with bad projectId', async function () {
|
||||
const projectId = new ObjectId().toString()
|
||||
await expect(postgresBackend.deleteBlobs(projectId)).to.be.rejectedWith(
|
||||
`bad projectId ${projectId}`
|
||||
'bad projectId'
|
||||
)
|
||||
})
|
||||
|
||||
it('findBlobs rejects when called with bad projectId', async function () {
|
||||
const projectId = new ObjectId().toString()
|
||||
await expect(postgresBackend.findBlobs(projectId)).to.be.rejectedWith(
|
||||
`bad projectId ${projectId}`
|
||||
'bad projectId'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -29,14 +29,14 @@ describe('BlobStore postgres backend', function () {
|
||||
const projectId = new ObjectId().toString()
|
||||
await expect(
|
||||
postgresBackend.findBlob(projectId, 'hash')
|
||||
).to.be.rejectedWith(`bad projectId ${projectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
})
|
||||
|
||||
it('getProjectBlobs rejects when called with bad projectId', async function () {
|
||||
const projectId = new ObjectId().toString()
|
||||
await expect(
|
||||
postgresBackend.getProjectBlobs(projectId)
|
||||
).to.be.rejectedWith(`bad projectId ${projectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,32 +11,32 @@ describe('chunk store Postgres backend', function () {
|
||||
const invalidProjectId = new ObjectId().toString()
|
||||
|
||||
await expect(backend.getLatestChunk(invalidProjectId)).to.be.rejectedWith(
|
||||
`bad projectId ${invalidProjectId}`
|
||||
'bad projectId'
|
||||
)
|
||||
await expect(
|
||||
backend.getChunkForVersion(invalidProjectId, 1)
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
await expect(
|
||||
backend.getChunkForTimestamp(invalidProjectId, new Date())
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
await expect(
|
||||
backend.getProjectChunkIds(invalidProjectId)
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
await expect(
|
||||
backend.insertPendingChunk(invalidProjectId, makeChunk([], 0))
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
await expect(
|
||||
backend.confirmCreate(invalidProjectId, makeChunk([], 0), 1)
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
await expect(
|
||||
backend.confirmUpdate(invalidProjectId, 1, makeChunk([], 0), 2)
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
await expect(backend.deleteChunk(invalidProjectId, 1)).to.be.rejectedWith(
|
||||
`bad projectId ${invalidProjectId}`
|
||||
'bad projectId'
|
||||
)
|
||||
await expect(
|
||||
backend.deleteProjectChunks(invalidProjectId)
|
||||
).to.be.rejectedWith(`bad projectId ${invalidProjectId}`)
|
||||
).to.be.rejectedWith('bad projectId')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const {
|
||||
Chunk,
|
||||
Snapshot,
|
||||
History,
|
||||
File,
|
||||
AddFileOperation,
|
||||
Origin,
|
||||
Change,
|
||||
V2DocVersions,
|
||||
} = require('overleaf-editor-core')
|
||||
const cleanup = require('./support/cleanup')
|
||||
const redisBackend = require('../../../../storage/lib/chunk_store/redis')
|
||||
|
||||
describe('chunk store Redis backend', function () {
|
||||
beforeEach(cleanup.everything)
|
||||
const projectId = '123456'
|
||||
|
||||
describe('getCurrentChunk', function () {
|
||||
it('should return null on cache miss', async function () {
|
||||
const chunk = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(chunk).to.be.null
|
||||
})
|
||||
|
||||
it('should return the cached chunk', async function () {
|
||||
// Create a sample chunk
|
||||
const snapshot = new Snapshot()
|
||||
const changes = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history = new History(snapshot, changes)
|
||||
const chunk = new Chunk(history, 5) // startVersion 5
|
||||
|
||||
// Cache the chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunk)
|
||||
|
||||
// Retrieve the cached chunk
|
||||
const cachedChunk = await redisBackend.getCurrentChunk(projectId)
|
||||
|
||||
expect(cachedChunk).to.not.be.null
|
||||
expect(cachedChunk.getStartVersion()).to.equal(5)
|
||||
expect(cachedChunk.getEndVersion()).to.equal(6)
|
||||
expect(cachedChunk).to.deep.equal(chunk)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentChunk', function () {
|
||||
it('should successfully cache a chunk', async function () {
|
||||
// Create a sample chunk
|
||||
const snapshot = new Snapshot()
|
||||
const changes = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history = new History(snapshot, changes)
|
||||
const chunk = new Chunk(history, 5) // startVersion 5
|
||||
|
||||
// Cache the chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunk)
|
||||
|
||||
// Verify the chunk was cached correctly by retrieving it
|
||||
const cachedChunk = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunk).to.not.be.null
|
||||
expect(cachedChunk.getStartVersion()).to.equal(5)
|
||||
expect(cachedChunk.getEndVersion()).to.equal(6)
|
||||
expect(cachedChunk).to.deep.equal(chunk)
|
||||
|
||||
// Verify that the chunk was stored correctly using the chunk metadata
|
||||
const chunkMetadata =
|
||||
await redisBackend.getCurrentChunkMetadata(projectId)
|
||||
expect(chunkMetadata).to.not.be.null
|
||||
expect(chunkMetadata.startVersion).to.equal(5)
|
||||
expect(chunkMetadata.changesCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('should correctly handle a chunk with zero changes', async function () {
|
||||
// Create a sample chunk with no changes
|
||||
const snapshot = new Snapshot()
|
||||
const changes = []
|
||||
const history = new History(snapshot, changes)
|
||||
const chunk = new Chunk(history, 10) // startVersion 10
|
||||
|
||||
// Cache the chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunk)
|
||||
|
||||
// Retrieve the cached chunk
|
||||
const cachedChunk = await redisBackend.getCurrentChunk(projectId)
|
||||
|
||||
expect(cachedChunk).to.not.be.null
|
||||
expect(cachedChunk.getStartVersion()).to.equal(10)
|
||||
expect(cachedChunk.getEndVersion()).to.equal(10) // End version should equal start version with no changes
|
||||
expect(cachedChunk.history.changes.length).to.equal(0)
|
||||
expect(cachedChunk).to.deep.equal(chunk)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updating already cached chunks', function () {
|
||||
it('should replace a chunk with a longer chunk', async function () {
|
||||
// Set initial chunk with one change
|
||||
const snapshotA = new Snapshot()
|
||||
const changesA = [
|
||||
new Change(
|
||||
[
|
||||
new AddFileOperation(
|
||||
'test.tex',
|
||||
File.fromString('Initial content')
|
||||
),
|
||||
],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const historyA = new History(snapshotA, changesA)
|
||||
const chunkA = new Chunk(historyA, 10)
|
||||
|
||||
await redisBackend.setCurrentChunk(projectId, chunkA)
|
||||
|
||||
// Verify the initial chunk was cached
|
||||
const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkA.getStartVersion()).to.equal(10)
|
||||
expect(cachedChunkA.getEndVersion()).to.equal(11)
|
||||
expect(cachedChunkA.history.changes.length).to.equal(1)
|
||||
|
||||
// Create a longer chunk (with more changes)
|
||||
const snapshotB = new Snapshot()
|
||||
const changesB = [
|
||||
new Change(
|
||||
[new AddFileOperation('test1.tex', File.fromString('Content 1'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('test2.tex', File.fromString('Content 2'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('test3.tex', File.fromString('Content 3'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const historyB = new History(snapshotB, changesB)
|
||||
const chunkB = new Chunk(historyB, 15)
|
||||
|
||||
// Replace the cached chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunkB)
|
||||
|
||||
// Verify the new chunk replaced the old one
|
||||
const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkB).to.not.be.null
|
||||
expect(cachedChunkB.getStartVersion()).to.equal(15)
|
||||
expect(cachedChunkB.getEndVersion()).to.equal(18)
|
||||
expect(cachedChunkB.history.changes.length).to.equal(3)
|
||||
expect(cachedChunkB).to.deep.equal(chunkB)
|
||||
|
||||
// Verify the metadata was updated
|
||||
const updatedMetadata =
|
||||
await redisBackend.getCurrentChunkMetadata(projectId)
|
||||
expect(updatedMetadata.startVersion).to.equal(15)
|
||||
expect(updatedMetadata.changesCount).to.equal(3)
|
||||
})
|
||||
|
||||
it('should replace a chunk with a shorter chunk', async function () {
|
||||
// Set initial chunk with three changes
|
||||
const snapshotA = new Snapshot()
|
||||
const changesA = [
|
||||
new Change(
|
||||
[new AddFileOperation('file1.tex', File.fromString('Content 1'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('file2.tex', File.fromString('Content 2'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('file3.tex', File.fromString('Content 3'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const historyA = new History(snapshotA, changesA)
|
||||
const chunkA = new Chunk(historyA, 20)
|
||||
|
||||
await redisBackend.setCurrentChunk(projectId, chunkA)
|
||||
|
||||
// Verify the initial chunk was cached
|
||||
const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkA.getStartVersion()).to.equal(20)
|
||||
expect(cachedChunkA.getEndVersion()).to.equal(23)
|
||||
expect(cachedChunkA.history.changes.length).to.equal(3)
|
||||
|
||||
// Create a shorter chunk (with fewer changes)
|
||||
const snapshotB = new Snapshot()
|
||||
const changesB = [
|
||||
new Change(
|
||||
[new AddFileOperation('new.tex', File.fromString('New content'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const historyB = new History(snapshotB, changesB)
|
||||
const chunkB = new Chunk(historyB, 30)
|
||||
|
||||
// Replace the cached chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunkB)
|
||||
|
||||
// Verify the new chunk replaced the old one
|
||||
const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkB).to.not.be.null
|
||||
expect(cachedChunkB.getStartVersion()).to.equal(30)
|
||||
expect(cachedChunkB.getEndVersion()).to.equal(31)
|
||||
expect(cachedChunkB.history.changes.length).to.equal(1)
|
||||
expect(cachedChunkB).to.deep.equal(chunkB)
|
||||
|
||||
// Verify the metadata was updated
|
||||
const updatedMetadata =
|
||||
await redisBackend.getCurrentChunkMetadata(projectId)
|
||||
expect(updatedMetadata.startVersion).to.equal(30)
|
||||
expect(updatedMetadata.changesCount).to.equal(1)
|
||||
})
|
||||
|
||||
it('should replace a chunk with a zero-length chunk', async function () {
|
||||
// Set initial chunk with changes
|
||||
const snapshotA = new Snapshot()
|
||||
const changesA = [
|
||||
new Change(
|
||||
[new AddFileOperation('file1.tex', File.fromString('Content 1'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('file2.tex', File.fromString('Content 2'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const historyA = new History(snapshotA, changesA)
|
||||
const chunkA = new Chunk(historyA, 25)
|
||||
|
||||
await redisBackend.setCurrentChunk(projectId, chunkA)
|
||||
|
||||
// Verify the initial chunk was cached
|
||||
const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkA.getStartVersion()).to.equal(25)
|
||||
expect(cachedChunkA.getEndVersion()).to.equal(27)
|
||||
expect(cachedChunkA.history.changes.length).to.equal(2)
|
||||
|
||||
// Create a zero-length chunk (with no changes)
|
||||
const snapshotB = new Snapshot()
|
||||
const changesB = []
|
||||
const historyB = new History(snapshotB, changesB)
|
||||
const chunkB = new Chunk(historyB, 40)
|
||||
|
||||
// Replace the cached chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunkB)
|
||||
|
||||
// Verify the new chunk replaced the old one
|
||||
const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkB).to.not.be.null
|
||||
expect(cachedChunkB.getStartVersion()).to.equal(40)
|
||||
expect(cachedChunkB.getEndVersion()).to.equal(40) // Start version equals end version with no changes
|
||||
expect(cachedChunkB.history.changes.length).to.equal(0)
|
||||
expect(cachedChunkB).to.deep.equal(chunkB)
|
||||
|
||||
// Verify the metadata was updated
|
||||
const updatedMetadata =
|
||||
await redisBackend.getCurrentChunkMetadata(projectId)
|
||||
expect(updatedMetadata.startVersion).to.equal(40)
|
||||
expect(updatedMetadata.changesCount).to.equal(0)
|
||||
})
|
||||
|
||||
it('should replace a zero-length chunk with a non-empty chunk', async function () {
|
||||
// Set initial empty chunk
|
||||
const snapshotA = new Snapshot()
|
||||
const changesA = []
|
||||
const historyA = new History(snapshotA, changesA)
|
||||
const chunkA = new Chunk(historyA, 50)
|
||||
|
||||
await redisBackend.setCurrentChunk(projectId, chunkA)
|
||||
|
||||
// Verify the initial chunk was cached
|
||||
const cachedChunkA = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkA.getStartVersion()).to.equal(50)
|
||||
expect(cachedChunkA.getEndVersion()).to.equal(50)
|
||||
expect(cachedChunkA.history.changes.length).to.equal(0)
|
||||
|
||||
// Create a non-empty chunk
|
||||
const snapshotB = new Snapshot()
|
||||
const changesB = [
|
||||
new Change(
|
||||
[new AddFileOperation('newfile.tex', File.fromString('New content'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[
|
||||
new AddFileOperation(
|
||||
'another.tex',
|
||||
File.fromString('Another file')
|
||||
),
|
||||
],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const historyB = new History(snapshotB, changesB)
|
||||
const chunkB = new Chunk(historyB, 60)
|
||||
|
||||
// Replace the cached chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunkB)
|
||||
|
||||
// Verify the new chunk replaced the old one
|
||||
const cachedChunkB = await redisBackend.getCurrentChunk(projectId)
|
||||
expect(cachedChunkB).to.not.be.null
|
||||
expect(cachedChunkB.getStartVersion()).to.equal(60)
|
||||
expect(cachedChunkB.getEndVersion()).to.equal(62)
|
||||
expect(cachedChunkB.history.changes.length).to.equal(2)
|
||||
expect(cachedChunkB).to.deep.equal(chunkB)
|
||||
|
||||
// Verify the metadata was updated
|
||||
const updatedMetadata =
|
||||
await redisBackend.getCurrentChunkMetadata(projectId)
|
||||
expect(updatedMetadata.startVersion).to.equal(60)
|
||||
expect(updatedMetadata.changesCount).to.equal(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkCacheValidity', function () {
|
||||
it('should return true when versions match', function () {
|
||||
const snapshotA = new Snapshot()
|
||||
const historyA = new History(snapshotA, [])
|
||||
const chunkA = new Chunk(historyA, 10)
|
||||
chunkA.pushChanges([
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
])
|
||||
|
||||
const snapshotB = new Snapshot()
|
||||
const historyB = new History(snapshotB, [])
|
||||
const chunkB = new Chunk(historyB, 10)
|
||||
chunkB.pushChanges([
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
])
|
||||
|
||||
const isValid = redisBackend.checkCacheValidity(chunkA, chunkB)
|
||||
expect(isValid).to.be.true
|
||||
})
|
||||
|
||||
it('should return false when start versions differ', function () {
|
||||
const snapshotA = new Snapshot()
|
||||
const historyA = new History(snapshotA, [])
|
||||
const chunkA = new Chunk(historyA, 10)
|
||||
|
||||
const snapshotB = new Snapshot()
|
||||
const historyB = new History(snapshotB, [])
|
||||
const chunkB = new Chunk(historyB, 11)
|
||||
|
||||
const isValid = redisBackend.checkCacheValidity(chunkA, chunkB)
|
||||
expect(isValid).to.be.false
|
||||
})
|
||||
|
||||
it('should return false when end versions differ', function () {
|
||||
const snapshotA = new Snapshot()
|
||||
const historyA = new History(snapshotA, [])
|
||||
const chunkA = new Chunk(historyA, 10)
|
||||
chunkA.pushChanges([
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
])
|
||||
|
||||
const snapshotB = new Snapshot()
|
||||
const historyB = new History(snapshotB, [])
|
||||
const chunkB = new Chunk(historyB, 10)
|
||||
chunkB.pushChanges([
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('other.tex', File.fromString('World'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
])
|
||||
|
||||
const isValid = redisBackend.checkCacheValidity(chunkA, chunkB)
|
||||
expect(isValid).to.be.false
|
||||
})
|
||||
|
||||
it('should return false when cached chunk is null', function () {
|
||||
const snapshotB = new Snapshot()
|
||||
const historyB = new History(snapshotB, [])
|
||||
const chunkB = new Chunk(historyB, 10)
|
||||
|
||||
const isValid = redisBackend.checkCacheValidity(null, chunkB)
|
||||
expect(isValid).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareChunks', function () {
|
||||
it('should return true when chunks are identical', function () {
|
||||
// Create two identical chunks
|
||||
const snapshot = new Snapshot()
|
||||
const changes = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date('2025-04-10T12:00:00Z'), // Using fixed date for consistent comparison
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history1 = new History(snapshot, changes)
|
||||
const chunk1 = new Chunk(history1, 5)
|
||||
|
||||
// Create a separate but identical chunk
|
||||
const snapshot2 = new Snapshot()
|
||||
const changes2 = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date('2025-04-10T12:00:00Z'), // Using same fixed date
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history2 = new History(snapshot2, changes2)
|
||||
const chunk2 = new Chunk(history2, 5)
|
||||
|
||||
const result = redisBackend.compareChunks(projectId, chunk1, chunk2)
|
||||
expect(result).to.be.true
|
||||
})
|
||||
|
||||
it('should return false when chunks differ', function () {
|
||||
// Create first chunk
|
||||
const snapshot1 = new Snapshot()
|
||||
const changes1 = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date('2025-04-10T12:00:00Z'),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history1 = new History(snapshot1, changes1)
|
||||
const chunk1 = new Chunk(history1, 5)
|
||||
|
||||
// Create a different chunk (different content)
|
||||
const snapshot2 = new Snapshot()
|
||||
const changes2 = [
|
||||
new Change(
|
||||
[
|
||||
new AddFileOperation(
|
||||
'test.tex',
|
||||
File.fromString('Different content')
|
||||
),
|
||||
],
|
||||
new Date('2025-04-10T12:00:00Z'),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history2 = new History(snapshot2, changes2)
|
||||
const chunk2 = new Chunk(history2, 5)
|
||||
|
||||
const result = redisBackend.compareChunks(projectId, chunk1, chunk2)
|
||||
expect(result).to.be.false
|
||||
})
|
||||
|
||||
it('should return false when one chunk is null', function () {
|
||||
// Create a chunk
|
||||
const snapshot = new Snapshot()
|
||||
const changes = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date('2025-04-10T12:00:00Z'),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history = new History(snapshot, changes)
|
||||
const chunk = new Chunk(history, 5)
|
||||
|
||||
const resultWithNullCached = redisBackend.compareChunks(
|
||||
projectId,
|
||||
null,
|
||||
chunk
|
||||
)
|
||||
expect(resultWithNullCached).to.be.false
|
||||
|
||||
const resultWithNullCurrent = redisBackend.compareChunks(
|
||||
projectId,
|
||||
chunk,
|
||||
null
|
||||
)
|
||||
expect(resultWithNullCurrent).to.be.false
|
||||
})
|
||||
|
||||
it('should return false when chunks have different start versions', function () {
|
||||
// Create first chunk with start version 5
|
||||
const snapshot1 = new Snapshot()
|
||||
const changes1 = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date('2025-04-10T12:00:00Z'),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history1 = new History(snapshot1, changes1)
|
||||
const chunk1 = new Chunk(history1, 5)
|
||||
|
||||
// Create second chunk with identical content but different start version (10)
|
||||
const snapshot2 = new Snapshot()
|
||||
const changes2 = [
|
||||
new Change(
|
||||
[new AddFileOperation('test.tex', File.fromString('Hello World'))],
|
||||
new Date('2025-04-10T12:00:00Z'),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history2 = new History(snapshot2, changes2)
|
||||
const chunk2 = new Chunk(history2, 10)
|
||||
|
||||
const result = redisBackend.compareChunks(projectId, chunk1, chunk2)
|
||||
expect(result).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration with redis', function () {
|
||||
it('should store and retrieve complex chunks correctly', async function () {
|
||||
// Create a more complex chunk
|
||||
const snapshot = new Snapshot()
|
||||
const changes = [
|
||||
new Change(
|
||||
[new AddFileOperation('file1.tex', File.fromString('Content 1'))],
|
||||
new Date(),
|
||||
[1234]
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('file2.tex', File.fromString('Content 2'))],
|
||||
new Date(),
|
||||
null,
|
||||
new Origin('test-origin'),
|
||||
['5a296963ad5e82432674c839', null],
|
||||
'123.4',
|
||||
new V2DocVersions({
|
||||
'random-doc-id': { pathname: 'file2.tex', v: 123 },
|
||||
})
|
||||
),
|
||||
new Change(
|
||||
[new AddFileOperation('file3.tex', File.fromString('Content 3'))],
|
||||
new Date(),
|
||||
[]
|
||||
),
|
||||
]
|
||||
const history = new History(snapshot, changes)
|
||||
const chunk = new Chunk(history, 20)
|
||||
|
||||
// Cache the chunk
|
||||
await redisBackend.setCurrentChunk(projectId, chunk)
|
||||
|
||||
// Retrieve the cached chunk
|
||||
const cachedChunk = await redisBackend.getCurrentChunk(projectId)
|
||||
|
||||
expect(cachedChunk.getStartVersion()).to.equal(20)
|
||||
expect(cachedChunk.getEndVersion()).to.equal(23)
|
||||
expect(cachedChunk).to.deep.equal(chunk)
|
||||
expect(cachedChunk.history.changes.length).to.equal(3)
|
||||
|
||||
// Check that the operations were preserved correctly
|
||||
const retrievedChanges = cachedChunk.history.changes
|
||||
expect(retrievedChanges[0].getOperations()[0].getPathname()).to.equal(
|
||||
'file1.tex'
|
||||
)
|
||||
expect(retrievedChanges[1].getOperations()[0].getPathname()).to.equal(
|
||||
'file2.tex'
|
||||
)
|
||||
expect(retrievedChanges[2].getOperations()[0].getPathname()).to.equal(
|
||||
'file3.tex'
|
||||
)
|
||||
|
||||
// Check that the chunk was stored correctly using the chunk metadata
|
||||
const chunkMetadata =
|
||||
await redisBackend.getCurrentChunkMetadata(projectId)
|
||||
expect(chunkMetadata).to.not.be.null
|
||||
expect(chunkMetadata.startVersion).to.equal(20)
|
||||
expect(chunkMetadata.changesCount).to.equal(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
const config = require('config')
|
||||
|
||||
const { knex, persistor, mongodb } = require('../../../../../storage')
|
||||
const { knex, persistor, mongodb, redis } = require('../../../../../storage')
|
||||
const { S3Persistor } = require('@overleaf/object-persistor/src/S3Persistor')
|
||||
|
||||
const POSTGRES_TABLES = [
|
||||
@@ -43,6 +43,11 @@ async function cleanupMongo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupRedis() {
|
||||
await redis.rclientHistory.flushdb()
|
||||
await redis.rclientLock.flushdb()
|
||||
}
|
||||
|
||||
async function cleanupPersistor() {
|
||||
await Promise.all([
|
||||
clearBucket(config.get('blobStore.globalBucket')),
|
||||
@@ -82,6 +87,7 @@ async function cleanupEverything() {
|
||||
cleanupMongo(),
|
||||
cleanupPersistor(),
|
||||
cleanupBackup(),
|
||||
cleanupRedis(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -90,5 +96,6 @@ module.exports = {
|
||||
mongo: cleanupMongo,
|
||||
persistor: cleanupPersistor,
|
||||
backup: cleanupBackup,
|
||||
redis: cleanupRedis,
|
||||
everything: cleanupEverything,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const chai = require('chai')
|
||||
const chaiAsPromised = require('chai-as-promised')
|
||||
const config = require('config')
|
||||
const fetch = require('node-fetch')
|
||||
const { knex, mongodb } = require('../storage')
|
||||
const { knex, mongodb, redis } = require('../storage')
|
||||
|
||||
// ensure every ObjectId has the id string as a property for correct comparisons
|
||||
require('mongodb').ObjectId.cacheHexString = true
|
||||
@@ -53,6 +53,7 @@ async function createGcsBuckets() {
|
||||
// can exit.
|
||||
async function tearDownConnectionPool() {
|
||||
await knex.destroy()
|
||||
await redis.disconnect()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user