Preserve Prebid ad units across GPT refreshes#912
Conversation
aram356
left a comment
There was a problem hiding this comment.
Summary
The refresh snapshot and delivery-correlation behavior passes the local JavaScript checks, but the change introduces unbounded snapshot retention and the required integration CI gate is currently failing.
Blocking
🔧 wrench
- Required integration CI is failing:
prepare integration artifactsfails becauseERROR_TYPE_HTTP_STATUSis unused atcrates/trusted-server-core/src/auction/orchestrator.rs:103, and the downstream integration and browser jobs are consequently skipped. This error is inherited from the stacked base rather than introduced by either file in this diff, but the base needs to be fixed or updated and CI rerun successfully before merge. - Publisher snapshot cache grows without bound: see the inline comment on
crates/trusted-server-js/lib/src/integrations/prebid/index.ts.
CI Status
- JS tests: PASS (421/421; 74 focused Prebid tests)
- JS lint: PASS
- JS format: PASS
- JS bundle build: PASS
- prepare integration artifacts: FAIL
- downstream integration tests: SKIPPED
- browser integration tests: SKIPPED
aram356
left a comment
There was a problem hiding this comment.
Summary
The snapshot mechanism introduces a regression against the base branch: because the snapshot short-circuit keys on presence rather than content, an empty snapshot now beats live pbjs.adUnits config and silently drops the publisher's inline PBS demand from every refresh — the same class of failure #911 is meant to fix. Separately, the delivery-correlation heuristic is timing-based where a deterministic signal is available, and the unbounded snapshot cache from the previous review is still unaddressed.
Blocking
🔧 wrench
- Empty snapshot beats live config (regression):
capturePublisherAdUnitSnapshotreturns a truthy snapshot for any unit with a non-emptycode, andserverSideBidderParamsForRefresh/clientSideBidsForRefreshreturn early on presence, never reaching thefindRefreshAdUnitfallback. Re-registering an existing code as a fresh object with no bids yields{}where the base yields the publisher's params — verified by running the same probe against both branches, withpbjs.adUnitsstill holding the folded params in both. See the inline comment onserverSideBidderParamsForRefresh(crates/trusted-server-js/lib/src/integrations/prebid/index.ts:544). - Publisher snapshot cache still grows without bound: unchanged from the previous review — no eviction path other than
installPrebidNpm(). It additionally changes behavior: stale params now survivepbjs.removeAdUnit()and keep reaching/auctionforever, where the base returned{}(index.ts:771). - Mixed explicit refresh bypasses the entire slot list: any single covered slot attributes the whole call to delivery, so an uncovered TS-owned prebid slot in the same call loses both its auction and its
clearRefreshTargeting— GAM then re-requests it with stalehb_adid/hb_cache_pathand a persistingts_initial=1(index.ts:643). - Required integration CI is failing:
prepare integration artifactsfails and the downstream integration and browser jobs are consequently skipped. Inherited from the stacked base rather than introduced by either file in this diff, but the base needs to be fixed or updated and CI rerun successfully before merge.
❓ question
-
Why a 1000ms timer rather than correlating the actual auction?
PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MSis racy in both directions: a publisher whose refresh lands past 1s (lazy-load gate, consent await, loaded main thread on a low-end device, background-tab timer clamping) gets the duplicate auction the PR is meant to prevent, while any refresh of a context code arriving inside the window is bypassed on code membership alone — silently losing a legitimate auction and serving stale targeting. The second direction is the more damaging one and is untested; note the retain path is the common path, since an argument-lesssetTargetingForGPTAsync()— the canonical Prebid call — retains via theundefinedbranch.Two deterministic signals are already reachable from this file:
hb_adidcorrelation: aftersetTargetingForGPTAsync, the GPT slot carrieshb_adidfor the winning bid.slot.getTargetingis already onRefreshGptSlotand already used atindex.ts:1029. Recording the pending auction's winner and treating a slot as delivery iff itshb_adidmatches an unconsumed pending adId is time-free and naturally per-slot.auctionId: the chained handler atindex.ts:857takes...argsand discards them; Prebid passes(bidResponses, timedOut, auctionId). Keying contexts byauctionIdgives real identity instead of a timer.
Per-slot correlation would collapse the mixed-list bypass, the bare-refresh scope mismatch, both timer directions, and the duplicate-code leak into one rule. Was this considered and rejected?
Non-blocking
🤔 thinking
- Async targeting defeats the wrapper: the wrapper lives only for the synchronous
bidsBackHandlerframe, so a publisher setting targeting in asetTimeout/.then()misses it. The test atindex.test.ts:1912currently asserts the resulting duplicate auction as intended behavior (index.ts:889). setTargetingForGPTAsync(null, matcher)never retains: onlyundefinedis special-cased, butnullis a legitimate "all ad units" call (index.ts:598).- Bare refresh consumes one context but delivers all slots: uncovered TS slots serve stale targeting, and sibling contexts survive a refresh that just delivered their codes (
index.ts:606). - Duplicate-code context leak: consumption breaks at the newest covering context, leaving an older context to suppress a legitimate refresh (
index.ts:639). - A non-object slot entry both forces the duplicate auction and leaks the context: the
hasOnlyValidExplicitSlotsguard short-circuits before consumption (index.ts:1005).
⛏ nitpick
- Orphaned and stale docs: inserting
findRefreshSnapshotdetached thepbjs.adUnitsdoc comment fromfindRefreshAdUnit, and the docblocks atindex.ts:496-498and:532-537still describepbjs.adUnitsas the source when it is now the fallback. New helpers are undocumented in an otherwise heavily-documented file (index.ts:383).
CI Status
- JS tests: PASS (421/421 locally)
- JS lint: PASS
- JS format: PASS
- prepare integration artifacts: FAIL
- integration tests: SKIPPED
- browser integration tests: SKIPPED
c3143ab to
4ca09fa
Compare
Prebid non-2xx responses were reduced to bare provider errors, making intermittent failures difficult to diagnose. Surface safe HTTP metadata and bounded debug details while correlating server logs with the auction ID.
4ca09fa to
7af7598
Compare
c59a064 to
2a2b39d
Compare
2a2b39d to
fed8439
Compare
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
Solid, well-tested change: request-scoped Prebid ad-unit snapshots (bounded LRU) plus GPT-delivery correlation (bounded pending-bid map) fix duplicate auctions on refresh while preserving publisher bidder params, client-side bids, and zone. Read the full diff (prebid.rs, index.ts, test file, publisher.rs, docs) and ran the full local suite in place of CI, which never triggered on this commit (see CI Status). No blocking issues found.
Non-blocking
🤔 thinking
- Double-wrap on repeated
installPrebidNpm()calls: wraps whatever is currently onpbjs.removeAdUnit, so a secondinstallPrebidNpm()call double-wraps and firesremovePublisherStatetwice per real call. Harmless today, but a one-line sentinel guard would future-proof it. (crates/trusted-server-js/lib/src/integrations/prebid/index.ts:724, see inline comment)
🌱 seedling
- CI never ran on this commit: GitHub's "Run Tests" and "Run Format" workflows never triggered for head commit
fed84393— only "Integration Tests" shows in the check-runs API. Worth a maintainer look at why (independent of this PR's code). I ran the equivalent locally in its place — see CI Status below.
👍 praise
- Snapshot/LRU/delivery-correlation design (
crates/trusted-server-js/lib/src/integrations/prebid/index.ts):capturePublisherAdUnitSnapshot+findRefreshSnapshot+publisherDeliverySlotssolve a genuinely tricky reentrancy problem (nestedbidsBackHandlers, synthetic vs. publisher auctions, live-vs-snapshot precedence) cleanly, with bounded LRU maps (256 snapshots / 2048 pending bids) guarding memory. 82 dedicated tests cover nested callbacks, throwing callbacks, timer races, and malformed bid shapes. - Prebid error-diagnostics security posture (
crates/trusted-server-core/src/integrations/prebid.rs, stacked from #893): bounded, allowlisted-field JSON extraction, HTML-page rejection, control-character normalization, and debug-gated exposure of upstream error text — well tested including oversized and streaming body edge cases.
CI Status
- fmt: PASS (
cargo fmt --all -- --check, local) - clippy: PASS (
clippy-fastly,clippy-axum,clippy-cloudflare,clippy-cloudflare-wasm,clippy-spin-native,clippy-spin-wasm, local) - rust tests: PASS (
cargo test-axum: 32 passed;cargo test-fastlyvia Viceroy: 1639 core + 21 openrtb passed, 0 failed — local, since GitHub's Run Tests workflow did not trigger on this commit) - js tests: PASS (
npx vitest run: 429 passed;npm run lintandnpm run formatclean) - GitHub Integration Tests: PASS (per
gh pr checks) - Note: PR currently shows
mergeStateStatus: CONFLICTINGagainstmain— needs a rebase/merge resolution before merge regardless of review outcome.
aram356
left a comment
There was a problem hiding this comment.
Summary
All nine findings from the previous review are resolved, and the delivery-correlation mechanism was rewritten rather than patched: the timer wrapper is gone, live pbjs.adUnits is now authoritative with snapshots as fallback, snapshots are a bounded LRU tied to a removeAdUnit wrapper, and refreshes partition per slot. CI is green and JS tests pass 429/429 locally.
The rewrite relocates the risk rather than removing it. Two blocking issues: publisher auctions without a bidsBackHandler get no correlation at all and are misclassified into duplicate auctions, and the new per-slot partition splits one publisher refresh() into two GAM requests, which breaks Single Request Architecture on pages this codebase itself puts into SRA mode.
Blocking
🔧 wrench
- Auctions without a
bidsBackHandlerregister no pending bids: thetypeof originalBidsBack !== 'function'guard sits aboveregisterPendingPublisherBids, so a callback-dispatch concern gates state registration. The Prebid 8+ promise form and theonEvent('auctionEnd')pattern both pass no handler. Probed in isolation on this branch: without a handler the delivery refresh produced 1 synthetic auction, 0 passthrough refreshes, and cleared all 6TS_REFRESH_TARGETING_KEYS; with a handler it correctly produced 0 synthetic auctions and 1 passthrough. One-line reorder fixes it (crates/trusted-server-js/lib/src/integrations/prebid/index.ts:905). - The refresh split breaks GPT Single Request Architecture: one publisher
refresh()now yields twooriginalRefreshcalls up to 1500ms apart. This is not hypothetical —src/integrations/gpt/index.ts:585andcrates/trusted-server-core/src/integrations/gpt_bootstrap.js:137both callenableSingleRequest(). Under SRA one refresh is one ad request, so the split loses competitive-exclusion enforcement (labels are enforced within a single request), breaks roadblocks spanning the boundary, and breaks companion pairing. Frequency capping is not affected. A single combined deferred refresh fixes it, at the cost of up to 1500ms on the publisher's already-won render — a tradeoff worth making explicitly rather than silently (index.ts:1017,:1068).
❓ question
- Correlator double-bump: the same
optsreference is passed to both split refresh calls, andchangeCorrelatordefaults totrue, so the common case records two GAM page views for one publisher refresh — compounding the SRA finding. Entirely untested; everyrefreshassertion in the suite passesundefinedoptions. Was this considered? (index.ts:1068)
Non-blocking
🤔 thinking
- A no-bid slot splits the refresh:
groupByPlacementomits ad units that received no bids, so a mixed bid/no-bidrefresh([A,B])tears into two GAM requests and clears targeting on the no-bid slot (index.ts:621). hb_adidis the sole correlation key: publishers usingtargetingControls.allowTargetingKeyswithoutAD_ID, or applying their own targeting, are permanently and silently misclassified. Falling back to ad-unit-code matching would makehb_adidan optimization rather than the only signal (index.ts:646).- Pending bids have no TTL: a first impression rendered via
googletag.display()never consumes its entry, so the next viewability refresh is suppressed once before self-healing. Removing the timer was right; a bound on data staleness is a separate concern (index.ts:603). - New partial-refresh failure mode: if the synthetic
requestBidsthrows or its handler never fires, delivery slots have already refreshed and independent slots never do. The previous all-or-nothing design failed cleanly (index.ts:1064).
⛏ nitpick
removeAdUnitwrapper stacks across repeatedinstallPrebidNpm()calls. Verified benign — the base implementation still runs once per call andremovePublisherStateis idempotent — and it mirrors the pre-existingrequestBidspattern. A__tsRemoveAdUnitWrappedsentinel would match the__tsRefreshWrappedidiom (index.ts:723).- Dropped
hasOnlyValidExplicitSlotsguard:refresh([slotA, null])now silently drops thenullinstead of taking the untouched passthrough. Very low impact, but the removal looks incidental (index.ts:999).
CI Status
- prepare integration artifacts: PASS
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- browser integration tests: PASS
- JS tests: PASS (429/429 locally)
Summary
pbjs.adUnitsis empty.add-prebid-error-diagnostics).Changes
crates/trusted-server-js/lib/src/integrations/prebid/index.tscrates/trusted-server-js/lib/test/integrations/prebid/index.test.tsCloses
Closes #911
Test plan
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest run(421 passed; 74 focused Prebid tests)cd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute servenpm run lint,node build-all.mjs, external Prebid bundle build, independent review, and natural proxy validation with the rebuilt bundle. A delayed explicit GPT delivery refresh containing four publisher slots plus a GAM-only interstitial produced no duplicate auction; a later independent header refresh retained Kargo/PubMatic params and zone.Checklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!) — no logging was added