1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.adservices.data.adselection; 18 19 import android.adservices.common.AdTechIdentifier; 20 import android.adservices.common.FrequencyCapFilters; 21 22 import androidx.annotation.NonNull; 23 import androidx.annotation.Nullable; 24 import androidx.room.Dao; 25 import androidx.room.Insert; 26 import androidx.room.OnConflictStrategy; 27 import androidx.room.Query; 28 import androidx.room.Transaction; 29 30 import com.android.adservices.service.adselection.HistogramEvent; 31 32 import com.google.common.base.Preconditions; 33 34 import java.time.Instant; 35 import java.util.Objects; 36 37 /** 38 * DAO used to access ad counter histogram data used in frequency cap filtering during ad selection. 39 * 40 * <p>Annotated abstract methods will generate their own Room DB implementations. 41 */ 42 @Dao 43 public abstract class FrequencyCapDao { 44 /** 45 * Attempts to persist a new {@link DBHistogramIdentifier} to the identifier table. 46 * 47 * <p>If there is already an identifier persisted with the same foreign key (see {@link 48 * DBHistogramIdentifier#getHistogramIdentifierForeignKey()}), the transaction is canceled and 49 * rolled back. 50 * 51 * <p>This method is not intended to be called on its own. Please use {@link 52 * #insertHistogramEvent(HistogramEvent, int, int)} instead. 53 * 54 * @return the row ID of the identifier in the table, or {@code -1} if the specified row ID is 55 * already occupied 56 */ 57 @Insert(onConflict = OnConflictStrategy.ABORT) insertNewHistogramIdentifier(@onNull DBHistogramIdentifier identifier)58 protected abstract long insertNewHistogramIdentifier(@NonNull DBHistogramIdentifier identifier); 59 60 /** 61 * Returns the foreign key ID for the identifier matching the given constraints, or {@code null} 62 * if no match is found. 63 * 64 * <p>If multiple matches are found, only the first (as ordered by the numerical ID) is 65 * returned. 66 * 67 * <p>This method is not intended to be called on its own. It should only be used in {@link 68 * #insertHistogramEvent(HistogramEvent, int, int)}. 69 * 70 * @return the row ID of the identifier in the table, or {@code null} if not found 71 */ 72 @Query( 73 "SELECT foreign_key_id FROM fcap_histogram_ids " 74 + "WHERE ad_counter_key = :adCounterKey " 75 + "AND buyer = :buyer " 76 // Note that the IS operator in SQLite specifically is equivalent to = for value 77 // matching except that it also matches NULL 78 + "AND custom_audience_owner IS :customAudienceOwner " 79 + "AND custom_audience_name IS :customAudienceName " 80 + "ORDER BY foreign_key_id ASC " 81 + "LIMIT 1") 82 @Nullable getHistogramIdentifierForeignKeyIfExists( @onNull String adCounterKey, @NonNull AdTechIdentifier buyer, @Nullable String customAudienceOwner, @Nullable String customAudienceName)83 protected abstract Long getHistogramIdentifierForeignKeyIfExists( 84 @NonNull String adCounterKey, 85 @NonNull AdTechIdentifier buyer, 86 @Nullable String customAudienceOwner, 87 @Nullable String customAudienceName); 88 89 /** 90 * Attempts to persist a new {@link DBHistogramEventData} to the event data table. 91 * 92 * <p>If there is already an entry in the table with the same non-{@code null} row ID, the 93 * transaction is canceled and rolled back. 94 * 95 * <p>This method is not intended to be called on its own. Please use {@link 96 * #insertHistogramEvent(HistogramEvent, int, int)} instead. 97 * 98 * @return the row ID of the event data in the table, or -1 if the event data already exists 99 */ 100 @Insert(onConflict = OnConflictStrategy.ABORT) insertNewHistogramEventData(@onNull DBHistogramEventData eventData)101 protected abstract long insertNewHistogramEventData(@NonNull DBHistogramEventData eventData); 102 103 /** 104 * Attempts to insert a {@link HistogramEvent} into the histogram tables in a single 105 * transaction. 106 * 107 * <p>If the current number of events in the histogram table is larger than the given {@code 108 * absoluteMaxHistogramEventCount}, then the oldest events in the table will be evicted so that 109 * the count of events is the given {@code lowerMaxHistogramEventCount}. 110 * 111 * @throws IllegalStateException if an error was encountered adding the event 112 */ 113 @Transaction insertHistogramEvent( @onNull HistogramEvent event, int absoluteMaxHistogramEventCount, int lowerMaxHistogramEventCount)114 public void insertHistogramEvent( 115 @NonNull HistogramEvent event, 116 int absoluteMaxHistogramEventCount, 117 int lowerMaxHistogramEventCount) 118 throws IllegalStateException { 119 Objects.requireNonNull(event); 120 Preconditions.checkArgument(absoluteMaxHistogramEventCount > 0); 121 Preconditions.checkArgument(lowerMaxHistogramEventCount > 0); 122 Preconditions.checkArgument(absoluteMaxHistogramEventCount > lowerMaxHistogramEventCount); 123 124 // TODO(b/275581841): Collect and send telemetry on frequency cap eviction 125 // Check the table size first and evict older events if necessary 126 int currentHistogramEventCount = getTotalNumHistogramEvents(); 127 if (currentHistogramEventCount >= absoluteMaxHistogramEventCount) { 128 int numEventsToDelete = currentHistogramEventCount - lowerMaxHistogramEventCount; 129 deleteOldestHistogramEventData(numEventsToDelete); 130 deleteUnpairedHistogramIdentifiers(); 131 } 132 133 // Converting to DBHistogramIdentifier drops custom audience fields if the type is WIN 134 DBHistogramIdentifier identifier = DBHistogramIdentifier.fromHistogramEvent(event); 135 Long foreignKeyId = 136 getHistogramIdentifierForeignKeyIfExists( 137 identifier.getAdCounterKey(), 138 identifier.getBuyer(), 139 identifier.getCustomAudienceOwner(), 140 identifier.getCustomAudienceName()); 141 142 if (foreignKeyId == null) { 143 try { 144 foreignKeyId = insertNewHistogramIdentifier(identifier); 145 } catch (Exception exception) { 146 throw new IllegalStateException("Error inserting histogram identifier", exception); 147 } 148 } 149 150 try { 151 insertNewHistogramEventData( 152 DBHistogramEventData.fromHistogramEvent(foreignKeyId, event)); 153 } catch (Exception exception) { 154 throw new IllegalStateException("Error inserting histogram event data", exception); 155 } 156 } 157 158 /** 159 * Returns the number of events in the appropriate buyer's histograms that have been registered 160 * since the given timestamp. 161 * 162 * @return the number of found events that match the criteria 163 */ 164 @Query( 165 "SELECT COUNT(DISTINCT data.row_id) FROM fcap_histogram_data AS data " 166 + "INNER JOIN fcap_histogram_ids AS ids " 167 + "ON data.foreign_key_id = ids.foreign_key_id " 168 + "WHERE ids.ad_counter_key = :adCounterKey " 169 + "AND ids.buyer = :buyer " 170 + "AND data.ad_event_type = :adEventType " 171 + "AND data.timestamp >= :startTime") getNumEventsForBuyerAfterTime( @onNull String adCounterKey, @NonNull AdTechIdentifier buyer, @FrequencyCapFilters.AdEventType int adEventType, @NonNull Instant startTime)172 public abstract int getNumEventsForBuyerAfterTime( 173 @NonNull String adCounterKey, 174 @NonNull AdTechIdentifier buyer, 175 @FrequencyCapFilters.AdEventType int adEventType, 176 @NonNull Instant startTime); 177 178 /** 179 * Returns the number of events in the appropriate custom audience's histogram that have been 180 * registered since the given timestamp. 181 * 182 * @return the number of found events that match the criteria 183 */ 184 @Query( 185 "SELECT COUNT(DISTINCT data.row_id) FROM fcap_histogram_data AS data " 186 + "INNER JOIN fcap_histogram_ids AS ids " 187 + "ON data.foreign_key_id = ids.foreign_key_id " 188 + "WHERE ids.ad_counter_key = :adCounterKey " 189 + "AND ids.buyer = :buyer " 190 + "AND ids.custom_audience_owner = :customAudienceOwner " 191 + "AND ids.custom_audience_name = :customAudienceName " 192 + "AND data.ad_event_type = :adEventType " 193 + "AND data.timestamp >= :startTime") getNumEventsForCustomAudienceAfterTime( @onNull String adCounterKey, @NonNull AdTechIdentifier buyer, @NonNull String customAudienceOwner, @NonNull String customAudienceName, @FrequencyCapFilters.AdEventType int adEventType, @NonNull Instant startTime)194 public abstract int getNumEventsForCustomAudienceAfterTime( 195 @NonNull String adCounterKey, 196 @NonNull AdTechIdentifier buyer, 197 @NonNull String customAudienceOwner, 198 @NonNull String customAudienceName, 199 @FrequencyCapFilters.AdEventType int adEventType, 200 @NonNull Instant startTime); 201 202 /** 203 * Deletes all histogram event data older than the given {@code expiryTime}. 204 * 205 * <p>This method is not intended to be called on its own. Please use {@link 206 * #deleteAllExpiredHistogramData(Instant)} instead. 207 * 208 * @return the number of deleted events 209 */ 210 @Query("DELETE FROM fcap_histogram_data WHERE timestamp < :expiryTime") deleteHistogramEventDataBeforeTime(@onNull Instant expiryTime)211 protected abstract int deleteHistogramEventDataBeforeTime(@NonNull Instant expiryTime); 212 213 /** 214 * Deletes the oldest N histogram events, where N is at most {@code numEventsToDelete}, and 215 * returns the number of entries deleted. 216 * 217 * <p>This method is not meant to be called on its own. Please use {@link 218 * #insertHistogramEvent(HistogramEvent, int, int)} to evict data when the table is full. 219 */ 220 @Query( 221 "DELETE FROM fcap_histogram_data " 222 + "WHERE row_id IN " 223 + "(SELECT row_id FROM fcap_histogram_data " 224 + "ORDER BY timestamp ASC " 225 + "LIMIT :numEventsToDelete)") deleteOldestHistogramEventData(int numEventsToDelete)226 protected abstract int deleteOldestHistogramEventData(int numEventsToDelete); 227 228 /** 229 * Deletes histogram identifiers which have no associated event data. 230 * 231 * <p>This method is not intended to be called on its own. Please use {@link 232 * #deleteAllExpiredHistogramData(Instant)} instead. 233 * 234 * @return the number of deleted identifiers 235 */ 236 @Query( 237 "DELETE FROM fcap_histogram_ids " 238 + "WHERE foreign_key_id NOT IN " 239 + "(SELECT ids.foreign_key_id FROM fcap_histogram_ids AS ids " 240 + "INNER JOIN fcap_histogram_data AS data " 241 + "ON ids.foreign_key_id = data.foreign_key_id)") deleteUnpairedHistogramIdentifiers()242 protected abstract int deleteUnpairedHistogramIdentifiers(); 243 244 /** 245 * Deletes all histogram data older than the given {@code expiryTime} in a single database 246 * transaction. 247 * 248 * <p>Also cleans up any histogram identifiers which are no longer associated with any event 249 * data. 250 * 251 * @return the number of deleted events 252 */ 253 @Transaction deleteAllExpiredHistogramData(@onNull Instant expiryTime)254 public int deleteAllExpiredHistogramData(@NonNull Instant expiryTime) { 255 Objects.requireNonNull(expiryTime); 256 257 int numDeletedEvents = deleteHistogramEventDataBeforeTime(expiryTime); 258 deleteUnpairedHistogramIdentifiers(); 259 return numDeletedEvents; 260 } 261 262 /** Returns the current total number of histogram events in the data table. */ 263 @Query("SELECT COUNT(DISTINCT row_id) FROM fcap_histogram_data") getTotalNumHistogramEvents()264 public abstract int getTotalNumHistogramEvents(); 265 } 266