Skip to content
Merged
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
351 changes: 351 additions & 0 deletions CRR/ReplicationSiteRemover.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
const { promisify } = require('util');
const { eachLimit } = require('async');
const { ObjectMD } = require('arsenal').models;
Comment thread
nicolas2bert marked this conversation as resolved.
const { ListObjectVersionsCommand, GetBucketReplicationCommand } = require('@aws-sdk/client-s3');
const CloudserverClient = require('../Clients/CloudserverClient');
const createS3Client = require('../Clients/s3Client');

const LOG_PROGRESS_INTERVAL_MS = 10000;

/**
* Returns the replication info of an object never set up for replication.
* Taken from arsenal ObjectMD defaults, so it follows the installed arsenal format.
* @returns {Object} Empty replication info (fresh copy).
*/
function getEmptyReplicationInfo() {
return new ObjectMD().getReplicationInfo();
}

/**
* Computes the global replication status from the remaining sites.
* A REPLICA backend means the object is a replica on this site. Its other
* backends are a frozen copy of the source (BB-875), so it stays REPLICA.
* On S3C 9.5 the REPLICA status is the only thing that keeps backbeat from
* replicating it again.
* Otherwise same order as backbeat: FAILED, then PENDING, then COMPLETED.
* One difference: a PENDING site gives PENDING, not PROCESSING, so the object
* is requeued for that site.
* @param {Array<Object>} backends - Remaining replication backends.
* @returns {string} Global replication status.
*/
function getGlobalStatus(backends) {
Comment thread
nicolas2bert marked this conversation as resolved.
const statuses = backends.map(b => b.status);
if (statuses.includes('REPLICA')) {
return 'REPLICA';
}
if (statuses.includes('FAILED')) {
return 'FAILED';
}
if (statuses.includes('PENDING')) {
return 'PENDING';
}
return 'COMPLETED';
}

/**
* Removes the site from the comma-separated site list.
* Also removes the item at the same position in the aligned list (storageType)
* when both lists have the same length.
* @param {string} storageClass - Comma-separated site list.
* @param {string} storageType - Comma-separated list aligned with the site list.
* @param {string} site - Site to remove.
* @returns {Object} Updated storageClass and storageType.
*/
function removeSiteFromLists(storageClass, storageType, site) {
const sites = storageClass ? storageClass.split(',') : [];
const types = storageType ? storageType.split(',') : [];
const idx = sites.findIndex(s => s.split(':preferred_read')[0] === site);
if (idx === -1) {
return { storageClass, storageType };
}
sites.splice(idx, 1);
if (types.length === sites.length + 1) {
types.splice(idx, 1);
}
return { storageClass: sites.join(','), storageType: types.join(',') };
}

/**
* Returns the sites set in the StorageClass of the bucket replication rules.
* @param {Array<Object>} rules - Bucket replication rules.
* @returns {Array<string>} Site names.
*/
function getRuleSites(rules) {
return (rules || [])
.map(rule => rule.Destination && rule.Destination.StorageClass)
.filter(Boolean)
.flatMap(storageClass => storageClass.split(','))
.map(site => site.split(':preferred_read')[0]);
}

class ReplicationSiteRemover {
/**
* @param {Object} params - Configuration.
* @param {Array<string>} params.buckets - Buckets to process.
* @param {string} params.siteToRemove - Replication site to remove from object metadata.
* @param {string} params.accessKey - Access key.
* @param {string} params.secretKey - Secret key.
* @param {string} params.endpoint - S3 endpoint.
* @param {boolean} [params.dryRun=true] - Log what would change, write nothing.
* @param {number} [params.workers=10] - Parallel metadata updates.
* @param {number} [params.listingLimit=1000] - Listing page size.
* @param {string} [params.targetPrefix] - Only process keys with this prefix.
* @param {number} [params.maxUpdates] - Stop after this many updates (resume with the markers).
* @param {number} [params.maxScanned] - Stop after this many scanned versions (resume with the markers).
* @param {string} [params.keyMarker] - Resume listing from this key (first bucket only).
* @param {string} [params.versionIdMarker] - Resume listing from this version (first bucket only).
* @param {boolean} [params.forceRemoveConfiguredSite=false] - Remove the site even if a bucket
* replication rule names it in StorageClass.
* @param {Object} log - Logger.
*/
constructor(params, log) {
this.buckets = params.buckets;
this.siteToRemove = params.siteToRemove;
Comment thread
nicolas2bert marked this conversation as resolved.
this.dryRun = params.dryRun !== false;
this.workers = params.workers || 10;
this.listingLimit = params.listingLimit || 1000;
this.targetPrefix = params.targetPrefix;
this.maxUpdates = params.maxUpdates;
this.maxScanned = params.maxScanned;
this.inputKeyMarker = params.keyMarker;
this.inputVersionIdMarker = params.versionIdMarker;
this.forceRemoveConfiguredSite = params.forceRemoveConfiguredSite === true;
this.log = log;

this.s3 = createS3Client({
accessKey: params.accessKey,
secretKey: params.secretKey,
endpoint: params.endpoint,
}, log);
this.cloudserverClient = new CloudserverClient(params.endpoint, params.accessKey, params.secretKey);

this.stats = {
scanned: 0, updated: 0, reset: 0, skipped: 0, errors: 0,
};
this._bucketInProgress = null;
this._keyMarker = null;
this._versionIdMarker = null;
}

_logProgress(message = 'progress update') {
this.log.info(message, {
dryRun: this.dryRun,
site: this.siteToRemove,
bucket: this._bucketInProgress,
keyMarker: this._keyMarker,
versionIdMarker: this._versionIdMarker,
...this.stats,
});
}

/**
* Returns the limit reached (MAX_UPDATES or MAX_SCANNED), if any.
* Checked at the end of each listing page.
* @returns {string|null} Limit name and value, or null.
*/
_limitReached() {
if (this.maxUpdates !== undefined && this.stats.updated >= this.maxUpdates) {
return `MAX_UPDATES (${this.maxUpdates})`;
}
if (this.maxScanned !== undefined && this.stats.scanned >= this.maxScanned) {
return `MAX_SCANNED (${this.maxScanned})`;
}
return null;
}

async _getMetadata(params) {
return promisify(this.cloudserverClient.getMetadata.bind(this.cloudserverClient))(params);
}

async _putMetadata(params) {
return promisify(this.cloudserverClient.putMetadata.bind(this.cloudserverClient))(params);
}

/**
* Removes the site from one object version. Never throws: errors are
* counted and logged, so one bad version does not stop the bucket.
* @param {string} bucket - Bucket name.
* @param {string} key - Object key.
* @param {string} versionId - Version ID.
* @returns {Promise<undefined>} Resolves when done.
*/
async _processVersion(bucket, key, versionId) {
const params = { Bucket: bucket, Key: key, VersionId: versionId };
const logFields = { bucket, key, versionId };
this.stats.scanned += 1;
try {
const mdRes = await this._getMetadata(params);
const originalMD = JSON.parse(mdRes.Body);
const objMD = new ObjectMD(originalMD);
if (objMD.getModelVersion() < originalMD['md-model-version']) {
this.stats.errors += 1;
this.log.error('model version regression, refusing to overwrite newer metadata', logFields);
return;
}
const repInfo = objMD.getReplicationInfo();
const backends = (repInfo && repInfo.backends) || [];
if (!backends.some(b => b.site === this.siteToRemove)) {
this.stats.skipped += 1;
return;
}
const remaining = backends.filter(b => b.site !== this.siteToRemove);
const { storageClass, storageType } = removeSiteFromLists(
objMD.getReplicationStorageClass(), objMD.getReplicationStorageType(), this.siteToRemove);
const reset = remaining.length === 0;
const newStatus = reset ? '' : getGlobalStatus(remaining);
this.log.info(this.dryRun ? 'would update object (dry run)' : 'updating object', {
...logFields,
action: reset ? 'reset' : 'remove-site',
isDeleteMarker: objMD.getIsDeleteMarker(),
oldStatus: repInfo.status,
newStatus,
oldStorageClass: objMD.getReplicationStorageClass(),
newStorageClass: storageClass,
});
if (!this.dryRun) {
if (reset) {
// the site was the only destination: back to the never-replicated state,
// so a crrExistingObjects run with the right SITE_NAME picks the object up
objMD.setReplicationInfo({
...getEmptyReplicationInfo(),
isNFS: repInfo.isNFS,
Comment thread
nicolas2bert marked this conversation as resolved.
});
// a replica stays a replica (S3C 10.1: isReplica). setReplicationInfo
// only keeps known fields, so set it back after.
if (repInfo.isReplica !== undefined) {
objMD.getReplicationInfo().isReplica = repInfo.isReplica;
}
} else {
objMD.setReplicationBackends(remaining)
.setReplicationStorageClass(storageClass)
Comment thread
nicolas2bert marked this conversation as resolved.
.setReplicationStorageType(storageType)
.setReplicationStatus(newStatus);
}
objMD.updateMicroVersionId();
Comment thread
nicolas2bert marked this conversation as resolved.

@leif-scality leif-scality Oct 15, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

R8 [Should fix: S3C 10.1] The microVersionId format. This predates the PR (S3UTILS-234, fixed only in 1.19+), but it matters for the recovery paths this PR documents.

This now ships to S3C 10.1 through 1.18.3. On 1.17/1.18, this line and crrExistingObjects write a random 16-hex microVersionId.

  • The mechanism. When the object already exists at the destination:
  • The format can't survive this. A random 16-hex value never survives encode/decode: 0/1000 (half come back different, half fail to decode).
  • E2E on 10.1: crrExistingObjects with the right site and TARGET_REPLICATION_STATUS=COMPLETED on an already-replicated object (the PR description's "requeued to its original site"):
    • The destination rejects it with x-scal-micro-version-id header does not match body microVersionId.
    • The entry goes to replay, and the object stays PENDING (watched for 4 min).
    • It reproduced on a V2 two-destination object too.
    • In another run, Backbeat skipped the metadata put and completed, so the outcome isn't deterministic.
  • Not affected: the cleanup itself, since its COMPLETED result triggers no replication.
  • Affected: a remover result of PENDING, or any later requeue of objects already at the destination.

Suggestion: since this ships on 1.18 for 10.1, backport S3UTILS-234 (6ca6cca: timestamp-ordered microVersionId with a random replicationGroupId) to both scripts.

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.

Agreed, it hits the recovery paths on 10.1. Tracked in S3C-11591, for both scripts. Until then, the README warns that on 10.1 a requeue of an object already at the destination can stay PENDING.

await this._putMetadata({ ...params, Body: objMD.getSerialized() });
}
this.stats.updated += 1;
if (reset) {
this.stats.reset += 1;
}
} catch (err) {
this.stats.errors += 1;
this.log.error('error updating object', { ...logFields, error: err.message });
}
}

/**
* Refuses to remove a site that a bucket replication rule names in StorageClass:
* that site is a real destination. Removing it would reset objects that replicate
* to it, and a real run can't be undone.
* A bucket without replication rules has nothing to protect.
* @param {string} bucket - Bucket name.
* @returns {Promise<undefined>} Resolves if the site can be removed, rejects otherwise.
*/
async _checkSiteNotInRules(bucket) {
if (this.forceRemoveConfiguredSite) {
return;
}
let rules;
try {
const res = await this.s3.send(new GetBucketReplicationCommand({ Bucket: bucket }));
rules = res.ReplicationConfiguration.Rules;
} catch (err) {
if (err.name === 'ReplicationConfigurationNotFoundError') {
return;
}
throw err;
}
const ruleSites = getRuleSites(rules);
if (ruleSites.includes(this.siteToRemove)) {
const message = `SITE_TO_REMOVE "${this.siteToRemove}" is a destination of bucket ${bucket} `
+ '(StorageClass of its replication rule). Removing it would reset objects that '
+ 'replicate to it. Check SITE_TO_REMOVE, or set FORCE_REMOVE_CONFIGURED_SITE=true';
this.log.error(message, { bucket, site: this.siteToRemove, ruleSites });
throw new Error(message);
}
}

/**
* Processes one bucket, page by page.
* @param {string} bucket - Bucket name.
* @returns {Promise<boolean>} True if the bucket was fully processed,
* false if stopped by MAX_UPDATES or MAX_SCANNED.
*/
async _processBucket(bucket) {
this._bucketInProgress = bucket;
await this._checkSiteNotInRules(bucket);

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.

[minor]
Please tell me if I understand this incorrectly. When I give the script several buckets, it looks at the replication rule of a bucket only when it gets to that bucket. It doesn't look at the rules of all the buckets first. So it can already change bucket "a" before it finds out, on bucket "b", that SITE_TO_REMOVE is a real site.

The dry run covers most of this: with the same bucket list, it stops on "b" before anything is written. The gap is a real run without that dry run first. Example: the real site is "dr", and I type SITE_TO_REMOVE=dr by mistake with DRY_RUN=false on buckets a,b. Bucket "a" has no StorageClass in its rule, so the script removes "dr" from every object in "a". Only when it reaches "b", whose rule says StorageClass=dr, does it stop. The final summary line doesn't print either, so I don't know how many objects it changed.

It only costs one GetBucketReplication call per bucket. Could the script read the rules of all the buckets at the start, before it changes any object, and still print the summary when it stops?

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.

Good catch. The script now logs the stats when it stops on an error, so you see how many objects it changed before the stop.

I kept the rules check per bucket. The dry run is the default, and it stops on "b" before anything is written. A real run without a dry run first is an operator choice, and the summary now shows what it changed.

this._keyMarker = this.inputKeyMarker || null;
this._versionIdMarker = this.inputVersionIdMarker || null;
// markers only apply to the first bucket processed
this.inputKeyMarker = undefined;
this.inputVersionIdMarker = undefined;
this.log.info('starting bucket', {
bucket, site: this.siteToRemove, dryRun: this.dryRun,
keyMarker: this._keyMarker, versionIdMarker: this._versionIdMarker,
});
do {
const data = await this.s3.send(new ListObjectVersionsCommand({
Bucket: bucket,
MaxKeys: this.listingLimit,
Prefix: this.targetPrefix,
KeyMarker: this._keyMarker || undefined,
VersionIdMarker: this._versionIdMarker || undefined,
}));
const versions = (data.Versions || []).concat(data.DeleteMarkers || []);
await eachLimit(versions, this.workers,
async v => this._processVersion(bucket, v.Key, v.VersionId));
this._keyMarker = data.NextKeyMarker || null;
this._versionIdMarker = data.NextVersionIdMarker || null;
if (this._limitReached()) {
return !(this._keyMarker || this._versionIdMarker);
}
} while (this._keyMarker || this._versionIdMarker);
this._logProgress('completed bucket');
return true;
}

/**
* Runs on all buckets, in order.
* Stops on a listing error, or when MAX_UPDATES or MAX_SCANNED is reached: then
* it logs the bucket list and markers to resume.
* @returns {Promise<Object>} Final stats.
*/
async run() {
const progressInterval = setInterval(() => this._logProgress(), LOG_PROGRESS_INTERVAL_MS);
try {
for (let i = 0; i < this.buckets.length; i++) {
const bucket = this.buckets[i].trim();
const bucketDone = await this._processBucket(bucket);
const limit = this._limitReached();
if (limit) {
const remaining = this.buckets.slice(bucketDone ? i + 1 : i);
if (remaining.length > 0) {
let message = `reached ${limit}, resume by re-running `
+ `with the bucket list "${remaining.join(',')}"`;
if (!bucketDone) {
message += ` and KEY_MARKER=${this._keyMarker} VERSION_ID_MARKER=${this._versionIdMarker}`;
}
this.log.info(message);
}
break;
}
}
this._bucketInProgress = null;
this._logProgress('completed task');
return this.stats;
} catch (err) {
// log what was already changed before the stop
this._logProgress('stopped on error');
throw err;
} finally {
clearInterval(progressInterval);
}
}
}

module.exports = ReplicationSiteRemover;
module.exports.getGlobalStatus = getGlobalStatus;
module.exports.removeSiteFromLists = removeSiteFromLists;
module.exports.getEmptyReplicationInfo = getEmptyReplicationInfo;
module.exports.getRuleSites = getRuleSites;
Loading
Loading