Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/api/apiUtils/object/versioning.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { errorInstances, versioning } = require('arsenal');
const { ExternalNullVersionId } = versioning.VersioningConstants;
const async = require('async');

const metadata = require('../../../metadata/wrapper');
Expand All @@ -18,7 +19,7 @@ const nonVersionedObjId = versionIdUtils.getInfVid(config.replicationGroupId);
* fails due to improper format, otherwise undefined or the decoded version id
*/
function decodeVID(versionId) {
if (versionId === 'null') {
if (versionId === ExternalNullVersionId) {
return versionId;
}

Expand Down
52 changes: 20 additions & 32 deletions lib/routes/routeBackbeat.js

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment regarding Maël 3 feedbacks : I mostly agree with him that there is weirdness in the code changes, and that the current solution doesn't feel fully satisfying, but I don't think there is any perfect way to do it, else we would need a bigger refactoring

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else we would need a bigger refactoring

generally : if the proper way requires a refactoring, it should be done. The risk otherwise is to end up with an accumulation of code which is just slightly off - but taken together ends up being a huge pile of mess...

so one must dig into this "not fully satisfying feeling" : is it because we are introducing debt? is that debt something that may come back at us, or something easily manageable? what refactoring would be required? would this refactoring really help the problem here, or just cleanup the rest (but the change would still be as complex)? How long would this refactor take, is it worth it? is it worth it to solve the specific problem at hand? Is it because the problem should be solved more simply? in that case, does the complexity come from the problem it self (i.e. not so easy because of some corner case/...), from some implementation choice, ...? are there some conditions (context) which could allow for a better solution? ...

Original file line number Diff line number Diff line change
Expand Up @@ -432,38 +432,26 @@ function putData(request, response, bucketInfo, objMd, log, callback) {
return callback(errorInstances.BadRequest.customizeDescription(errMessage));
}

const incomingVersionIdEncoded = request.headers['x-scal-version-id'];
if (incomingVersionIdEncoded !== undefined) {
const incomingVersionIdDecoded =
incomingVersionIdEncoded !== 'null' ? decode(incomingVersionIdEncoded) : 'null';
if (incomingVersionIdDecoded instanceof Error) {
log.error('crr putData: failed to decode x-scal-version-id header', {
method: 'putData',
error: incomingVersionIdDecoded.message,
});
return callback(
errorInstances.BadRequest.customizeDescription('bad request: invalid x-scal-version-id header'),
);
}
if (objMd && objMd.versionId === incomingVersionIdDecoded) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for objMd.versionId === incomingVersionIdDecoded anymore as the middleware is already fetching objMd for the specific versionID provided in the header

// Data already at destination for this version; return 409 with the existing
// microVersionId so backbeat can decide if putMetadata is still needed.
log.debug('crr putData: version already at destination', {
method: 'putData',
bucketName: request.bucketName,
objectKey: request.objectKey,
hasMicroVersionId: !!objMd.microVersionId,
});
request.resume();
return _respondWithHeaderCrrConflict(
response,
log,
callback,
VersionIdCollisionException.name,
'version id already at destination',
objMd.microVersionId,
);
}
const incomingVersionIdEncoded = request.query?.versionId;
if (incomingVersionIdEncoded !== undefined && objMd) {
// objMd is the specific version requested via the versionId query param.
// Its existence means the data is already at the destination. Return 409 with the
// existing microVersionId so backbeat can decide if putMetadata is still needed.
log.debug('crr putData: version already at destination', {
method: 'putData',
bucketName: request.bucketName,
objectKey: request.objectKey,
hasMicroVersionId: !!objMd.microVersionId,
});
request.resume();
return _respondWithHeaderCrrConflict(
response,
log,
callback,
VersionIdCollisionException.name,
'version id already at destination',
objMd.microVersionId,
);
}

writeContinue(request, response);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"vaultclient": "scality/vaultclient#8.5.8",
"werelogs": "scality/werelogs#semver:^8.2.4",
"ws": "^8.18.0",
"@scality/cloudserverclient": "1.0.9",
"@scality/cloudserverclient": "1.0.12",
"xml2js": "^0.6.2"
},
"devDependencies": {
Expand Down
97 changes: 90 additions & 7 deletions tests/functional/backbeat/putData.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { BackbeatRoutesClient, PutDataCommand, VersionIdCollisionException } = re
const { generateVersionId, encode: encodeVersionId } = versioning.VersionID;

const TEST_BUCKET = `bucket-putdata-${uuidv4().split('-')[0]}`;
const TEST_BUCKET_UNVERSIONED = `bucket-putdata-unver-${uuidv4().split('-')[0]}`;
const OBJECT_BODY = 'imAboutToBeCascadedWitNoParachuteInMyBack';
const OBJECT_MD5_HEX = createHash('md5').update(OBJECT_BODY).digest('hex');
const CANONICAL_ID = '79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be';
Expand Down Expand Up @@ -55,6 +56,8 @@ before(async () => {
VersioningConfiguration: { Status: 'Enabled' },
}),
);

await s3.send(new CreateBucketCommand({ Bucket: TEST_BUCKET_UNVERSIONED }));
});

describe('putData : VersionId collision detection', () => {
Expand Down Expand Up @@ -106,17 +109,63 @@ describe('putData : VersionId collision detection', () => {
const output = await putData(key, { versionId: differentVersionId });
assert.ok(output.Location, 'should return a Location when data is written normally');
});

it('should throw VersionIdCollisionException when a non-current version already exists', async () => {
const key = 'putdata-non-current-collision';

const v1Result = await s3.send(
new PutObjectCommand({
Bucket: TEST_BUCKET,
Key: key,
Body: Buffer.from(OBJECT_BODY),
ContentType: 'text/plain',
}),
);
const v1VersionId = v1Result.VersionId;
assert.ok(v1VersionId, 'first PutObject should return a VersionId');

const v2Result = await s3.send(
new PutObjectCommand({
Bucket: TEST_BUCKET,
Key: key,
Body: Buffer.from(OBJECT_BODY),
ContentType: 'text/plain',
}),
);
assert.ok(v2Result.VersionId, 'second PutObject should return a VersionId');

// putData on v1 (non-current) must detect the collision, not just compare against master
try {
await putData(key, { versionId: v1VersionId });
assert.fail('expected VersionIdCollisionException');
} catch (err) {
assert.ok(
err instanceof VersionIdCollisionException,
`expected VersionIdCollisionException, got ${err.constructor.name}`,
);
assert.strictEqual(err.microVersionId, '', 'microVersionId should be empty for original write state');
}
});
});

describe('putData : null-version objects (ExternalNullVersionId)', () => {
// Null-version objects created before versioning was enabled use Arsenal constant ExternalNullVersionId = 'null'
// getEncodedVersionId() returns 'null' as-is (no base62 encoding), and objMd.versionId is
// undefined in metadata : collision detection is not possible, so putData must write normally.
it('should write normally when VersionId is "null" (ExternalNullVersionId)', async () => {
it('should write normally when VersionId is "null" and destination has no null version', async () => {
// Versioned bucket: existing object has a real versionId, not a null version.
// Fetching with ExternalNullVersionId returns no objMd => no collision => write normally.
const key = 'putdata-null-version-no-collision';
await s3.send(
new PutObjectCommand({
Bucket: TEST_BUCKET,
Key: key,
Body: Buffer.from(OBJECT_BODY),
ContentType: 'text/plain',
}),
);

const output = await backbeatClient.send(
new PutDataCommand({
Bucket: TEST_BUCKET,
Key: 'putdata-null-version',
Key: key,
ContentMD5: OBJECT_MD5_HEX,
CanonicalID: CANONICAL_ID,
VersioningRequired: true,
Expand All @@ -126,10 +175,44 @@ describe('putData : null-version objects (ExternalNullVersionId)', () => {
);
assert.ok(output.Location, 'putData with null-version versionId should write normally');
});

it('should throw VersionIdCollisionException when a null version already exists at destination', async () => {
// Unversioned bucket: objects have no versionId in metadata (they are null versions).
// putData with ExternalNullVersionId must detect the collision.
const key = 'putdata-null-version-collision';
await s3.send(
new PutObjectCommand({
Bucket: TEST_BUCKET_UNVERSIONED,
Key: key,
Body: Buffer.from(OBJECT_BODY),
ContentType: 'text/plain',
}),
);

try {
await backbeatClient.send(
new PutDataCommand({
Bucket: TEST_BUCKET_UNVERSIONED,
Key: key,
ContentMD5: OBJECT_MD5_HEX,
CanonicalID: CANONICAL_ID,
VersionId: ExternalNullVersionId,
Body: Buffer.from(OBJECT_BODY),
}),
);
assert.fail('expected VersionIdCollisionException');
} catch (err) {
assert.ok(
err instanceof VersionIdCollisionException,
`expected VersionIdCollisionException, got ${err.constructor.name}`,
);
assert.strictEqual(err.microVersionId, '', 'microVersionId should be empty for null-version collision');
}
Comment thread
SylvainSenechal marked this conversation as resolved.
});
});

describe('putData : baseline (no cascade headers)', () => {
it('should succeed normally when putData has no VersionId header', async () => {
describe('putData : baseline', () => {
it('should succeed normally when putData has no VersionId query param', async () => {
const key = 'putdata-baseline-no-version-id';
const output = await putData(key);
assert.ok(output.Location, 'putData without VersionId should return a Location');
Expand Down
3 changes: 1 addition & 2 deletions tests/unit/routes/routeBackbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,9 @@ describe('routeBackbeat', () => {
'content-md5': '1234',
'content-length': '0',
'x-scal-versioning-required': 'true',
'x-scal-version-id': encodedVersionId,
});
mockRequest.method = 'PUT';
mockRequest.url = '/_/backbeat/data/bucket0/key0';
mockRequest.url = `/_/backbeat/data/bucket0/key0?versionId=${encodedVersionId}`;
mockRequest.destroy = () => {};

metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => {
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3210,10 +3210,10 @@
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==

"@scality/cloudserverclient@1.0.9":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@scality/cloudserverclient/-/cloudserverclient-1.0.9.tgz#0a333e39436e1f1e74279c3b5a11645be1fffbc1"
integrity sha512-ZGqH4G535opDAEP2PxdYDFG7kjZ/eBrzP3ZmO49goFVjRhyDCZ1gT0Pask3/wZcyysZ2Au1qylorZSLPiZmB2A==
"@scality/cloudserverclient@1.0.12":
version "1.0.12"
resolved "https://registry.yarnpkg.com/@scality/cloudserverclient/-/cloudserverclient-1.0.12.tgz#cbef36fb65b9cb9f7235d6d7dec291a535cd8dd2"
integrity sha512-A85ki+dQSdBt7C9pGxTA2NXRbg74azZ2LV/dBWf2A5soQFYXabDAnKQl/4+hnar8OAbIZ8XFoJjqqQzRZ4mGhg==
dependencies:
"@aws-sdk/client-s3" "^3.1009.0"
"@aws-sdk/middleware-expect-continue" "^3.972.8"
Expand Down
Loading