Skip to content

Commit d52ba09

Browse files
committed
Merge branch 'improvement/CLDSRV-952' into q/9.4
2 parents e2e59a2 + 7a82ba5 commit d52ba09

5 files changed

Lines changed: 656 additions & 1224 deletions

File tree

lib/routes/routeBackbeat.js

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,9 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
587587
// Note: '' is falsy so the presence check must be !== undefined, not truthy.
588588
const hasMicroVersionId = encodedMicroVersionId !== undefined;
589589
let incomingMicroVersionId = null;
590+
const metadataOptions = {
591+
overheadField: constants.overheadField,
592+
};
590593
if (hasMicroVersionId && objMd) {
591594
// '' means source has no microVersionId, treated as older revision
592595
incomingMicroVersionId = encodedMicroVersionId === '' ? null : decode(encodedMicroVersionId);
@@ -640,6 +643,13 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
640643
conflictErr.mvId,
641644
);
642645
}
646+
647+
// Atomic counterpart of the JS pre-checks above, performed at database level.
648+
if (incomingMicroVersionId !== null) {
649+
metadataOptions.conditions = {
650+
$or: [{ microVersionId: { $exists: false } }, { microVersionId: { $gt: incomingMicroVersionId } }],
651+
};
652+
}
643653
}
644654

645655
return _getRequestPayload(request, (err, payload) => {
@@ -791,25 +801,21 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
791801
omVal.replicationInfo.isNFS = !omVal.replicationInfo.isReplica;
792802
}
793803

794-
const options = {
795-
overheadField: constants.overheadField,
796-
};
797-
798804
// NOTE: When 'versioning' is set to true and no 'versionId' is specified,
799805
// it results in the creation of a "new" version, which also updates the master.
800806
// NOTE: Since option fields are converted to strings when they're sent to Metadata via the query string,
801807
// Metadata interprets the value "false" as if it were true.
802808
// Therefore, to avoid this confusion, we don't pass the versioning parameter at all if its value is false.
803809
if (versioning) {
804-
options.versioning = true;
810+
metadataOptions.versioning = true;
805811
}
806812

807813
// NOTE: When options fields are sent to Metadata through the query string,
808814
// they are converted to strings. As a result, Metadata interprets the value undefined
809815
// in the versionId field as an empty string ('').
810816
// To prevent this, the versionId field is only included in options when it is defined.
811817
if (versionId !== undefined) {
812-
options.versionId = versionId;
818+
metadataOptions.versionId = versionId;
813819
omVal.versionId = versionId;
814820

815821
if (isNull) {
@@ -829,13 +835,13 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
829835
// The update is only done when putting a new version as updating versions that already exist
830836
// shouldn't affect the master.
831837
if (!objMd) {
832-
options.repairMaster = true;
838+
metadataOptions.repairMaster = true;
833839
}
834840
}
835841

836842
// If the new null keys logic (S3C-7352) is not supported (compatibility mode), 'isNull' remains undefined.
837843
if (!nullVersionCompatMode) {
838-
options.isNull = isNull;
844+
metadataOptions.isNull = isNull;
839845
}
840846

841847
const isReplicationWrite = !!headers['x-scal-replication-content'];
@@ -937,7 +943,7 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
937943
);
938944

939945
if (versioningPreprocessingResult) {
940-
options.deleteNullKey = versioningPreprocessingResult.deleteNullKey;
946+
metadataOptions.deleteNullKey = versioningPreprocessingResult.deleteNullKey;
941947

942948
// The master references a null version only via extraMD,
943949
// which is set solely in nullVersionCompatMode. In null-key
@@ -953,9 +959,9 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
953959
log.trace('putting object version', {
954960
objectKey: request.objectKey,
955961
omVal,
956-
options,
962+
metadataOptions,
957963
});
958-
return metadata.putObjectMD(bucketName, objectKey, omVal, options, log, (err, md) => {
964+
return metadata.putObjectMD(bucketName, objectKey, omVal, metadataOptions, log, (err, md) => {
959965
if (err) {
960966
// Handle duplicate key error during repair operation
961967
// This can happen due to race conditions when multiple operations
@@ -964,7 +970,7 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
964970
// treat this as success.
965971
const errorMessage = err.message || err.toString() || '';
966972
const isRepairDuplicateKeyError =
967-
options.repairMaster &&
973+
metadataOptions.repairMaster &&
968974
(errorMessage.includes('E11000') ||
969975
errorMessage.includes('duplicate key') ||
970976
errorMessage.includes('repair'));
@@ -1045,7 +1051,25 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
10451051
});
10461052
},
10471053
],
1048-
callback,
1054+
(err, results) => {
1055+
if (err?.is?.PreconditionFailed && metadataOptions.conditions) {
1056+
log.debug('putMetadata: write rejected, incoming microVersionId is not newer than stored', {
1057+
method: 'putMetadata',
1058+
bucketName,
1059+
objectKey,
1060+
});
1061+
request.resume();
1062+
return _respondWithHeaderCrrConflict(
1063+
response,
1064+
log,
1065+
callback,
1066+
StaleMicroVersionIdException.name,
1067+
'incoming revision is not newer than stored',
1068+
objMd?.microVersionId,
1069+
);
1070+
}
1071+
return callback(err, results);
1072+
},
10491073
);
10501074
});
10511075
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"@opentelemetry/instrumentation-ioredis": "~0.64.0",
3636
"@opentelemetry/instrumentation-mongodb": "~0.69.0",
3737
"@smithy/node-http-handler": "^3.0.0",
38-
"arsenal": "git+https://github.com/scality/arsenal#8.5.12",
38+
"arsenal": "git+https://github.com/scality/arsenal#8.5.14",
3939
"async": "2.6.4",
4040
"aws-crt": "^1.24.0",
4141
"bucketclient": "scality/bucketclient#8.2.7",

tests/functional/backbeat/putMetadata.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,51 @@ describe('putMetadata : microVersionId conditional updates (no replication conte
289289
},
290290
);
291291
});
292+
293+
it('should allow first write to pre-cascade object then reject the same microVersionId', async () => {
294+
const key = 'putmetadata-cond-pre-cascade';
295+
296+
// Step 1: write a pre-cascade object : no microVersionId in body, no header
297+
await backbeatClient.send(
298+
new PutMetadataCommand({
299+
Bucket: TEST_BUCKET,
300+
Key: key,
301+
Body: buildMetadataBody({}),
302+
}),
303+
);
304+
const { Body: beforeBody } = await backbeatClient.send(
305+
new GetMetadataCommand({ Bucket: TEST_BUCKET, Key: key }),
306+
);
307+
assert.strictEqual(
308+
new ObjectMD(JSON.parse(beforeBody)).getMicroVersionId(),
309+
undefined,
310+
'pre-cascade object should have no microVersionId',
311+
);
312+
313+
// Step 2: first putMetadata with a microVersionId : $exists: false arm passes
314+
const mvId = makeMicroVersionId();
315+
await putMetadata(key, mvId);
316+
const { Body: afterFirst } = await backbeatClient.send(
317+
new GetMetadataCommand({ Bucket: TEST_BUCKET, Key: key }),
318+
);
319+
assert.strictEqual(
320+
new ObjectMD(JSON.parse(afterFirst)).getMicroVersionId(),
321+
mvId.raw,
322+
'first write should store the microVersionId',
323+
);
324+
325+
// Step 3: second write with the same microVersionId : already stored, should be rejected
326+
await assert.rejects(
327+
() => putMetadata(key, mvId),
328+
err => {
329+
assert.ok(
330+
err instanceof MicroVersionIdAlreadyStoredException,
331+
`expected MicroVersionIdAlreadyStoredException, got ${err.constructor.name}`,
332+
);
333+
return true;
334+
},
335+
);
336+
});
292337
});
293338

294339
// These tests send x-scal-replication-content to simulate backbeat replication writes.

tests/unit/routes/routeBackbeat.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,115 @@ describe('routeBackbeat', () => {
395395
assert.deepStrictEqual(mockResponse.body, {});
396396
});
397397

398+
it('should set an atomic microVersionId condition when updating an existing version', async () => {
399+
// reverse-chronological ordering: sorted ascending, the newer revision (incoming) sorts first
400+
const [incomingRaw, storedRaw] = [
401+
versioning.VersionID.generateVersionId('test', 'RG001'),
402+
versioning.VersionID.generateVersionId('test', 'RG001'),
403+
].sort();
404+
405+
mockRequest = preparePutMetadataRequest({ microVersionId: incomingRaw });
406+
mockRequest.headers['x-scal-micro-version-id'] = versioning.VersionID.encode(incomingRaw);
407+
mockRequest.url = '/_/backbeat/metadata/bucket0/key0' + '?versionId=aIXVkw5Tw2Pd00000000001I4j3QKsvf';
408+
409+
metadataUtils.standardMetadataValidateBucketAndObj.callsFake((_params, _denies, _log, callback) => {
410+
callback(null, bucketInfo, { microVersionId: storedRaw });
411+
});
412+
413+
const putObjectMDStub = sandbox
414+
.stub(metadata, 'putObjectMD')
415+
.callsFake((_bucketName, _objectKey, _omVal, options, _logParam, cb) => {
416+
assert.deepStrictEqual(options.conditions, {
417+
$or: [{ microVersionId: { $exists: false } }, { microVersionId: { $gt: incomingRaw } }],
418+
});
419+
cb(null, {});
420+
});
421+
422+
routeBackbeat('127.0.0.1', mockRequest, mockResponse, log);
423+
void (await endPromise);
424+
425+
sinon.assert.called(putObjectMDStub);
426+
assert.strictEqual(mockResponse.statusCode, 200);
427+
});
428+
429+
it('should return 409 without writing when the incoming microVersionId is already stored', async () => {
430+
const incomingRaw = versioning.VersionID.generateVersionId('test', 'RG001');
431+
432+
mockRequest = preparePutMetadataRequest({ microVersionId: incomingRaw });
433+
mockRequest.headers['x-scal-micro-version-id'] = versioning.VersionID.encode(incomingRaw);
434+
mockRequest.url = '/_/backbeat/metadata/bucket0/key0' + '?versionId=aIXVkw5Tw2Pd00000000001I4j3QKsvf';
435+
436+
metadataUtils.standardMetadataValidateBucketAndObj.callsFake((_params, _denies, _log, callback) => {
437+
callback(null, bucketInfo, { microVersionId: incomingRaw });
438+
});
439+
440+
const putObjectMDStub = sandbox.stub(metadata, 'putObjectMD');
441+
442+
routeBackbeat('127.0.0.1', mockRequest, mockResponse, log);
443+
void (await endPromise);
444+
445+
sinon.assert.notCalled(putObjectMDStub);
446+
assert.strictEqual(mockResponse.statusCode, 409);
447+
assert.strictEqual(mockResponse.body.code, 'MicroVersionIdAlreadyStoredException');
448+
});
449+
450+
it(
451+
'should set an atomic microVersionId condition even when the stored revision has none ' +
452+
'(pre-cascade object)',
453+
async () => {
454+
const incomingRaw = versioning.VersionID.generateVersionId('test', 'RG001');
455+
456+
mockRequest = preparePutMetadataRequest({ microVersionId: incomingRaw });
457+
mockRequest.headers['x-scal-micro-version-id'] = versioning.VersionID.encode(incomingRaw);
458+
mockRequest.url = '/_/backbeat/metadata/bucket0/key0' + '?versionId=aIXVkw5Tw2Pd00000000001I4j3QKsvf';
459+
460+
metadataUtils.standardMetadataValidateBucketAndObj.callsFake((_params, _denies, _log, callback) => {
461+
callback(null, bucketInfo, {});
462+
});
463+
464+
const putObjectMDStub = sandbox
465+
.stub(metadata, 'putObjectMD')
466+
.callsFake((_bucketName, _objectKey, _omVal, options, _logParam, cb) => {
467+
assert.deepStrictEqual(options.conditions, {
468+
$or: [{ microVersionId: { $exists: false } }, { microVersionId: { $gt: incomingRaw } }],
469+
});
470+
cb(null, {});
471+
});
472+
473+
routeBackbeat('127.0.0.1', mockRequest, mockResponse, log);
474+
void (await endPromise);
475+
476+
sinon.assert.called(putObjectMDStub);
477+
assert.strictEqual(mockResponse.statusCode, 200);
478+
},
479+
);
480+
481+
it('should return 409 when the metadata write rejects a stale microVersionId', async () => {
482+
const [incomingRaw, storedRaw] = [
483+
versioning.VersionID.generateVersionId('test', 'RG001'),
484+
versioning.VersionID.generateVersionId('test', 'RG001'),
485+
].sort();
486+
487+
mockRequest = preparePutMetadataRequest({ microVersionId: incomingRaw });
488+
mockRequest.headers['x-scal-micro-version-id'] = versioning.VersionID.encode(incomingRaw);
489+
mockRequest.url = '/_/backbeat/metadata/bucket0/key0' + '?versionId=aIXVkw5Tw2Pd00000000001I4j3QKsvf';
490+
491+
metadataUtils.standardMetadataValidateBucketAndObj.callsFake((_params, _denies, _log, callback) => {
492+
callback(null, bucketInfo, { microVersionId: storedRaw });
493+
});
494+
495+
sandbox
496+
.stub(metadata, 'putObjectMD')
497+
.callsFake((_bucketName, _objectKey, _omVal, _options, _logParam, cb) => {
498+
cb(errors.PreconditionFailed);
499+
});
500+
501+
routeBackbeat('127.0.0.1', mockRequest, mockResponse, log);
502+
void (await endPromise);
503+
504+
assert.strictEqual(mockResponse.statusCode, 409);
505+
});
506+
398507
it('should handle error when putting metadata', async () => {
399508
const putObjectMDStub = sandbox.stub(metadata, 'putObjectMD');
400509
putObjectMDStub.onCall(0).callsFake((bucketName, objectKey, omVal, options, logParam, cb) => {

0 commit comments

Comments
 (0)