Merge pull request #21375 from overleaf/jpa-if-none-match

[object-persistor] add support for ifNoneMatch=* in sendStream

GitOrigin-RevId: 268f054ac1b6452105b02757cdec32bad00702fd
This commit is contained in:
Jakob Ackermann 2024-10-31 13:08:41 +01:00 committed by Copybot
parent a551a0e9f7
commit 7b3e39f63f
8 changed files with 122 additions and 9 deletions

View file

@ -24,7 +24,7 @@
"@overleaf/logger": "*", "@overleaf/logger": "*",
"@overleaf/metrics": "*", "@overleaf/metrics": "*",
"@overleaf/o-error": "*", "@overleaf/o-error": "*",
"aws-sdk": "^2.718.0", "aws-sdk": "^2.1691.0",
"fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51", "fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51",
"glob": "^7.1.6", "glob": "^7.1.6",
"range-parser": "^1.2.1", "range-parser": "^1.2.1",

View file

@ -5,6 +5,7 @@ class WriteError extends OError {}
class ReadError extends OError {} class ReadError extends OError {}
class SettingsError extends OError {} class SettingsError extends OError {}
class NotImplementedError extends OError {} class NotImplementedError extends OError {}
class AlreadyWrittenError extends OError {}
module.exports = { module.exports = {
NotFoundError, NotFoundError,
@ -12,4 +13,5 @@ module.exports = {
ReadError, ReadError,
SettingsError, SettingsError,
NotImplementedError, NotImplementedError,
AlreadyWrittenError,
} }

View file

@ -8,7 +8,7 @@ const { pipeline } = require('stream/promises')
const { promisify } = require('util') const { promisify } = require('util')
const AbstractPersistor = require('./AbstractPersistor') const AbstractPersistor = require('./AbstractPersistor')
const { ReadError, WriteError } = require('./Errors') const { ReadError, WriteError, NotImplementedError } = require('./Errors')
const PersistorHelper = require('./PersistorHelper') const PersistorHelper = require('./PersistorHelper')
const glob = promisify(globCallbacks) const glob = promisify(globCallbacks)
@ -36,6 +36,14 @@ module.exports = class FSPersistor extends AbstractPersistor {
} }
async sendStream(location, target, sourceStream, opts = {}) { async sendStream(location, target, sourceStream, opts = {}) {
if (opts.ifNoneMatch === '*') {
// The standard library only has fs.rename(), which does not support exclusive flags.
// Refuse to act on this write operation.
throw new NotImplementedError(
'Overwrite protection required by caller, but it is not available is FS backend. Configure GCS or S3 backend instead, get in touch with support for further information.'
)
}
const targetPath = this._getFsPath(location, target) const targetPath = this._getFsPath(location, target)
try { try {
@ -55,7 +63,7 @@ module.exports = class FSPersistor extends AbstractPersistor {
throw PersistorHelper.wrapError( throw PersistorHelper.wrapError(
err, err,
'failed to write stream', 'failed to write stream',
{ location, target }, { location, target, ifNoneMatch: opts.ifNoneMatch },
WriteError WriteError
) )
} }

View file

@ -78,10 +78,14 @@ module.exports = class GcsPersistor extends AbstractPersistor {
writeOptions.metadata = writeOptions.metadata || {} writeOptions.metadata = writeOptions.metadata || {}
writeOptions.metadata.contentEncoding = opts.contentEncoding writeOptions.metadata.contentEncoding = opts.contentEncoding
} }
const fileOptions = {}
if (opts.ifNoneMatch === '*') {
fileOptions.generation = 0
}
const uploadStream = this.storage const uploadStream = this.storage
.bucket(bucketName) .bucket(bucketName)
.file(key) .file(key, fileOptions)
.createWriteStream(writeOptions) .createWriteStream(writeOptions)
await pipeline(readStream, observer, uploadStream) await pipeline(readStream, observer, uploadStream)
@ -97,7 +101,7 @@ module.exports = class GcsPersistor extends AbstractPersistor {
throw PersistorHelper.wrapError( throw PersistorHelper.wrapError(
err, err,
'upload to GCS failed', 'upload to GCS failed',
{ bucketName, key }, { bucketName, key, ifNoneMatch: opts.ifNoneMatch },
WriteError WriteError
) )
} }

View file

@ -3,7 +3,7 @@ const Stream = require('stream')
const { pipeline } = require('stream/promises') const { pipeline } = require('stream/promises')
const Logger = require('@overleaf/logger') const Logger = require('@overleaf/logger')
const Metrics = require('@overleaf/metrics') const Metrics = require('@overleaf/metrics')
const { WriteError, NotFoundError } = require('./Errors') const { WriteError, NotFoundError, AlreadyWrittenError } = require('./Errors')
const _128KiB = 128 * 1024 const _128KiB = 128 * 1024
const TIMING_BUCKETS = [ const TIMING_BUCKETS = [
@ -146,6 +146,13 @@ function wrapError(error, message, params, ErrorType) {
(error.response && error.response.statusCode === 404) (error.response && error.response.statusCode === 404)
) { ) {
return new NotFoundError('no such file', params, error) return new NotFoundError('no such file', params, error)
} else if (
params.ifNoneMatch === '*' &&
(error.code === 'PreconditionFailed' ||
error.response?.statusCode === 412 ||
error instanceof AlreadyWrittenError)
) {
return new AlreadyWrittenError(message, params, error)
} else { } else {
return new ErrorType(message, params, error) return new ErrorType(message, params, error)
} }

View file

@ -52,6 +52,9 @@ module.exports = class S3Persistor extends AbstractPersistor {
if (opts.contentEncoding) { if (opts.contentEncoding) {
uploadOptions.ContentEncoding = opts.contentEncoding uploadOptions.ContentEncoding = opts.contentEncoding
} }
if (opts.ifNoneMatch === '*') {
uploadOptions.IfNoneMatch = '*'
}
// if we have an md5 hash, pass this to S3 to verify the upload - otherwise // if we have an md5 hash, pass this to S3 to verify the upload - otherwise
// we rely on the S3 client's checksum calculation to validate the upload // we rely on the S3 client's checksum calculation to validate the upload
@ -69,7 +72,7 @@ module.exports = class S3Persistor extends AbstractPersistor {
throw PersistorHelper.wrapError( throw PersistorHelper.wrapError(
err, err,
'upload to S3 failed', 'upload to S3 failed',
{ bucketName, key }, { bucketName, key, ifNoneMatch: opts.ifNoneMatch },
WriteError WriteError
) )
} }

55
package-lock.json generated
View file

@ -258,7 +258,7 @@
"@overleaf/logger": "*", "@overleaf/logger": "*",
"@overleaf/metrics": "*", "@overleaf/metrics": "*",
"@overleaf/o-error": "*", "@overleaf/o-error": "*",
"aws-sdk": "^2.718.0", "aws-sdk": "^2.1691.0",
"fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51", "fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51",
"glob": "^7.1.6", "glob": "^7.1.6",
"range-parser": "^1.2.1", "range-parser": "^1.2.1",
@ -276,6 +276,27 @@
"typescript": "^5.0.4" "typescript": "^5.0.4"
} }
}, },
"libraries/object-persistor/node_modules/aws-sdk": {
"version": "2.1691.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1691.0.tgz",
"integrity": "sha512-/F2YC+DlsY3UBM2Bdnh5RLHOPNibS/+IcjUuhP8XuctyrN+MlL+fWDAiela32LTDk7hMy4rx8MTgvbJ+0blO5g==",
"hasInstallScript": true,
"dependencies": {
"buffer": "4.9.2",
"events": "1.1.1",
"ieee754": "1.1.13",
"jmespath": "0.16.0",
"querystring": "0.2.0",
"sax": "1.2.1",
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
"xml2js": "0.6.2"
},
"engines": {
"node": ">= 10.0.0"
}
},
"libraries/object-persistor/node_modules/gcp-metadata": { "libraries/object-persistor/node_modules/gcp-metadata": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
@ -337,6 +358,14 @@
} }
} }
}, },
"libraries/object-persistor/node_modules/uuid": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz",
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"libraries/overleaf-editor-core": { "libraries/overleaf-editor-core": {
"version": "1.0.0", "version": "1.0.0",
"license": "Proprietary", "license": "Proprietary",
@ -49452,7 +49481,7 @@
"@overleaf/logger": "*", "@overleaf/logger": "*",
"@overleaf/metrics": "*", "@overleaf/metrics": "*",
"@overleaf/o-error": "*", "@overleaf/o-error": "*",
"aws-sdk": "^2.718.0", "aws-sdk": "^2.1691.0",
"chai": "^4.3.6", "chai": "^4.3.6",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51", "fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51",
@ -49468,6 +49497,23 @@
"typescript": "^5.0.4" "typescript": "^5.0.4"
}, },
"dependencies": { "dependencies": {
"aws-sdk": {
"version": "2.1691.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1691.0.tgz",
"integrity": "sha512-/F2YC+DlsY3UBM2Bdnh5RLHOPNibS/+IcjUuhP8XuctyrN+MlL+fWDAiela32LTDk7hMy4rx8MTgvbJ+0blO5g==",
"requires": {
"buffer": "4.9.2",
"events": "1.1.1",
"ieee754": "1.1.13",
"jmespath": "0.16.0",
"querystring": "0.2.0",
"sax": "1.2.1",
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
"xml2js": "0.6.2"
}
},
"gcp-metadata": { "gcp-metadata": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
@ -49490,6 +49536,11 @@
"bson": "^6.1.0", "bson": "^6.1.0",
"mongodb-connection-string-url": "^2.6.0" "mongodb-connection-string-url": "^2.6.0"
} }
},
"uuid": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz",
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw=="
} }
} }
}, },

View file

@ -32,6 +32,10 @@ process.on('unhandledRejection', e => {
// store settings for multiple backends, so that we can test each one. // store settings for multiple backends, so that we can test each one.
// fs will always be available - add others if they are configured // fs will always be available - add others if they are configured
const { BackendSettings, s3Config } = require('./TestConfig') const { BackendSettings, s3Config } = require('./TestConfig')
const {
AlreadyWrittenError,
NotImplementedError,
} = require('@overleaf/object-persistor/src/Errors')
describe('Filestore', function () { describe('Filestore', function () {
this.timeout(1000 * 10) this.timeout(1000 * 10)
@ -273,6 +277,40 @@ describe('Filestore', function () {
expect(body).to.equal(newContent) expect(body).to.equal(newContent)
}) })
describe('IfNoneMatch', function () {
if (backendSettings.backend === 'fs') {
it('should refuse to handle IfNoneMatch', async function () {
await expect(
app.persistor.sendStream(
Settings.filestore.stores.user_files,
`${projectId}/${fileId}`,
fs.createReadStream(localFileReadPath),
{ ifNoneMatch: '*' }
)
).to.be.rejectedWith(NotImplementedError)
})
} else {
it('should reject sendStream on the same key with IfNoneMatch', async function () {
await expect(
app.persistor.sendStream(
Settings.filestore.stores.user_files,
`${projectId}/${fileId}`,
fs.createReadStream(localFileReadPath),
{ ifNoneMatch: '*' }
)
).to.be.rejectedWith(AlreadyWrittenError)
})
it('should allow sendStream on a different key with IfNoneMatch', async function () {
await app.persistor.sendStream(
Settings.filestore.stores.user_files,
`${projectId}/${fileId}-other`,
fs.createReadStream(localFileReadPath),
{ ifNoneMatch: '*' }
)
})
}
})
if (backendSettings.backend !== 'fs') { if (backendSettings.backend !== 'fs') {
it('should record an egress metric for the upload', async function () { it('should record an egress metric for the upload', async function () {
const metric = await TestHelper.getMetric( const metric = await TestHelper.getMetric(