Skip to content

User ID: a filtered refreshUserIds must not release the auction early - #15562

Merged
patmmccann merged 10 commits into
prebid:masterfrom
Optable:mo/userid-filtered-refresh-waits
Sep 9, 2026
Merged

patmmccann merged 10 commits into
prebid:masterfrom
Optable:mo/userid-filtered-refresh-waits

Conversation

@MO-Thibault

@MO-Thibault MO-Thibault commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Type of change

  • Bugfix

  • Does this change affect user-facing APIs or examples documented on http://prebid.org?

Description of change

refreshUserIds({submoduleNames}) chains off done.catch(() => null). cancelAndTry rejects the previous cancel deferred first, so that promise settles immediately and the new chain never waits for the work the old one was still holding. A submodule the refresh did not name, one with a network request in flight, is dropped from the auction it was about to reach.

getUserIdsAsync is documented to resolve only once all submodules have initialized, and startAuctionHook races it against auctionDelay, so the auction is released early.

Seen in production: a publisher running auctionDelay: 300 with liveIntentId and pubProvidedId calls refreshUserIds({submoduleNames: ['pubProvidedId']}) at page load to apply EIDs. The first auction opened about 300ms early and first-auction LiveIntent presence fell from 77.1% to 49.7%, z ≈ 8, against a control arm that differed by one inert value in local storage. It converged by the third auction of the page, which is the signature of the race rather than a data problem.

The fix tracks the callback batches that have been discovered but not finished. A filtered refresh waits for those before its chain resolves; an unfiltered refresh supersedes them and keeps its current behaviour, including escaping a stuck initialization. Batches are tracked from discovery rather than from when they start running, because with auctionDelay: 0 callbacks are held until AUCTION_END plus syncDelay, and a refresh in that window would otherwise see nothing outstanding.

getUserIdsAsync, retryOnCancel and processSubmoduleCallbacks are unchanged.

One behaviour change worth confirming: a filtered refresh no longer escapes a stuck initialization, since it waits for the callbacks it left running. An unfiltered refresh still does, and there is a test for it. Keeping the escape for filtered refreshes as well needs per-submodule bookkeeping, which is what #15553 does and is considerably more code.

Tests added to test/spec/modules/userId_spec.js, each failing without the change:

  • a filtered refresh must not release the auction while an unnamed submodule is still fetching
  • an unfiltered refresh still escapes a stuck initialization
  • a filtered refresh waits for a batch started by an earlier filtered refresh
  • a filtered refresh does not inherit a batch an unfiltered refresh superseded

Other information

Supersedes #15553, which fixes the same bug with a per-submodule map of pending callbacks consulted by getUserIdsAsync. Automated review found five gaps in that approach, all stemming from the map being a second source of truth about whether initialization has finished, one that does not participate in cancellation. This version keeps a single wait channel.

refreshUserIds({submoduleNames}) chains off done.catch(() => null). Since
cancelAndTry rejects the previous cancel deferred, that promise settles at
once and the new chain never waits for the work the old one still held. A
submodule the refresh did not name, one with a network call, is dropped from
the auction it was about to reach.

A publisher on auctionDelay 300 with liveIntentId and pubProvidedId called
refreshUserIds({submoduleNames: ['pubProvidedId']}) at page load: the first
auction opened ~300ms early and first-auction LiveIntent presence fell from
77.1% to 49.7% (z ~ 8).

Track the callback batches that have been discovered but not finished, from
discovery rather than from when they start running, since with auctionDelay
0 they are held until after the auction ends. A filtered refresh then waits
for what it left running, while an unfiltered one supersedes everything and
keeps escaping a stuck initialization.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0741e5490a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread modules/userId/index.ts Resolved
Comment thread modules/userId/index.ts Resolved
…refresh release what it supersedes

Two from review. The batch was registered once the refresh chain reached
initSubmodules, so a second filtered refresh issued back to back could
snapshot the outstanding set before the first had registered, and resolve
while the first one's callback was still running. It is now registered
synchronously when the refresh is issued.

And an unfiltered refresh only omitted the existing markers from its own
wait, leaving them in place: a batch nothing would ever complete stayed
outstanding and any later filtered refresh inherited it. An unfiltered
refresh now releases the markers it supersedes.
@MO-Thibault

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f253130b6e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread modules/userId/index.ts Resolved
…alled

An unfiltered refresh released the markers it supersedes before cancelAndTry
swapped in the new done. With greedy promises that can fulfil the chain the
refresh is replacing, and a caller checking whether the chain is still current
sees the old one settle and takes its result. The release now happens after the
swap, and skips the refresh's own batch.

Reported by Codex review.
@MO-Thibault

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: d8e8779b15

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@MO-Thibault
MO-Thibault marked this pull request as draft August 31, 2026 19:39
@MO-Thibault
MO-Thibault marked this pull request as ready for review August 31, 2026 19:39
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

This PR introduces changes that may not work on all browsers. According to Babel, the following polyfills may be needed, and they are not automatically included:

  • Changes to modules/userId/index.ts may need:
    • es.iterator.some
    • esnext.iterator.some

The best way to address this is to provide good test coverage, as normal PR checks run unit tests on older browsers.

@barecheck

barecheck Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Barecheck - Code coverage report

Total: 91.29%

Your code coverage diff: 0.01% ▴

Uncovered files and lines
FileLines
modules/userId/index.ts173, 207, 221, 233, 293, 344, 802, 812, 823, 829, 848-849, 973-976, 1016-1017, 1069, 1092, 1099, 1137, 1357, 1376-1377

@MO-Thibault

Copy link
Copy Markdown
Contributor Author

initModules.submodules is a plain array (SubmoduleContainer<UserIdProvider>[]), so the flagged call is Array.prototype.some — ES5, supported on every target. Babel's polyfill provider runs after TypeScript stripping and can't infer the receiver type, so it conservatively lists the Iterator.prototype.some variants.

modules/userId/index.ts already reports the same iterator variants for find, every and flatMap on equally plain arrays; those are just already in the base set, so the per-file diff doesn't surface them. .some() on untyped receivers is also already present in src/prebid.ts, src/auction.ts, src/native.ts and src/ajax.ts, so es.iterator.some is in the global set either way.

The ES5 bundle covers it regardless: webpack.common.js injects core-js-pure via usage-pure against chrome >= 50 / firefox >= 50 / safari >= 10, which rewrites the call site to an imported implementation.

On coverage: this PR adds 132 lines of tests in test/spec/modules/userId_spec.js for the new refresh/wait behaviour.

@patmmccann

Copy link
Copy Markdown
Collaborator

@MO-Thibault the polyfill comment is a warning to you, not a blocker

@patmmccann patmmccann left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

please run eslint --fix

Whitespace only: the added tracking block left initChain over-indented by two
spaces and a multi-space run before PbPromise.all.
@MO-Thibault

Copy link
Copy Markdown
Contributor Author

@patmmccann done.

@patmmccann
patmmccann requested a review from dgirardi September 8, 2026 15:04
dgirardi
dgirardi previously approved these changes Sep 8, 2026
@dgirardi
dgirardi dismissed their stale review September 8, 2026 16:01

let me look at the test failure

The test passed the delay stub as `delay`, but startAuctionHook reads
`mkDelay`, so the real 10ms auctionDelay timer stayed in the race and
won on Safari before the callback resolved.
@dgirardi

dgirardi commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

@MO-Thibault I don't have enough permission to update this PR so I cut Optable#16 which I believe should fix the test failure on Safari

@MO-Thibault

Copy link
Copy Markdown
Contributor Author

@dgirardi Do you want me to encompass your changes in my PR? It does look like it will fix the failing test indeed.

@patmmccann
patmmccann merged commit 69a1dea into prebid:master Sep 9, 2026
102 checks passed
steffenmllr pushed a commit to mllrsohn/Prebid.js that referenced this pull request Sep 28, 2026
…prebid#15562)

* User ID: a filtered refreshUserIds must not release the auction early

refreshUserIds({submoduleNames}) chains off done.catch(() => null). Since
cancelAndTry rejects the previous cancel deferred, that promise settles at
once and the new chain never waits for the work the old one still held. A
submodule the refresh did not name, one with a network call, is dropped from
the auction it was about to reach.

A publisher on auctionDelay 300 with liveIntentId and pubProvidedId called
refreshUserIds({submoduleNames: ['pubProvidedId']}) at page load: the first
auction opened ~300ms early and first-auction LiveIntent presence fell from
77.1% to 49.7% (z ~ 8).

Track the callback batches that have been discovered but not finished, from
discovery rather than from when they start running, since with auctionDelay
0 they are held until after the auction ends. A filtered refresh then waits
for what it left running, while an unfiltered one supersedes everything and
keeps escaping a stuck initialization.

* userId: register a refresh's batch when it is issued, and let a full refresh release what it supersedes

Two from review. The batch was registered once the refresh chain reached
initSubmodules, so a second filtered refresh issued back to back could
snapshot the outstanding set before the first had registered, and resolve
while the first one's callback was still running. It is now registered
synchronously when the refresh is issued.

And an unfiltered refresh only omitted the existing markers from its own
wait, leaving them in place: a batch nothing would ever complete stayed
outstanding and any later filtered refresh inherited it. An unfiltered
refresh now releases the markers it supersedes.

* userId: release superseded batches only after the replacement is installed

An unfiltered refresh released the markers it supersedes before cancelAndTry
swapped in the new done. With greedy promises that can fulfil the chain the
refresh is replacing, and a caller checking whether the chain is still current
sees the old one settle and takes its result. The release now happens after the
swap, and skips the refresh's own batch.

Reported by Codex review.

* User ID: eslint --fix on the initChain indentation

Whitespace only: the added tracking block left initChain over-indented by two
spaces and a multi-space run before PbPromise.all.

* userId: stub the auction delay in the filtered-refresh test

The test passed the delay stub as `delay`, but startAuctionHook reads
`mkDelay`, so the real 10ms auctionDelay timer stayed in the race and
won on Safari before the callback resolved.

---------

Co-authored-by: Patrick McCann <patmmccann@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants