diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 8be0ec96..2460340e 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -29,6 +29,17 @@ So anything read back out of storage is ready to merge as-is, and anything comin - Each EID keeps at most `maxUidsPerEid` UIDs (default 2). - `ortb2.user.data` comes from the new response, falling back to the cached one. - The inputs are never mutated. The merged cache is built from copies. + +## Non-overriding matchers + +`nonOverridingMatchers` lists matcher values that lose a contested source. An EID whose `eid.matcher` is listed will not displace one already cached on that source from a matcher that is not, revocation included: a listed matcher sending the source with no uids leaves the cached EID alone. Anything else still wins, so a call keeps refreshing and revoking its own entries, and the kept EID keeps its refs. An EID with no matcher is never listed, so it always wins. + +```typescript +const { merged } = mergeCache(replaceCache(response), cached, { nonOverridingMatchers: ["acme.com"] }); +``` + +Listing a publisher's own domains is what lets a targeting call resolved with `skip_matchers: ["1p"]` run alongside a tokenize call: the two fill the same sources, and whichever lands second no longer overwrites the other. The decision is made on the label rather than on arrival order, so the calls can go out in parallel instead of tokenize waiting for targeting to come back empty. Leave it unset and every new EID wins its source, as before. + - Cached EIDs are wire EIDs: refresh material never sits on them, so every consumer — RTD, `pubProvidedId`, anything else — can hand them to bidding as-is, with nothing to strip. ## UID2 refresh material diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts index 9f69fabd..df4ba0a6 100644 --- a/lib/core/eid-cache.test.ts +++ b/lib/core/eid-cache.test.ts @@ -254,3 +254,120 @@ describe("mergeCache", () => { expect(staleUid2s).toEqual([]); }); }); + +describe("mergeCache - nonOverridingMatchers", () => { + const FP = { nonOverridingMatchers: ["acme.com"] }; + const idOn = (merged: unknown, source: string) => + (merged as any).ortb2?.user?.eids?.find((e: any) => e.source === source)?.uids?.[0]?.id; + + it("keeps a third-party EID when a first-party one lands on the same source", () => { + const held = cache([eid("liveramp.com", { matcher: "3p.example", uids: [{ id: "third-party" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("third-party"); + }); + + it("lets a third-party EID displace a first-party one", () => { + const held = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "3p.example", uids: [{ id: "third-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("third-party"); + }); + + it("lets a first-party EID refresh its own entry", () => { + const held = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "stale" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "fresh" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("fresh"); + }); + + it("treats an EID with no matcher as third-party", () => { + const held = cache([eid("liveramp.com", { uids: [{ id: "unlabelled" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("unlabelled"); + }); + + it("keeps the held EID's refs when a first-party EID is turned away", () => { + const kept = ref({ advertising_token: "kept" }); + const held = cache([eid("uidapi.com", { matcher: "3p.example" })], { refs: { "uidapi.com": kept } }); + const incoming = cache([eid("uidapi.com", { matcher: "acme.com" })], { + refs: { "uidapi.com": ref({ advertising_token: "discarded" }) }, + }); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(getRefData(merged, "uidapi.com")).toEqual(kept); + }); + + it("still revokes a source the new response sends with no uids", () => { + const held = cache([eid("id5-sync.com", { matcher: "3p.example" })]); + const incoming = cache([{ source: "id5-sync.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(merged.ortb2?.user?.eids).toEqual([]); + }); + + it("without the option, a first-party EID wins its source as before", () => { + const held = cache([eid("liveramp.com", { matcher: "3p.example", uids: [{ id: "third-party" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any); + + expect(idOn(merged, "liveramp.com")).toBe("first-party"); + }); +}); + +describe("mergeCache - first-party revocation", () => { + const FP = { nonOverridingMatchers: ["acme.com"] }; + const sources = (merged: unknown) => (merged as any).ortb2?.user?.eids?.map((e: any) => e.source); + + it("does not let a first-party revocation drop a held third-party EID", () => { + const kept = ref({ advertising_token: "kept" }); + const held = cache([eid("uidapi.com", { matcher: "3p.example" })], { refs: { "uidapi.com": kept } }); + const incoming = cache([{ source: "uidapi.com", matcher: "acme.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(sources(merged)).toEqual(["uidapi.com"]); + expect(getRefData(merged, "uidapi.com")).toEqual(kept); + }); + + it("lets a third-party revocation drop a held first-party EID", () => { + const held = cache([eid("uidapi.com", { matcher: "acme.com" })], { refs: { "uidapi.com": ref() } }); + const incoming = cache([{ source: "uidapi.com", matcher: "3p.example", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(sources(merged)).toEqual([]); + expect(getRefData(merged, "uidapi.com")).toBeNull(); + }); + + it("lets an unlabelled revocation drop a held first-party EID", () => { + const held = cache([eid("id5-sync.com", { matcher: "acme.com" })]); + const incoming = cache([{ source: "id5-sync.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(sources(merged)).toEqual([]); + }); + + it("without the option, any revocation still drops the held EID", () => { + const held = cache([eid("uidapi.com", { matcher: "3p.example" })], { refs: { "uidapi.com": ref() } }); + const incoming = cache([{ source: "uidapi.com", matcher: "acme.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any); + + expect(sources(merged)).toEqual([]); + expect(getRefData(merged, "uidapi.com")).toBeNull(); + }); +}); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts index ee0edba7..7a1e9f13 100644 --- a/lib/core/eid-cache.ts +++ b/lib/core/eid-cache.ts @@ -18,6 +18,8 @@ type Uid2RefData = { type CachedEid = { source: string; + // Matching domain the edge labelled this EID with; absent counts as third-party. + matcher?: string; uids?: Array<{ id?: string; atype?: number; @@ -129,16 +131,22 @@ export function replaceCache(response: T): T { * result. The cache read back from storage already is, since setTargeting * writes through replaceCache; a wire response goes through replaceCache * first. + * + * An EID whose matcher is in nonOverridingMatchers never displaces one held on + * that source from a matcher that is not. Unset, every new EID wins its source. */ export function mergeCache( newObj: ResolvedCache | null | undefined, oldObj: ResolvedCache | null | undefined, - options?: { maxUidsPerEid?: number } + options?: { maxUidsPerEid?: number; nonOverridingMatchers?: string[] } ): { merged: ResolvedCache; staleUid2s: StaleUid2[] } { const oldEids = oldObj?.ortb2?.user?.eids || []; const newEids = newObj?.ortb2?.user?.eids || []; const maxUids = options?.maxUidsPerEid ?? DEFAULT_MAX_UIDS_PER_EID; + const nonOverriding = new Set(options?.nonOverridingMatchers ?? []); + const yieldsToHeld = (eid: CachedEid): boolean => !!eid.matcher && nonOverriding.has(eid.matcher); + // Copies are wire-clean: capped uids, and the ref pointer into the response // refs map is dropped since the sidecar replaces it. const copyOf = (eid: CachedEid): CachedEid => ({ @@ -146,27 +154,33 @@ export function mergeCache( uids: (eid.uids || []).slice(0, maxUids).map(stripRefPointer), }); - const newSources = new Set(newEids.map((e) => e.source)); const eidMap = new Map(); const refs: Record = {}; - // Carry over old EIDs whose source is not in the new response, along with - // their refs entry. + // Every old EID is a candidate, contested sources included: the collision is + // settled below rather than by arrival order. oldEids.forEach((eid) => { if (!eid.uids?.length) return; - if (!newSources.has(eid.source)) { - eidMap.set(eid.source, copyOf(eid)); - const ref = getRefData(oldObj, eid.source); - if (ref) { - refs[eid.source] = ref; - } + eidMap.set(eid.source, copyOf(eid)); + const ref = getRefData(oldObj, eid.source); + if (ref) { + refs[eid.source] = ref; } }); - // New EIDs overwrite old ones with the same source, and so does their refs - // entry: a source re-resolved without one has its stale entry dropped. + // New EIDs take their source, refs included: a source re-resolved without one + // has its stale entry dropped, and one sent with no uids is revoked. A + // first-party EID yields to a third-party one held, revocation included. newEids.forEach((eid) => { - if (!eid.uids?.length) return; + const held = eidMap.get(eid.source); + if (held && yieldsToHeld(eid) && !yieldsToHeld(held)) return; + + if (!eid.uids?.length) { + eidMap.delete(eid.source); + delete refs[eid.source]; + return; + } + eidMap.set(eid.source, copyOf(eid)); const ref = getRefData(newObj, eid.source); if (ref) {