66import io .vertx .ext .web .RoutingContext ;
77import org .apache .commons .lang3 .StringUtils ;
88import org .prebid .server .cookie .model .UidWithExpiry ;
9- import org .prebid .server .cookie .model .UidsCookieUpdateResult ;
109import org .prebid .server .cookie .proto .Uids ;
1110import org .prebid .server .json .DecodeException ;
1211import org .prebid .server .json .JacksonMapper ;
1312import org .prebid .server .log .Logger ;
1413import org .prebid .server .log .LoggerFactory ;
1514import org .prebid .server .metric .Metrics ;
1615import org .prebid .server .model .HttpRequestContext ;
16+ import org .prebid .server .model .UpdateResult ;
1717import org .prebid .server .util .HttpUtil ;
1818
1919import java .time .Duration ;
20+ import java .util .ArrayList ;
2021import java .util .Base64 ;
2122import java .util .Collections ;
23+ import java .util .HashMap ;
2224import java .util .Iterator ;
25+ import java .util .List ;
2326import java .util .Map ;
2427import java .util .Objects ;
2528import java .util .Optional ;
@@ -34,15 +37,20 @@ public class UidsCookieService {
3437 private static final Logger logger = LoggerFactory .getLogger (UidsCookieService .class );
3538
3639 private static final String COOKIE_NAME = "uids" ;
40+ private static final String COOKIE_NAME_FORMAT = "uids%d" ;
3741 private static final int MIN_COOKIE_SIZE_BYTES = 500 ;
42+ private static final int MIN_NUMBER_OF_UID_COOKIES = 1 ;
43+ private static final int MAX_NUMBER_OF_UID_COOKIES = 30 ;
3844
3945 private final String optOutCookieName ;
4046 private final String optOutCookieValue ;
4147 private final String hostCookieFamily ;
4248 private final String hostCookieName ;
4349 private final String hostCookieDomain ;
4450 private final long ttlSeconds ;
51+
4552 private final int maxCookieSizeBytes ;
53+ private final int numberOfUidCookies ;
4654
4755 private final PrioritizedCoopSyncProvider prioritizedCoopSyncProvider ;
4856 private final Metrics metrics ;
@@ -55,6 +63,7 @@ public UidsCookieService(String optOutCookieName,
5563 String hostCookieDomain ,
5664 int ttlDays ,
5765 int maxCookieSizeBytes ,
66+ int numberOfUidCookies ,
5867 PrioritizedCoopSyncProvider prioritizedCoopSyncProvider ,
5968 Metrics metrics ,
6069 JacksonMapper mapper ) {
@@ -64,13 +73,20 @@ public UidsCookieService(String optOutCookieName,
6473 "Configured cookie size is less than allowed minimum size of " + MIN_COOKIE_SIZE_BYTES );
6574 }
6675
76+ if (numberOfUidCookies < MIN_NUMBER_OF_UID_COOKIES || numberOfUidCookies > MAX_NUMBER_OF_UID_COOKIES ) {
77+ throw new IllegalArgumentException (
78+ "Configured number of uid cookies should be in the range from %d to %d"
79+ .formatted (MIN_NUMBER_OF_UID_COOKIES , MAX_NUMBER_OF_UID_COOKIES ));
80+ }
81+
6782 this .optOutCookieName = optOutCookieName ;
6883 this .optOutCookieValue = optOutCookieValue ;
6984 this .hostCookieFamily = hostCookieFamily ;
7085 this .hostCookieName = hostCookieName ;
7186 this .hostCookieDomain = StringUtils .isNotBlank (hostCookieDomain ) ? hostCookieDomain : null ;
7287 this .ttlSeconds = Duration .ofDays (ttlDays ).getSeconds ();
7388 this .maxCookieSizeBytes = maxCookieSizeBytes ;
89+ this .numberOfUidCookies = numberOfUidCookies ;
7490 this .prioritizedCoopSyncProvider = Objects .requireNonNull (prioritizedCoopSyncProvider );
7591 this .metrics = Objects .requireNonNull (metrics );
7692 this .mapper = Objects .requireNonNull (mapper );
@@ -105,57 +121,66 @@ public UidsCookie parseFromRequest(HttpRequestContext httpRequest) {
105121 */
106122 UidsCookie parseFromCookies (Map <String , String > cookies ) {
107123 final Uids parsedUids = parseUids (cookies );
124+ final boolean isOptedOut = isOptedOut (cookies );
108125
109- final Boolean optout ;
110- final Map <String , UidWithExpiry > uidsMap ;
111-
112- if (isOptedOut (cookies )) {
113- optout = true ;
114- uidsMap = Collections .emptyMap ();
115- } else {
116- optout = parsedUids != null ? parsedUids .getOptout () : null ;
117- uidsMap = enrichAndSanitizeUids (parsedUids , cookies );
118- }
119-
120- final Uids uids = Uids .builder ().uids (uidsMap ).optout (optout ).build ();
126+ final Uids uids = Uids .builder ()
127+ .uids (isOptedOut ? Collections .emptyMap () : enrichAndSanitizeUids (parsedUids , cookies ))
128+ .optout (isOptedOut )
129+ .build ();
121130
122131 return new UidsCookie (uids , mapper );
123132 }
124133
125134 /**
126135 * Parses cookies {@link Map} and composes {@link Uids} model.
127136 */
128- public Uids parseUids (Map <String , String > cookies ) {
129- if (cookies .containsKey (COOKIE_NAME )) {
130- final String cookieValue = cookies .get (COOKIE_NAME );
137+ private Uids parseUids (Map <String , String > cookies ) {
138+ final Map <String , UidWithExpiry > uids = new HashMap <>();
139+
140+ for (Map .Entry <String , String > cookie : cookies .entrySet ()) {
141+ final String cookieKey = cookie .getKey ();
142+ if (!cookieKey .startsWith (COOKIE_NAME )) {
143+ continue ;
144+ }
145+
131146 try {
132- return mapper .decodeValue (Buffer .buffer (Base64 .getUrlDecoder ().decode (cookieValue )), Uids .class );
147+ final Uids parsedUids = mapper .decodeValue (
148+ Buffer .buffer (Base64 .getUrlDecoder ().decode (cookie .getValue ())), Uids .class );
149+ if (parsedUids != null && parsedUids .getUids () != null ) {
150+ parsedUids .getUids ().forEach ((key , value ) -> uids .merge (key , value , (newValue , oldValue ) ->
151+ newValue .getExpires ().compareTo (oldValue .getExpires ()) > 0 ? newValue : oldValue ));
152+ }
133153 } catch (IllegalArgumentException | DecodeException e ) {
134- logger .debug ("Could not decode or parse {} cookie value {}" , e , COOKIE_NAME , cookieValue );
154+ logger .debug ("Could not decode or parse {} cookie value {}" , e , COOKIE_NAME , cookie . getValue () );
135155 }
136156 }
137- return null ;
157+
158+ return Uids .builder ().uids (uids ).build ();
138159 }
139160
140161 /**
141162 * Creates a {@link Cookie} with 'uids' as a name and encoded JSON string representing supplied {@link UidsCookie}
142163 * as a value.
143164 */
144- public Cookie toCookie (UidsCookie uidsCookie ) {
145- return makeCookie (uidsCookie );
165+ public Cookie aliveCookie (String cookieName , UidsCookie uidsCookie ) {
166+ final String value = Base64 .getUrlEncoder ().encodeToString (uidsCookie .toJson ().getBytes ());
167+ return makeCookie (cookieName , value , ttlSeconds );
168+ }
169+
170+ public Cookie aliveCookie (UidsCookie uidsCookie ) {
171+ return aliveCookie (COOKIE_NAME , uidsCookie );
146172 }
147173
148- private int cookieBytesLength ( UidsCookie uidsCookie ) {
149- return makeCookie (uidsCookie ). encode (). getBytes (). length ;
174+ public Cookie expiredCookie ( String cookieName ) {
175+ return makeCookie (cookieName , StringUtils . EMPTY , 0 ) ;
150176 }
151177
152- private Cookie makeCookie (UidsCookie uidsCookie ) {
153- return Cookie
154- .cookie (COOKIE_NAME , Base64 .getUrlEncoder ().encodeToString (uidsCookie .toJson ().getBytes ()))
178+ private Cookie makeCookie (String cookieName , String value , long maxAge ) {
179+ return Cookie .cookie (cookieName , value )
155180 .setPath ("/" )
156181 .setSameSite (CookieSameSite .NONE )
157182 .setSecure (true )
158- .setMaxAge (ttlSeconds )
183+ .setMaxAge (maxAge )
159184 .setDomain (hostCookieDomain );
160185 }
161186
@@ -221,20 +246,18 @@ private static boolean facebookSentinelOrEmpty(Map.Entry<String, UidWithExpiry>
221246
222247 /***
223248 * Removes expired {@link Uids}, updates {@link UidsCookie} with new uid for family name according to priority
224- * and trims it to the limit
225249 */
226- public UidsCookieUpdateResult updateUidsCookie (UidsCookie uidsCookie , String familyName , String uid ) {
227- final UidsCookie initialCookie = trimToLimit (removeExpiredUids (uidsCookie )); // if already exceeded limit
228-
229- if (StringUtils .isBlank (uid )) {
230- return UidsCookieUpdateResult .unaltered (initialCookie .deleteUid (familyName ));
231- } else if (UidsCookie .isFacebookSentinel (familyName , uid )) {
232- // At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook.
233- // They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID.
234- return UidsCookieUpdateResult .unaltered (initialCookie );
250+ public UpdateResult <UidsCookie > updateUidsCookie (UidsCookie uidsCookie , String familyName , String uid ) {
251+ final UidsCookie initialCookie = removeExpiredUids (uidsCookie );
252+
253+ // At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook.
254+ // They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID.
255+ if (StringUtils .isBlank (uid ) || UidsCookie .isFacebookSentinel (familyName , uid )) {
256+ return UpdateResult .unaltered (initialCookie );
235257 }
236258
237- return updateUidsCookieByPriority (initialCookie , familyName , uid );
259+ final UidsCookie updatedCookie = initialCookie .updateUid (familyName , uid );
260+ return UpdateResult .updated (updatedCookie );
238261 }
239262
240263 private static UidsCookie removeExpiredUids (UidsCookie uidsCookie ) {
@@ -250,47 +273,58 @@ private static UidsCookie removeExpiredUids(UidsCookie uidsCookie) {
250273 return updatedCookie ;
251274 }
252275
253- private UidsCookieUpdateResult updateUidsCookieByPriority (UidsCookie uidsCookie , String familyName , String uid ) {
254- final UidsCookie updatedCookie = uidsCookie .updateUid (familyName , uid );
255- if (!cookieExceededMaxLength (updatedCookie )) {
256- return UidsCookieUpdateResult .updated (updatedCookie );
257- }
276+ public List <Cookie > splitUidsIntoCookies (UidsCookie uidsCookie ) {
277+ final Uids cookieUids = uidsCookie .getCookieUids ();
278+ final Map <String , UidWithExpiry > uids = cookieUids .getUids ();
279+ final boolean hasOptout = !uidsCookie .allowsSync ();
258280
259- if (!prioritizedCoopSyncProvider .hasPrioritizedBidders ()
260- || prioritizedCoopSyncProvider .isPrioritizedFamily (familyName )) {
261- return UidsCookieUpdateResult .updated (trimToLimit (updatedCookie ));
262- } else {
263- metrics .updateUserSyncSizeBlockedMetric (familyName );
264- return UidsCookieUpdateResult .unaltered (uidsCookie );
265- }
266- }
281+ final Iterator <String > cookieFamilies = cookieFamilyNamesByDescPriorityAndExpiration (uidsCookie );
282+ final List <Cookie > splitCookies = new ArrayList <>();
267283
268- private boolean cookieExceededMaxLength (UidsCookie uidsCookie ) {
269- return maxCookieSizeBytes > 0 && cookieBytesLength (uidsCookie ) > maxCookieSizeBytes ;
270- }
284+ final int cookieSchemaSize = UidsCookieSize .schemaSize (makeCookie (COOKIE_NAME , StringUtils .EMPTY , ttlSeconds ));
285+ String nextCookieFamily = null ;
286+ for (int i = 0 ; i < numberOfUidCookies ; i ++) {
287+ final int digits = i < 10 ? Integer .signum (i ) : 2 ;
288+ final UidsCookieSize uidsCookieSize = new UidsCookieSize (cookieSchemaSize + digits , maxCookieSizeBytes );
271289
272- private UidsCookie trimToLimit ( UidsCookie uidsCookie ) {
273- if (! cookieExceededMaxLength ( uidsCookie )) {
274- return uidsCookie ;
275- }
290+ final Map < String , UidWithExpiry > tempUids = new HashMap <>();
291+ while ( nextCookieFamily != null || cookieFamilies . hasNext ( )) {
292+ nextCookieFamily = nextCookieFamily == null ? cookieFamilies . next () : nextCookieFamily ;
293+ final UidWithExpiry uidWithExpiry = uids . get ( nextCookieFamily );
276294
277- UidsCookie trimmedUids = uidsCookie ;
278- final Iterator <String > familyToRemoveIterator = cookieFamilyNamesByAscendingPriority (uidsCookie );
295+ uidsCookieSize .addUid (nextCookieFamily , uidWithExpiry .getUid ());
296+ if (!uidsCookieSize .isValid ()) {
297+ break ;
298+ }
299+
300+ tempUids .put (nextCookieFamily , uidWithExpiry );
301+ nextCookieFamily = null ;
302+ }
303+
304+ final String uidsName = i == 0 ? COOKIE_NAME : COOKIE_NAME_FORMAT .formatted (i + 1 );
305+
306+ if (tempUids .isEmpty ()) {
307+ splitCookies .add (expiredCookie (uidsName ));
308+ } else {
309+ splitCookies .add (aliveCookie (
310+ uidsName ,
311+ new UidsCookie (Uids .builder ().uids (tempUids ).optout (hasOptout ).build (), mapper )));
312+ }
313+ }
279314
280- while (familyToRemoveIterator .hasNext () && cookieExceededMaxLength (trimmedUids )) {
281- final String familyToRemove = familyToRemoveIterator .next ();
282- metrics .updateUserSyncSizedOutMetric (familyToRemove );
283- trimmedUids = trimmedUids .deleteUid (familyToRemove );
315+ if (nextCookieFamily != null ) {
316+ updateSyncSizeMetrics (nextCookieFamily );
284317 }
285318
286- return trimmedUids ;
319+ cookieFamilies .forEachRemaining (this ::updateSyncSizeMetrics );
320+
321+ return splitCookies ;
287322 }
288323
289- private Iterator <String > cookieFamilyNamesByAscendingPriority (UidsCookie uidsCookie ) {
324+ private Iterator <String > cookieFamilyNamesByDescPriorityAndExpiration (UidsCookie uidsCookie ) {
290325 return uidsCookie .getCookieUids ().getUids ().entrySet ().stream ()
291326 .sorted (this ::compareCookieFamilyNames )
292327 .map (Map .Entry ::getKey )
293- .toList ()
294328 .iterator ();
295329 }
296330
@@ -303,9 +337,17 @@ private int compareCookieFamilyNames(Map.Entry<String, UidWithExpiry> left,
303337 if ((leftPrioritized && rightPrioritized ) || (!leftPrioritized && !rightPrioritized )) {
304338 return left .getValue ().getExpires ().compareTo (right .getValue ().getExpires ());
305339 } else if (leftPrioritized ) {
306- return 1 ;
307- } else { // right is prioritized
308340 return -1 ;
341+ } else { // right is prioritized
342+ return 1 ;
343+ }
344+ }
345+
346+ private void updateSyncSizeMetrics (String nextCookieFamily ) {
347+ if (prioritizedCoopSyncProvider .isPrioritizedFamily (nextCookieFamily )) {
348+ metrics .updateUserSyncSizedOutMetric (nextCookieFamily );
349+ } else {
350+ metrics .updateUserSyncSizeBlockedMetric (nextCookieFamily );
309351 }
310352 }
311353
0 commit comments