Skip to content

Commit aedd849

Browse files
Trigger localization of clean room objects
In a clean room, objects are created locally but their metadata still points at the source cluster's location: the data itself has not been copied over yet. Something has to notice those objects and ask for the data to be pulled in. The queue populator is the natural place for it, since bootstrap, re-bootstrap and streamed updates all go through the same oplog. When an object lands on a location flagged isCRR, publish a copyLocation action on the data mover topic and let the existing data mover + transition merge pipeline do the actual copy. The destination comes from the object metadata, which the source-side rewrite stamps as it prepares the entry; if it names a location we do not know, fall back to the first local one and log about it. This is unrelated to replicationInfo, which describes replication of a *local* object to remote sites, so the check sits before any replication condition. Localization is neither lifecycle nor CRR replication, so it gets its own action origin, and the legacy CRR byte metrics - which only make sense for replication to a remote site - skip it like they already skip lifecycle. Issue: BB-814
1 parent 02739de commit aedd849

6 files changed

Lines changed: 447 additions & 23 deletions

File tree

conf/locationConfig.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,12 @@
4242
"legacyAwsBehavior": false,
4343
"isCold": true,
4444
"details": {}
45+
},
46+
"location-crr-source": {
47+
"type": "scality",
48+
"objectId": "location-crr-source",
49+
"legacyAwsBehavior": false,
50+
"isCRR": true,
51+
"details": {}
4552
}
4653
}

extensions/replication/ReplicationMetric.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ const { Logger } = require('werelogs');
22

33
const MetricsModel = require('../../lib/models/MetricsModel');
44

5+
// Flows which report their own metrics, and for which the CRR metrics
6+
// below are meaningless: they are not replication to a CRR site.
7+
const ORIGINS_WITH_OWN_METRICS = ['lifecycle', 'localization'];
8+
59
/**
610
* Legacy: consider converting replication metrics with
711
* Prometheus-based {@link ReplicationMetrics} class
@@ -46,9 +50,9 @@ class ReplicationMetric {
4650
return this;
4751
}
4852

49-
_isLifecycleAction() {
53+
_hasOwnMetrics() {
5054
const { origin } = this._entry.getContext();
51-
return origin !== undefined && origin === 'lifecycle';
55+
return ORIGINS_WITH_OWN_METRICS.includes(origin);
5256
}
5357

5458
_createProducerMessage() {
@@ -65,8 +69,8 @@ class ReplicationMetric {
6569
}
6670

6771
publish() {
68-
// Lifecycle metrics not yet implemented.
69-
if (this._isLifecycleAction()) {
72+
// Lifecycle and localization metrics not yet implemented.
73+
if (this._hasOwnMetrics()) {
7074
return undefined;
7175
}
7276
const message = this._createProducerMessage();

extensions/replication/ReplicationQueuePopulator.js

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
const { isMasterKey } = require('arsenal').versioning;
2+
const { encode } = require('arsenal').versioning.VersionID;
23
const { usersBucket, mpuBucketPrefix } = require('arsenal').constants;
34

45
const QueuePopulatorExtension =
56
require('../../lib/queuePopulator/QueuePopulatorExtension');
67
const ObjectQueueEntry = require('../../lib/models/ObjectQueueEntry');
8+
const ReplicationAPI = require('./ReplicationAPI');
9+
const { LifecycleMetrics, LOCALIZATION_TYPE } = require('../lifecycle/LifecycleMetrics');
10+
const config = require('../../lib/Config');
711
const locationsConfig = require('../../conf/locationConfig.json') || {};
812
const safeJsonParse = require('../../lib/util/safeJsonParse');
13+
const { getTransitionAttempt } = require('../../lib/util/transitionAttempt');
914
const { traceHeadersFromEntry } = require('arsenal/build/lib/tracing').kafka;
1015

16+
const { transitionTasksTopic } = config.extensions.lifecycle;
17+
18+
// Where clean room objects are localized when their metadata does not name a
19+
// usable target. Cold and source (isCRR) locations can never hold localized
20+
// data, any other one is a valid local destination.
21+
const defaultLocalLocation = Object.keys(locationsConfig).find(
22+
name => !locationsConfig[name].isCold && !locationsConfig[name].isCRR);
23+
1124
class ReplicationQueuePopulator extends QueuePopulatorExtension {
1225
constructor(params) {
1326
super(params);
@@ -73,6 +86,15 @@ class ReplicationQueuePopulator extends QueuePopulatorExtension {
7386
if (sanityCheckRes) {
7487
return;
7588
}
89+
const locationConfig = locationsConfig[queueEntry.getDataStoreName()] || {};
90+
// Clean room: the object data still lives on the source (isCRR)
91+
// location and first needs to be localized. This is unrelated to
92+
// replicationInfo, which tracks replication of a *local* object to
93+
// remote sites, hence the check before any replication condition.
94+
if (locationConfig.isCRR) {
95+
this._publishLocalizationAction(entry, queueEntry, value);
96+
return;
97+
}
7698
// Allow a non-versioned object if being replicated from an NFS bucket.
7799
// Or if the master key is of a non versioned object
78100
if (!this._entryCanBeReplicated(queueEntry)) {
@@ -81,11 +103,8 @@ class ReplicationQueuePopulator extends QueuePopulatorExtension {
81103
if (queueEntry.getReplicationStatus() !== 'PENDING') {
82104
return;
83105
}
84-
const dataStoreName = queueEntry.getDataStoreName();
85-
const isObjectCold = dataStoreName && locationsConfig[dataStoreName]
86-
&& locationsConfig[dataStoreName].isCold;
87106
// We do not replicate cold objects.
88-
if (isObjectCold) {
107+
if (locationConfig.isCold) {
89108
return;
90109
}
91110

@@ -124,6 +143,132 @@ class ReplicationQueuePopulator extends QueuePopulatorExtension {
124143
traceHeaders);
125144
}
126145

146+
/**
147+
* Queue a copyLocation action for an object whose data still lives on the
148+
* source (isCRR) location, so the data mover copies it to the local
149+
* location and the transition processor merges the new location back into
150+
* the object metadata.
151+
*
152+
* Duplicates are expected (and harmless): the same object may show up
153+
* several times in the oplog, and the copy is idempotent.
154+
*
155+
* @param {Object} entry - raw metadata log entry
156+
* @param {ObjectQueueEntry} queueEntry - parsed entry
157+
* @param {Object} value - parsed entry metadata
158+
* @return {undefined}
159+
*/
160+
_publishLocalizationAction(entry, queueEntry, value) {
161+
// Clean room buckets are versioned: the master key is repaired by the
162+
// metadata layer once the version has been localized.
163+
if (isMasterKey(queueEntry.getObjectVersionedKey())) {
164+
return;
165+
}
166+
if (queueEntry.getIsDeleteMarker()) {
167+
return;
168+
}
169+
const locations = queueEntry.getLocation();
170+
if (!locations || locations.length === 0) {
171+
// Empty objects hold no data, there is nothing to localize. Any
172+
// other object without location information is inconsistent.
173+
if (queueEntry.getContentLength() > 0) {
174+
this.log.error('non-empty object without location, skipping localization', {
175+
method: 'ReplicationQueuePopulator._publishLocalizationAction',
176+
...queueEntry.getLogInfo(),
177+
dataStoreName: queueEntry.getDataStoreName(),
178+
contentLength: queueEntry.getContentLength(),
179+
});
180+
}
181+
return;
182+
}
183+
184+
const bucket = queueEntry.getBucket();
185+
const objectKey = queueEntry.getObjectKey();
186+
const contentLength = queueEntry.getContentLength();
187+
const targetLocation = this._getLocalizationTarget(queueEntry, locations);
188+
if (!targetLocation) {
189+
return;
190+
}
191+
const transitionTime = new Date(entry.overheadFields?.commitTimestamp ?? Date.now());
192+
const action = ReplicationAPI.createCopyLocationAction({
193+
bucketName: bucket,
194+
objectKey,
195+
owner: queueEntry.getOwnerId(),
196+
versionId: value.versionId ? encode(value.versionId) : undefined,
197+
eTag: `"${queueEntry.getContentMd5()}"`,
198+
lastModified: queueEntry.getLastModified(),
199+
toLocation: targetLocation,
200+
originLabel: 'localization',
201+
fromLocation: queueEntry.getDataStoreName(),
202+
contentLength,
203+
resultsTopic: transitionTasksTopic,
204+
transitionTime: transitionTime.toISOString(),
205+
attempt: getTransitionAttempt(queueEntry.getUserMetadata()),
206+
});
207+
// 'transition' is what the lifecycle transition processor dispatches
208+
// on to pick up the copyLocation result.
209+
action.addContext({
210+
origin: 'localization',
211+
ruleType: 'transition',
212+
bucketName: bucket,
213+
objectKey,
214+
versionId: value.versionId,
215+
});
216+
action.setAttribute('source', {
217+
bucket,
218+
objectKey,
219+
storageClass: queueEntry.getDataStoreName(),
220+
});
221+
222+
LifecycleMetrics.onLifecycleTriggered(this.log, 'queuePopulator',
223+
LOCALIZATION_TYPE, targetLocation, Date.now() - transitionTime.getTime());
224+
225+
this.log.trace('publishing object localization entry', { entry: queueEntry.getLogInfo() });
226+
this.publish(ReplicationAPI.getDataMoverTopic(),
227+
`${bucket}/${objectKey}`,
228+
action.toKafkaMessage(),
229+
undefined,
230+
traceHeadersFromEntry(value));
231+
}
232+
233+
/**
234+
* Local location the object data must be copied to.
235+
*
236+
* It is named in the source location entry itself, next to the bucket and
237+
* role the copy needs: the rewrite pipeline resolves it from the bucket
238+
* when it synthesizes that entry, which keeps this populator -a single
239+
* threaded oplog reader- from having to look the bucket up per object.
240+
*
241+
* @param {ObjectQueueEntry} queueEntry - parsed entry
242+
* @param {Object[]} locations - object data locations
243+
* @return {String|undefined} target location, undefined if there is none
244+
*/
245+
_getLocalizationTarget(queueEntry, locations) {
246+
const { targetLocation } = locations[0];
247+
if (locationsConfig[targetLocation]) {
248+
return targetLocation;
249+
}
250+
// Either the object predates the rewrite pipeline naming a target, or
251+
// the location was deleted since the metadata was written. Neither is
252+
// recoverable here, so fall back to the default location: localizing
253+
// elsewhere beats leaving the data on the source forever.
254+
if (!defaultLocalLocation) {
255+
this.log.error('invalid localization target and no local location ' +
256+
'to fall back to, skipping localization', {
257+
method: 'ReplicationQueuePopulator._getLocalizationTarget',
258+
...queueEntry.getLogInfo(),
259+
targetLocation,
260+
});
261+
return undefined;
262+
}
263+
this.log.error('invalid localization target in object metadata', {
264+
method: 'ReplicationQueuePopulator._getLocalizationTarget',
265+
...queueEntry.getLogInfo(),
266+
targetLocation,
267+
fallbackLocation: defaultLocalLocation,
268+
});
269+
return defaultLocalLocation;
270+
}
271+
127272
/**
128273
* Filter if the entry is considered a valid master key entry.
129274
* There is a case where a single null entry looks like a master key and

tests/unit/ReplicationMetric.js

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,27 +56,25 @@ describe('ReplicationMetric', () => {
5656
.forEach(key => assert.strictEqual(data[key], mock[key]));
5757
});
5858

59-
it('::_isLifecycleAction should return false by default', () => {
59+
it('::_hasOwnMetrics should return false by default', () => {
6060
metric.withEntry(entry);
61-
assert.strictEqual(metric._isLifecycleAction(), false);
61+
assert.strictEqual(metric._hasOwnMetrics(), false);
6262
});
6363

64-
it('::_isLifecycleAction should return true when origin is lifecycle',
65-
() => {
66-
entry.setAttribute('contextInfo', {
67-
origin: 'lifecycle',
64+
['lifecycle', 'localization'].forEach(origin => {
65+
it(`::_hasOwnMetrics should return true when origin is ${origin}`,
66+
() => {
67+
entry.setAttribute('contextInfo', { origin });
68+
metric.withEntry(entry);
69+
assert.strictEqual(metric._hasOwnMetrics(), true);
6870
});
69-
metric.withEntry(entry);
70-
assert.strictEqual(metric._isLifecycleAction(), true);
71-
});
7271

73-
it('::publish should not send data to topic if lifecycle task', () => {
74-
entry.setAttribute('contextInfo', {
75-
origin: 'lifecycle',
72+
it(`::publish should not send data to topic for a ${origin} action`, () => {
73+
entry.setAttribute('contextInfo', { origin });
74+
metric.withEntry(entry);
75+
metric.publish();
76+
assert.strictEqual(sentMessages.length, 0);
7677
});
77-
metric.withEntry(entry);
78-
metric.publish();
79-
assert.strictEqual(sentMessages.length, 0);
8078
});
8179

8280
it('::publish should send data to topic', () => {

tests/unit/lifecycle/CircuitBreakerGroup.spec.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@ describe('extractBucketProcessorCircuitBreakerConfigs', () => {
436436
'${location}',
437437
'location-dmf-v1',
438438
),
439+
formatProbeConfig(
440+
topicSpecificLocationTemplateProbe,
441+
'${location}',
442+
'location-crr-source',
443+
),
439444
],
440445
},
441446
global: [],
@@ -493,6 +498,11 @@ describe('extractBucketProcessorCircuitBreakerConfigs', () => {
493498
'${location}',
494499
'location-dmf-v1',
495500
),
501+
formatProbeConfig(
502+
topicSpecificLocationTemplateProbe,
503+
'${location}',
504+
'location-crr-source',
505+
),
496506
],
497507
},
498508
global: [],

0 commit comments

Comments
 (0)