Skip to content

Pull replication of objects still on the source location - #2829

Open
francoisferrand wants to merge 7 commits into
development/9.6from
improvement/BB-814
Open

Pull replication of objects still on the source location#2829
francoisferrand wants to merge 7 commits into
development/9.6from
improvement/BB-814

Conversation

@francoisferrand

@francoisferrand francoisferrand commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Objects are created locally but their metadata still points at the source cluster: the data itself has not been copied over yet. Something has to notice them and ask for it 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. When an object lands on a location flagged isCRR, publish a copyLocation action and let the existing data mover/transition workflow do the actual copy. No activation switch: the flag on the location is the trigger. It is skipped without lifecycle -S3C runs replication without it- as there would be nothing to merge the copy into the object metadata.

This is unrelated to replicationInfo, which tracks replication of a local object to remote sites, hence the check before any of its conditions.

The destination comes from the object metadata, where the source-side rewrite stamps it next to the bucket and role the copy already needs. If it names a location we do not know, we fall back to the first local one and print a : copying the data elsewhere beats leaving it on the source forever.

The populator only knows the object owner's canonical id, and a Vault lookup per entry would throttle it, so the account id is resolved by the transition processor instead, once per action and only when missing.

Pull replication reuses the transition pipeline, but remains a separate workflow to report on and to troubleshoot, so it gets its own metrics type. It is also counted as queued on the replication metrics: without it a growing backlog would be indistinguishable from an idle cluster.

Along the way

  • the transition attempt metadata key was open-coded in five places across lifecycle and gc, one of which would throw on user metadata that does not parse
  • BACKBEAT_QUEUEPOPULATOR_EXTENSIONS was applied by dropping the other extensions from the configuration, so anything reading their settings got nothing in the populator processes

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

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.14%. Comparing base (dfee42b) to head (414c837).
⚠️ Report is 7 commits behind head on development/9.6.

Additional details and impacted files

Impacted file tree graph

Files with missing lines Coverage Δ
extensions/gc/tasks/GarbageCollectorTask.js 88.00% <100.00%> (+0.09%) ⬆️
extensions/lifecycle/LifecycleMetrics.js 97.14% <100.00%> (+0.08%) ⬆️
...ectProcessor/LifecycleObjectTransitionProcessor.js 86.13% <100.00%> (+6.68%) ⬆️
extensions/lifecycle/objectProcessor/task.js 0.00% <ø> (ø)
.../lifecycle/tasks/LifecycleColdStatusArchiveTask.js 92.00% <100.00%> (+0.16%) ⬆️
...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.64% <100.00%> (+0.64%) ⬆️
extensions/replication/ReplicationMetric.js 96.15% <100.00%> (-0.15%) ⬇️
extensions/replication/ReplicationMetrics.js 100.00% <100.00%> (ø)
... and 6 more

... and 6 files with indirect coverage changes

Components Coverage Δ
Bucket Notification 80.25% <ø> (ø)
Core Library 81.94% <100.00%> (-0.47%) ⬇️
Ingestion 71.73% <ø> (+0.35%) ⬆️
Lifecycle 80.78% <100.00%> (+0.32%) ⬆️
Oplog Populator 85.80% <ø> (ø)
Replication 62.85% <100.00%> (+0.67%) ⬆️
Bucket Scanner 85.76% <ø> (ø)
@@                 Coverage Diff                 @@
##           development/9.6    #2829      +/-   ##
===================================================
+ Coverage            76.13%   76.14%   +0.01%     
===================================================
  Files                  204      205       +1     
  Lines                14056    14150      +94     
===================================================
+ Hits                 10701    10774      +73     
- Misses                3345     3366      +21     
  Partials                10       10              
Flag Coverage Δ
api:retry 9.37% <0.00%> (-0.08%) ⬇️
api:routes 9.14% <0.00%> (-0.08%) ⬇️
bucket-scanner 85.76% <ø> (ø)
ft_test:queuepopulator 10.85% <16.03%> (-0.56%) ⬇️
ingestion 13.03% <12.21%> (+0.44%) ⬆️
lib 9.14% <0.00%> (-0.09%) ⬇️
lifecycle 19.50% <8.39%> (-0.12%) ⬇️
notification 1.00% <0.00%> (-0.01%) ⬇️
oplogPopulator 0.13% <0.00%> (-0.01%) ⬇️
replication 19.01% <5.34%> (-0.17%) ⬇️
unit 56.00% <99.23%> (+0.35%) ⬆️

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.

Comment thread extensions/lifecycle/objectProcessor/LifecycleObjectProcessor.js Outdated
Comment thread extensions/lifecycle/objectProcessor/LifecycleObjectProcessor.js Outdated
Comment thread extensions/lifecycle/objectProcessor/LifecycleObjectProcessor.js Outdated
Comment thread extensions/replication/ReplicationQueuePopulator.js Outdated
Comment thread extensions/lifecycle/objectProcessor/LifecycleObjectTransitionProcessor.js Outdated
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 pull
replication work needs to read as well.

Issue: BB-814
copyLocation actions were all reported as transitions, which is about to
stop being true: pull replication reuses the same pipeline but times a
different thing, the delay from an object's metadata landing locally to
its data being copied, over a different population of objects.

Folding both under type=transition would give quantiles that describe
neither, and would dilute the LifecycleLatency alert, which groups by
type. Derive the metrics type from the origin of the action instead, so
trigger, start and completion keep pairing up.

Issue: BB-814
@francoisferrand francoisferrand changed the title Trigger localization of clean room objects Pull replication of objects still on the source location Sep 4, 2026
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
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.

Pulling data in 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
The queue populator counted the objects it queued for replication, and
the data mover reported the bytes it copied once they arrived, but
nothing counted the objects queued for pull replication: a growing
backlog was indistinguishable from an idle cluster.

Count those too, and tell the two flows apart with the direction the
data is moving. 'origin' cannot do it: it names the extensions loaded
in the populator, and is the same either way.

Their partition label is left empty, as the populator batches its
publishes and only learns the partition long after queuing; an empty
label and a missing one are the same series to prometheus.

Issue: BB-814
BACKBEAT_QUEUEPOPULATOR_EXTENSIONS selects the extensions a queue
populator runs, but it was applied by removing the others from the
configuration altogether. Anything reading another extension's settings
-a topic they share, for instance- then got nothing, in the populator
processes only.

Select them where they are loaded instead, and leave the configuration
alone.

Issue: BB-814
@francoisferrand
francoisferrand marked this pull request as ready for review September 4, 2026 22:49
// Data still on the source location has to be fetched first. This is
// unrelated to replicationInfo, which tracks replication of a *local*
// object to remote sites, hence the check before any of its conditions.
if (locationConfig.isCRR && this.transitionTasksTopic) {

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.

this always returns, including when _publishPullReplicationAction published nothing.
For empty objects CopyLocationTask already puts 0-byte without a GET, skipping them leaves dataStoreName on the source forever, and push CRR never runs either... Same for delete markers as those usually must be replicated, and they have no data to pull. We could either publish a copyLocation for the 0-byte case (so metadata moves to the local location), or only return after an actual publish I guess ?. Delete markers should fall through to the existing CRR path...

return;
}
const locations = queueEntry.getLocation();
if (!locations || locations.length === 0) {

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.

“nothing to fetch” is true for the bytes, not for the metadata. the object still advertises the source location...

Comment thread extensions/replication/ReplicationQueuePopulator.js
});
return undefined;
}
this.log.error('invalid target location in object metadata', {

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.

This path then succeeds with a fallback , better gowarn?

*/
function getTransitionAttempt(objMD) {
const attempt = Number.parseInt(objMD.getValue()[TRANSITION_ATTEMPT_MD], 10);
return Number.isInteger(attempt) ? attempt : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should we return 0 or 1 ? Not sure we should as the caller code will be much complex

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.

No, we need keep undefined : this is used to add the user-metadata and show the retry to the user. If not set (or invalid), we don't want to show anything. So the contract is really "give me the transition attempt if any".

(this field is added in the metadata by backbeat, using a value computed by SorbetCtl when requeuing from DLQ: sorbetctl builds the new attempt counter, send the "reset/retry" message to backbeat, which writes the counter in MD)


publish() {
// Lifecycle metrics not yet implemented.
if (this._isLifecycleAction()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

maybe a good idea to keep the function and rename it with "canPublish"

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.

canPublish() would be misleading: as publish can do nothing more multiple other reasons (no producer, send error...)

In this context, it is also not a good abstraction: we end up just wondering "why wouldn't we", any reader will just go look at the code of the function, and it would actually reduce readability.

I initially tried that, but could not find any fitting name to describe the condition (isLifeycleAction, skipMetrics, isTransitionAction, isCrrReplication, hasOwnMetrics) -but they were all either incorrect or did not provide an abstraction- thus I ended keeping the code simple, inline.

The information the reader actually needs is why those are excluded, but only the comment can say it (the metrics API/routes only implement CRR), the name can't.

Comment on lines +265 to +267
// Every publisher sets one or the other, so this is a malformed
// entry: log it, and let the task fail on its own further down
// rather than retrying something that cannot be fixed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
// Every publisher sets one or the other, so this is a malformed
// entry: log it, and let the task fail on its own further down
// rather than retrying something that cannot be fixed.
// Every publisher sets one or the other, so this is a malformed
// entry

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

To avoid the comment I would do:

if (!accountId && !owner) {
  log.error('cannot resolve account id: entry has no account id nor owner');
            return process.nextTick(cb);
}

if (accountId)
  ...

this.getAccountId

The needed value of accountId and owner is explicit instead of implicit and the comment not needed

if (this._isLifecycleAction()) {
// Metrics API/routes only support CRR
const { origin } = this._entry.getContext();
if (['lifecycle', 'pullReplication'].includes(origin)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should we use the new const ?

if (queueEntry.getIsDeleteMarker()) {
return;
}
const locations = queueEntry.getLocation();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
const locations = queueEntry.getLocation();
const locations = queueEntry.getLocations();

Hard to refacto ?

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.

yes, its' coming from ObjectMD in arsenal... (and looking at the code, it supports older ObjMD model versions which did not support multiple parts/locations)

* @return {String|undefined} target location, undefined if there is none
*/
_getPullReplicationTarget(queueEntry, locations) {
const { targetLocation } = locations[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If I remember well we'll keep in location only the one that is usuable before reaching this step (on the source oplog right?)


// Where the data is fetched to when the object metadata does not name a usable
// target: the first location that can actually hold a local copy.
this.defaultLocalLocation = Object.keys(locationsConfig).find(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why do we want a fallback ? In which case ? I read the description but didn't find the use case (neither in JIRA).

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.

it is not a use case, it is the corner case where the location is not usable...

  • mongo-processor reads the message from Kafka, it writes the ObjMD with the "pointer" location -
    including the location where the data should be written to.
  • while the message is in kafka, that location is removed (or renamed)
  • queue processor eventually sees the insertion, and triggers replication

--> the queue processor cannot store in that location (it has been removed, or possibly renamed)

What we would like is to use the bucket's default location -i.e. not passing any location in
putObject-, however we need a destination location to the message can be picked'up by a worker; and
we don't have that information in here (nor want to bear the cost of the extra I/O)

The actual target location is not really important (most likely there is only a single "local"
location). We compute it early because we have that information already (in mongo processor), to
avoid a hard coded value; and this also leaves room for implementing custom mapping (if needed at
some point in the future).

But in the end, the most critical part is that the data is replicated, so we use such a fallback in
the corner case above, instead of failing.

@francoisferrand francoisferrand removed their assignment Sep 7, 2026
The only case where no copyLocation action is queued for an object still
on the source was tested by stubbing the target resolution itself, which
asserted nothing about the resolution logic. Drive the real path instead,
with a deployment where no configured location can hold a local copy, so
the populator is pinned to skip the object rather than copy it back to
the source or to a location that does not exist.

Issue: BB-814

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@francoisferrand

Copy link
Copy Markdown
Contributor Author

The 2 uncovered patch lines were ReplicationQueuePopulator.js 256 and 262, the log.error + return undefined inside the !this.defaultLocalLocation guard of _getPullReplicationTarget. That case was already tested, but by stubbing _getPullReplicationTarget itself, so it asserted nothing about the resolution logic; 414c837 adds a test that drives the real path with no configured location able to hold a local copy, and checks nothing is published to the data-mover topic and no metrics fire.

Worth noting the guard is log-only: without it the fallback returns the same undefined, so the new test has teeth on the skip behaviour (verified: it fails if the guard returns the unusable target instead) but not on which of the two error messages is emitted.

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.

4 participants