Skip to content

Trigger localization of clean room objects - #2829

Draft
francoisferrand wants to merge 4 commits into
development/9.6from
improvement/BB-814
Draft

Trigger localization of clean room objects#2829
francoisferrand wants to merge 4 commits into
development/9.6from
improvement/BB-814

Conversation

@francoisferrand

@francoisferrand francoisferrand commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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 that, since bootstrap, re-bootstrap and streamed updates all go through the same oplog, so a single code path covers all three. When an object lands on a location flagged isCRR, we publish a copyLocation action on the data mover topic and let the existing data mover + transition merge pipeline do the actual copy.

The check sits before any replication condition, on purpose: replicationInfo describes replication of a local object to remote sites, and the clean room copy resets it since the copy has not been replicated anywhere. A clean room site may also legitimately have its own forward replication rules. Localization is not tracked there either, updating location/dataStoreName is enough.

There is no activation switch: the isCRR condition cannot occur outside a clean room, so the flag on the location is the trigger, consistently with how the design gates the write-time master key handling.

Where the data goes

The target location is read from the object's source location entry, as targetLocation, next to the bucket and role the copy already needs: the source-side rewrite pipeline resolves it from the bucket when it synthesizes that entry. That keeps the populator, a single threaded oplog reader, from having to look the bucket up per object, and makes the destination a property of the bucket rather than of the deployment.

If that location no longer exists (deleted between the metadata write and the oplog entry), or the object predates the pipeline naming one, we log an error and fall back to the default local location, the first one that is neither cold nor isCRR: localizing elsewhere beats leaving the data on the source forever.

Account id resolution

The populator only knows the object owner's canonical id, and a Vault lookup per entry would throttle the whole populator. So the action carries target.owner only, and the lookup is deferred to the transition processor, which resolves it once up front and stamps it back onto the action, the same way the garbage collector already does. Lifecycle-originated actions always carry an account id, so the lookup never runs for them, and the GC entry emitted after the merge gets the resolved id rather than resolving it again.

Metrics

A localization is a lifecycle transition, only triggered from the oplog instead of a bucket scan, so it reports onLifecycleTriggered with the same labels the transition merge already reports onLifecycleCompleted with. Localized bytes are already accounted by CopyLocationTask through ReplicationMetrics. The queued counters do not fire, since we publish straight to the data mover topic instead of going through sendDataMoverAction, which needs a producer a populator extension does not have.

Skipped entries

Master keys (clean room buckets are versioned, the master is repaired by the metadata layer), delete markers, entries without a dataStoreName (partial oplog projections), and objects with no location, since a 0-byte object has nothing to localize. isCRR with a non-zero content length and no location is inconsistent metadata and gets logged as an error.

Duplicates are expected and harmless: the topic is keyed per object, the data mover dedupes in memory, CopyLocationTask skips already-transitioned versions, and the merge task skips duplicate location updates.

Not in this PR

  • BB-816: forward replication should skip non-localized objects
  • BB-817: change stream projection / bucket selection
  • BB-819: suppressing duplicates via transitionInProgress
  • ZENKO-5351: resolving the destination lazily instead, by letting Cloudserver apply the bucket's default location constraint at PUT time. That cannot be done by simply blanking the attribute (the data mover routes by location name and the multiple-backend route requires an explicit storage class), hence the design ticket.
  • BB-813 must land before this is enabled in a real clean room: after the merge, _garbageCollectLocation publishes a deleteData for the old location, which against an isCRR source would delete source data.

Note that the full unit suite could not be run locally (needs Kafka/Redis); lint and the replication, gc, queuePopulator, lib and lifecycle task specs are green.

Issue: BB-814

@bert-e

bert-e commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Hello francoisferrand,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval
/bypass_build_status Bypass the build and test status
/bypass_commit_size Bypass the check on the size of the changeset TBA
/bypass_incompatible_branch Bypass the check on the source branch prefix
/bypass_jira_check Bypass the Jira issue check
/bypass_peer_approval Bypass the pull request peers' approval
/bypass_leader_approval Bypass the pull request leaders' approval
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request.
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e

bert-e commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Incorrect fix version

The Fix Version/s in issue BB-814 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.6.0

Please check the Fix Version/s of BB-814, or the target
branch of this pull request.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.65625% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.91%. Comparing base (929145d) to head (aedd849).

Files with missing lines Patch % Lines
...xtensions/replication/ReplicationQueuePopulator.js 95.34% 2 Missing ⚠️
lib/util/transitionAttempt.js 91.66% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

Files with missing lines Coverage Δ
extensions/gc/tasks/GarbageCollectorTask.js 88.09% <100.00%> (+0.19%) ⬆️
extensions/lifecycle/LifecycleMetrics.js 97.14% <100.00%> (+0.08%) ⬆️
...ectProcessor/LifecycleObjectTransitionProcessor.js 86.53% <100.00%> (+7.08%) ⬆️
extensions/lifecycle/objectProcessor/task.js 0.00% <ø> (ø)
.../lifecycle/tasks/LifecycleColdStatusArchiveTask.js 92.15% <100.00%> (+0.32%) ⬆️
...le/tasks/LifecycleResetTransitionInProgressTask.js 80.95% <100.00%> (+0.95%) ⬆️
extensions/lifecycle/tasks/LifecycleTask.js 91.66% <100.00%> (-0.06%) ⬇️
...s/lifecycle/tasks/LifecycleUpdateTransitionTask.js 94.73% <100.00%> (+0.73%) ⬆️
extensions/replication/ReplicationMetric.js 96.42% <100.00%> (+0.13%) ⬆️
extensions/replication/tasks/CopyLocationTask.js 67.02% <100.00%> (ø)
... and 2 more

... and 3 files with indirect coverage changes

Components Coverage Δ
Bucket Notification 80.27% <ø> (ø)
Core Library 81.91% <91.66%> (-0.05%) ⬇️
Ingestion 70.09% <ø> (ø)
Lifecycle 80.83% <100.00%> (+0.36%) ⬆️
Oplog Populator 85.83% <ø> (ø)
Replication 62.54% <95.83%> (+0.52%) ⬆️
Bucket Scanner 85.76% <ø> (ø)
@@                 Coverage Diff                 @@
##           development/9.6    #2829      +/-   ##
===================================================
+ Coverage            75.77%   75.91%   +0.14%     
===================================================
  Files                  200      201       +1     
  Lines                13922    14024     +102     
===================================================
+ Hits                 10549    10647      +98     
- Misses                3363     3367       +4     
  Partials                10       10              
Flag Coverage Δ
api:retry 9.04% <0.00%> (-0.07%) ⬇️
api:routes 8.80% <0.00%> (-0.07%) ⬇️
bucket-scanner 85.76% <ø> (ø)
ft_test:queuepopulator 11.17% <12.50%> (+0.13%) ⬆️
ingestion 12.68% <10.15%> (+0.41%) ⬆️
lib 8.75% <0.00%> (-0.08%) ⬇️
lifecycle 19.21% <7.03%> (-0.11%) ⬇️
notification 1.01% <0.00%> (-0.01%) ⬇️
oplogPopulator 0.13% <0.00%> (-0.01%) ⬇️
replication 18.73% <6.25%> (-0.12%) ⬇️
unit 55.22% <96.87%> (+0.33%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

* @param {Function} cb - callback: cb(err, accountId)
* @return {undefined}
*/
getAccountId(ownerId, log, cb) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Async/await migration: getAccountId is a new callback-style function (cb parameter). Per the project's migrate-when-you-touch rule, new code should use async/await. Since vaultClientWrapper.getAccountId is callback-based, wrap it with util.promisify. The same applies to _resolveAccountId in LifecycleUpdateTransitionTask — once this method is async, the caller can await it (or keep backward compat via util.callbackify until the task's processActionEntry is also migrated).

_accountIdLookupEnabled() {
const authConfig = this.getAuthConfig(this._lcConfig);
return authConfig.type === authTypeAssumeRole &&
!!(this._processConfig.vaultAdmin || authConfig.vault);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

authConfig.vault fallback means any processor whose auth has type: 'assumeRole' and a vault field will also enable vault initialization and block isReady() until credentials arrive — not just the transition processor. The expiration processor falls through to lcConfig.auth which could carry vault in an assumeRole deployment.

Consider restricting to this._processConfig.vaultAdmin only, since that field is only in the transitionProcessor schema:

Suggested change
!!(this._processConfig.vaultAdmin || authConfig.vault);
!!(this._processConfig.vaultAdmin);

* @param {Function} cb - callback: cb(err, accountId)
* @return {undefined}
*/
getAccountId(ownerId, log, cb) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getAccountId and _resolveAccountId are new functions using the callback pattern. Per the migrate-when-you-touch policy, new code should use async/await. The callee (VaultClientWrapper.getAccountId) can be wrapped with util.promisify, and backward compatibility for the callback-based caller chain can be kept with util.callbackify. (Suggestion, not a blocker.)

const { traceHeadersFromEntry } = require('arsenal/build/lib/tracing').kafka;

const TRANSITION_ATTEMPT_MD = 'x-amz-meta-scal-s3-transition-attempt';
const { transitionTasksTopic } = config.extensions.lifecycle;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Module-level destructuring of config.extensions.lifecycle crashes the queue populator on import if lifecycle is not configured — even when localization is disabled. Since LogReader.js requires this module unconditionally, any replication-only deployment would fail to start.

Move this into _publishLocalizationAction (the only consumer) so the access is deferred to when localization is actually enabled:

Suggested change
const { transitionTasksTopic } = config.extensions.lifecycle;
const { transitionTasksTopic } = config.extensions?.lifecycle ?? {};

Alternatively, add a config validation in Config.js that localization requires lifecycle to be configured.

The x-amz-meta-scal-s3-transition-attempt key was open-coded in five
places across lifecycle and gc, each with a slightly different way of
reading or clearing it - one of which would throw on user metadata that
does not parse. Move it behind a small helper, which the localization
work needs to read as well.

Issue: BB-814
copyLocation actions were all reported as transitions, which is about to
stop being true: localization uses the same pipeline but has a trigger
latency of milliseconds where a transition is minutes to hours, and the
two would share histogram buckets that start at a minute. Derive the
metrics type from the origin of the action instead, so trigger, start
and completion keep pairing up.

Issue: BB-814
The lifecycle conductor knows the account id of the bucket it is
scanning and stamps it on the actions it publishes. The queue populator
does not: it works off the oplog, where an object only carries its
owner's canonical id, and resolving an account per entry would throttle
the whole populator.

So let the transition processor do the lookup, once per action and only
when needed, the same way the garbage collector already does, and pass
the result on to the garbage collection entry it emits.

Issue: BB-814
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
// one needing a vault client to resolve them.
const authConfig = this.getAuthConfig(this._lcConfig);
if (authConfig.type === authTypeAssumeRole &&
(vaultAdminConfig || authConfig.vault)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The authConfig.vault fallback means the transition processor initializes a vault client and blocks isReady() on credential arrival in any assumeRole deployment that has a vault field in its auth config — even when clean room localization is not in use and vaultAdminConfig was not explicitly set.

Consider gating solely on the explicit vaultAdminConfig parameter:

Suggested change
(vaultAdminConfig || authConfig.vault)) {
if (authConfig.type === authTypeAssumeRole && vaultAdminConfig) {

(Note: a previous comment raised this same concern but was posted against LifecycleObjectProcessor.js, which was not modified by this PR and may not be visible in the diff view.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants