From e49d7c6bcc4c97bab4e68c7ef2ab1c0bfc326c8c Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Thu, 17 Sep 2026 15:47:44 +0200 Subject: [PATCH 1/2] Normalise archive timestamps before comparing them `_isObjectCold`, `_isObjectRestoring` and `_isObjectRestored` compared the archive timestamps directly with `Date.now()`, which returns a Number. A relational operator coerces both sides with ToNumeric, so a `Date` resolves to its epoch milliseconds and compares correctly while an ISO string resolves to `NaN` and every comparison is false. Arsenal types `restoreRequestedAt`, `restoreCompletedAt` and `restoreWillExpireAt` as `Date | string` and its setters validate the value but store it unchanged, so a caller that round-trips the metadata through JSON writes strings into MongoDB. With string timestamps only the never-restored case survived, because `!restoreRequestedAt` short-circuits before any comparison: every object which had ever had a restore requested fell through all three predicates and was accumulated into the plain masterCount/masterData counters as an ordinary hot object. Its bytes were also left out of the destination location while restoring, and out of the cold location once restored. Wrap each timestamp in `new Date()` before comparing, which is what cloudserver and backbeat already do at every equivalent site. Behaviour is unchanged for `Date` values and for objects with no `archive`. The existing tests built their fixtures exclusively with `new Date(...)`, so they never exercised the string form; they are now run over both. Issue: S3UTILS-246 --- tests/unit/utils/S3UtilsMongoClient.js | 139 ++++++++++++++++++++----- utils/S3UtilsMongoClient.js | 13 ++- 2 files changed, 122 insertions(+), 30 deletions(-) diff --git a/tests/unit/utils/S3UtilsMongoClient.js b/tests/unit/utils/S3UtilsMongoClient.js index 01b5fa77..f16485d9 100644 --- a/tests/unit/utils/S3UtilsMongoClient.js +++ b/tests/unit/utils/S3UtilsMongoClient.js @@ -891,6 +891,39 @@ describe('S3UtilsMongoClient::_processEntryData', () => { }, }, ], + [ + 'should add content-length to cold storage location and current dataStore ' + + 'when object is restoring with string typed timestamps', + testBucketName, + true, + { + _id: 'testkey6b', + value: { + ...objectMdTemp, + dataStoreName: 'cold-location', + replicationInfo: { + backends: [], + }, + archive: { + archiveInfo: {}, + restoreRequestedAt: new Date(Date.now() - 1000).toISOString(), + restoreCompletedAt: null, + restoreWillExpireAt: null, + }, + }, + }, + locationConfig, + { + data: { + account: { [testAccountCanonicalId]: 42 }, + bucket: { [`${testBucketName}_${testBucketCreationDate}`]: 42 }, + location: { + 'us-east-1': 42, + 'cold-location': 42, + }, + }, + }, + ], [ 'should add content-length to cold storage location and current dataStore ' + 'when object is restored', @@ -923,6 +956,38 @@ describe('S3UtilsMongoClient::_processEntryData', () => { }, }, ], + [ + 'should add content-length to cold storage location and current dataStore ' + + 'when object is restored with string typed timestamps', + testBucketName, + true, + { + _id: 'testkey6c', + value: { + ...objectMdTemp, + 'replicationInfo': { + backends: [], + }, + 'archive': { + archiveInfo: {}, + restoreCompletedAt: new Date(Date.now() - 1000).toISOString(), + restoreWillExpireAt: new Date(Date.now() + 1000).toISOString(), + }, + 'x-amz-storage-class': 'cold-location', + }, + }, + locationConfig, + { + data: { + account: { [testAccountCanonicalId]: 42 }, + bucket: { [`${testBucketName}_${testBucketCreationDate}`]: 42 }, + location: { + 'us-east-1': 42, + 'cold-location': 42, + }, + }, + }, + ], [ 'should return error if content-length is invalid', testBucketName, @@ -3174,31 +3239,55 @@ describe('S3UtilsMongoClient, cold object helpers', () => { assert.strictEqual(mongoTestClient._isObjectCold(coldObject), true); }); - it('should detect a restoring object', () => { - const restoringObject = { - value: { - ...coldObjectMdTemp, - archive: new ObjectMDArchive({}, new Date(Date.now() - 5000), 10), - dataStoreName: 'cold-location', - }, - }; - assert.strictEqual(mongoTestClient._isObjectRestoring(restoringObject), true); - }); + // Arsenal types the archive timestamps `Date | string` and stores them as + // given, so both forms reach these helpers. + describe.each([ + ['Date', date => date], + ['string', date => date.toISOString()], + ])('with %s timestamps', (label, stored) => { + it('should detect a restoring object', () => { + const restoringObject = { + value: { + ...coldObjectMdTemp, + archive: new ObjectMDArchive({}, stored(new Date(Date.now() - 5000)), 10), + dataStoreName: 'cold-location', + }, + }; + assert.strictEqual(mongoTestClient._isObjectRestoring(restoringObject), true); + }); - it('should detect a restored object', () => { - const restoredObject = { - value: { - ...coldObjectMdTemp, - archive: new ObjectMDArchive( - {}, - new Date(Date.now() - 5000), - 10, - new Date(Date.now() - 1000), - new Date(Date.now() + 10000), - ), - dataStoreName: 'us-east-1', - }, - }; - assert.strictEqual(mongoTestClient._isObjectRestored(restoredObject), true); + it('should detect a restored object', () => { + const restoredObject = { + value: { + ...coldObjectMdTemp, + archive: new ObjectMDArchive( + {}, + stored(new Date(Date.now() - 5000)), + 10, + stored(new Date(Date.now() - 1000)), + stored(new Date(Date.now() + 10000)), + ), + dataStoreName: 'us-east-1', + }, + }; + assert.strictEqual(mongoTestClient._isObjectRestored(restoredObject), true); + }); + + it('should detect an object with an expired restore as cold', () => { + const expiredObject = { + value: { + ...coldObjectMdTemp, + archive: new ObjectMDArchive( + {}, + stored(new Date(Date.now() - 90000)), + 10, + stored(new Date(Date.now() - 80000)), + stored(new Date(Date.now() - 1000)), + ), + dataStoreName: 'cold-location', + }, + }; + assert.strictEqual(mongoTestClient._isObjectCold(expiredObject), true); + }); }); }); diff --git a/utils/S3UtilsMongoClient.js b/utils/S3UtilsMongoClient.js index 3934a0b9..e2b19336 100644 --- a/utils/S3UtilsMongoClient.js +++ b/utils/S3UtilsMongoClient.js @@ -1094,7 +1094,8 @@ class S3UtilsMongoClient extends MongoClientInterface { */ _isObjectCold(entry) { return entry.value.archive - && (!entry.value.archive.restoreRequestedAt || entry.value.archive.restoreWillExpireAt <= Date.now()); + && (!entry.value.archive.restoreRequestedAt + || new Date(entry.value.archive.restoreWillExpireAt) <= Date.now()); } /** @@ -1104,8 +1105,9 @@ class S3UtilsMongoClient extends MongoClientInterface { */ _isObjectRestoring(entry) { return entry.value.archive - && entry.value.archive.restoreRequestedAt <= Date.now() - && (!entry.value.archive.restoreCompletedAt || entry.value.archive.restoreCompletedAt > Date.now()); + && new Date(entry.value.archive.restoreRequestedAt) <= Date.now() + && (!entry.value.archive.restoreCompletedAt + || new Date(entry.value.archive.restoreCompletedAt) > Date.now()); } /** @@ -1115,8 +1117,9 @@ class S3UtilsMongoClient extends MongoClientInterface { */ _isObjectRestored(entry) { return entry.value.archive - && entry.value.archive.restoreCompletedAt && (entry.value.archive.restoreCompletedAt <= Date.now()) - && entry.value.archive.restoreWillExpireAt > Date.now(); + && entry.value.archive.restoreCompletedAt + && (new Date(entry.value.archive.restoreCompletedAt) <= Date.now()) + && new Date(entry.value.archive.restoreWillExpireAt) > Date.now(); } } From 7ec47b056a59dcb812f5b9ad9c7eb0ed429aa8fc Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Fri, 25 Sep 2026 00:05:58 +0200 Subject: [PATCH 2/2] Bump package.json to 1.17.15 Issue: S3UTILS-246 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e89650e..58ea2de1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "s3utils", - "version": "1.17.14", + "version": "1.17.15", "engines": { "node": ">= 22" },