Each user profile should have its own epoch origin
Bug: 229091766
Test: atest AdServicesServiceCoreUnitTests --iterations 2
Change-Id: I500e22c2c7449ca9cb062f408f7df0346bfef575
diff --git a/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java b/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java
index 2eb7296..ceb6843 100644
--- a/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java
+++ b/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java
@@ -51,7 +51,8 @@
TopicsTables.CallerCanLearnTopicsContract.TABLE,
TopicsTables.ReturnedTopicContract.TABLE,
TopicsTables.TopTopicsContract.TABLE,
- TopicsTables.BlockedTopicsContract.TABLE
+ TopicsTables.BlockedTopicsContract.TABLE,
+ TopicsTables.EpochOriginContract.TABLE,
};
private final DbHelper mDbHelper; // Used in tests.
@@ -963,4 +964,66 @@
LogUtil.e(e, String.format("Failed to delete %s in table %s.", appNames, tableName));
}
}
+
+ /**
+ * Persist the origin's timestamp of epoch service in milliseconds into database.
+ *
+ * @param originTimestampMs the timestamp user first calls Topics API
+ */
+ public void persistEpochOrigin(long originTimestampMs) {
+ SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
+ if (db == null) {
+ return;
+ }
+
+ ContentValues values = new ContentValues();
+ values.put(TopicsTables.EpochOriginContract.ORIGIN, originTimestampMs);
+
+ try {
+ db.insert(TopicsTables.EpochOriginContract.TABLE, /* nullColumnHack */ null, values);
+ } catch (SQLException e) {
+ LogUtil.e("Failed to persist epoch origin." + e.getMessage());
+ }
+ }
+
+ /**
+ * Retrieve origin's timestamp of epoch service in milliseconds. If there is no origin persisted
+ * in database, return -1;
+ *
+ * @return the origin's timestamp of epoch service in milliseconds. Return -1 if no origin is
+ * persisted.
+ */
+ public long retrieveEpochOrigin() {
+ long origin = -1L;
+
+ SQLiteDatabase db = mDbHelper.safeGetReadableDatabase();
+ if (db == null) {
+ return origin;
+ }
+
+ String[] projection = {
+ TopicsTables.EpochOriginContract.ORIGIN,
+ };
+
+ try (Cursor cursor =
+ db.query(
+ TopicsTables.EpochOriginContract.TABLE, // The table to query
+ projection, // The array of columns to return (pass null to get all)
+ null, // The columns for the WHERE clause
+ null, // The values for the WHERE clause
+ null, // don't group the rows
+ null, // don't filter by row groups
+ null // The sort order
+ )) {
+ // Return the only entry in this table if existed.
+ if (cursor.moveToNext()) {
+ origin =
+ cursor.getLong(
+ cursor.getColumnIndexOrThrow(
+ TopicsTables.EpochOriginContract.ORIGIN));
+ }
+ }
+
+ return origin;
+ }
}
diff --git a/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java b/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java
index 86ae81d..0203095 100644
--- a/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java
+++ b/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java
@@ -281,6 +281,33 @@
+ " INTEGER NOT NULL"
+ ")";
+ /**
+ * Table to store the original timestamp when the user calls Topics API. This table should have
+ * only 1 row that stores the origin.
+ */
+ public interface EpochOriginContract {
+ String TABLE = TOPICS_TABLE_PREFIX + "epoch_origin";
+ String ONE_ROW_CHECK = "one_row_check"; // to constrain 1 origin
+ String ORIGIN = "origin";
+ }
+
+ // At the first time inserting a record, it won't persist one_row_check field so that this first
+ // entry will have one_row_check = 1. Therefore, further persisting is not allowed as primary
+ // key cannot be duplicated value and one_row_check is constrained to only equal to 1 to forbid
+ // any increment.
+ private static final String CREATE_TABLE_EPOCH_ORIGIN =
+ "CREATE TABLE "
+ + EpochOriginContract.TABLE
+ + "("
+ + EpochOriginContract.ONE_ROW_CHECK
+ + " INTEGER PRIMARY KEY DEFAULT 1, "
+ + EpochOriginContract.ORIGIN
+ + " INTEGER NOT NULL, "
+ + "CONSTRAINT one_row_constraint CHECK ("
+ + EpochOriginContract.ONE_ROW_CHECK
+ + " = 1) "
+ + ")";
+
// Consolidated list of create statements for all tables.
public static final List<String> CREATE_STATEMENTS =
Collections.unmodifiableList(
@@ -292,7 +319,8 @@
CREATE_TABLE_USAGE_HISTORY,
CREATE_TABLE_APP_USAGE_HISTORY,
CREATE_TABLE_CALLER_CAN_LEARN_TOPICS,
- CREATE_TABLE_BLOCKED_TOPICS));
+ CREATE_TABLE_BLOCKED_TOPICS,
+ CREATE_TABLE_EPOCH_ORIGIN));
// Private constructor to prevent instantiation.
private TopicsTables() {}
diff --git a/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java b/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java
index 41ba81b..5247bd5 100644
--- a/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java
+++ b/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java
@@ -32,12 +32,14 @@
import com.android.adservices.data.topics.TopicsTables;
import com.android.adservices.service.Flags;
import com.android.adservices.service.FlagsFactory;
+import com.android.adservices.service.stats.Clock;
import com.android.adservices.service.topics.classifier.Classifier;
import com.android.adservices.service.topics.classifier.OnDeviceClassifier;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.Preconditions;
import java.io.PrintWriter;
+import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -49,14 +51,6 @@
/** A class to manage Epoch computation. */
public class EpochManager implements Dumpable {
-
- // We use this origin to compute epoch timestamp.
- // In other words, the first epoch started at
- // Saturday, January 1, 2022 12:00:00 AM
- // TODO(b/221463765): get the timestamp when first access to the origin.
- // Save in SharedPreferences or slqlite db.
- private static final long ORIGIN_EPOCH_TIMESTAMP = 1640995200;
-
// TODO(b/223915674): make this configurable.
// The Top Topics will have 6 topics.
// The first 5 topics are the Top Topics derived by ML, and the 6th is a random topic from
@@ -100,6 +94,8 @@
private final Random mRandom;
private final Classifier mClassifier;
private final Flags mFlags;
+ // Use Clock.SYSTEM_CLOCK except in unit tests, which pass in a local instance of Clock to mock.
+ private final Clock mClock;
@VisibleForTesting
EpochManager(
@@ -107,12 +103,14 @@
@NonNull DbHelper dbHelper,
@NonNull Random random,
@NonNull Classifier classifier,
- Flags flags) {
+ Flags flags,
+ @NonNull Clock clock) {
mTopicsDao = topicsDao;
mDbHelper = dbHelper;
mRandom = random;
mClassifier = classifier;
mFlags = flags;
+ mClock = clock;
}
/** Returns an instance of the EpochManager given a context. */
@@ -126,7 +124,8 @@
DbHelper.getInstance(context),
new Random(),
OnDeviceClassifier.getInstance(context),
- FlagsFactory.getFlags());
+ FlagsFactory.getFlags(),
+ Clock.SYSTEM_CLOCK);
}
return sSingleton;
}
@@ -139,10 +138,12 @@
return;
}
- // This cross db and java boundaries multiple times so we need to have a db transaction.
+ // This cross db and java boundaries multiple times, so we need to have a db transaction.
db.beginTransaction();
+
long currentEpochId = getCurrentEpochId();
LogUtil.d("EpochManager.processEpoch for the current epochId %d", currentEpochId);
+
try {
// Step 1: Compute the UsageMap from the UsageHistory table.
// appSdksUsageMap = Map<App, List<SDK>> has the app and its SDKs that called Topics API
@@ -198,7 +199,7 @@
mTopicsDao.persistTopTopics(currentEpochId, topTopics);
// Step 6: Assign topics to apps and SDK from the global top topics.
- // Currently hard-code the taxonomyVersion and the modelVersion.
+ // Currently, hard-code the taxonomyVersion and the modelVersion.
// Return returnedAppSdkTopics = Map<Pair<App, Sdk>, Topic>
Map<Pair<String, String>, Topic> returnedAppSdkTopics =
computeReturnedAppSdkTopics(callersCanLearnMap, appSdksUsageMap, topTopics);
@@ -283,13 +284,13 @@
* empty string.
*/
public void recordUsageHistory(String app, String sdk) {
- long epochID = getCurrentEpochId();
+ long epochId = getCurrentEpochId();
// TODO(b/223159123): Do we need to filter out this log in prod build?
LogUtil.v(
"EpochManager.recordUsageHistory for current EpochId = %d for %s, %s",
- epochID, app, sdk);
- mTopicsDao.recordUsageHistory(epochID, app, sdk);
- mTopicsDao.recordAppUsageHistory(epochID, app);
+ epochId, app, sdk);
+ mTopicsDao.recordUsageHistory(epochId, app, sdk);
+ mTopicsDao.recordAppUsageHistory(epochId, app);
}
/**
@@ -320,6 +321,35 @@
&& callersCanLearnMap.get(topic).contains(caller);
}
+ /**
+ * Get the ID of current epoch.
+ *
+ * <p>The origin's timestamp is saved in the database. If the origin doesn't exist, it means the
+ * user never calls Topics API and the origin will be returned with -1. In this case, set
+ * current time as origin and persist it into database.
+ *
+ * @return a non-negative epoch ID of current epoch.
+ */
+ // TODO(b/237119788): Cache origin in cache manager.
+ // TODO(b/237119790): Set origin to sometime after midnight to get better maintenance timing.
+ public long getCurrentEpochId() {
+ long origin = mTopicsDao.retrieveEpochOrigin();
+ long currentTimeStamp = mClock.currentTimeMillis();
+ long epochJobPeriodsMs = mFlags.getTopicsEpochJobPeriodMs();
+
+ // If origin doesn't exist in database, set current timestamp as origin.
+ if (origin == -1) {
+ origin = currentTimeStamp;
+ mTopicsDao.persistEpochOrigin(origin);
+ LogUtil.d(
+ "Origin isn't found! Set current time %s as origin.",
+ Instant.ofEpochMilli(origin).toString());
+ }
+
+ LogUtil.v("Epoch length is %d", epochJobPeriodsMs);
+ return (long) Math.floor((currentTimeStamp - origin) / (double) epochJobPeriodsMs);
+ }
+
// Return a Map from Topic to set of App or Sdk that can learn about that topic.
// This is similar to the table Can Learn Topic in the explainer.
// Return Map<Topic, Set<Caller>> where Caller = App or Sdk.
@@ -448,21 +478,6 @@
}
}
- // Return the current epochId.
- // Each Epoch will have an Id. The first epoch has Id = 0.
- // For Alpha 1, we assume a fixed origin epoch starting from
- // Saturday, January 1, 2022 12:00:00 AM.
- // Later, we will use per device starting origin.
- @VisibleForTesting
- public long getCurrentEpochId() {
- // TODO(b/221463765): Don't use a fix epoch origin like this. This is for Alpha 1 only.
- LogUtil.v("Epoch length is %d", mFlags.getTopicsEpochJobPeriodMs());
- return (long)
- Math.floor(
- (System.currentTimeMillis() - ORIGIN_EPOCH_TIMESTAMP)
- / mFlags.getTopicsEpochJobPeriodMs());
- }
-
@Override
public void dump(@NonNull PrintWriter writer, @Nullable String[] args) {
writer.println("==== EpochManager Dump ====");
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java
index 5c9309f..204f876 100644
--- a/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java
@@ -71,6 +71,7 @@
DbTestUtil.deleteTable(TopicsTables.UsageHistoryContract.TABLE);
DbTestUtil.deleteTable(TopicsTables.AppUsageHistoryContract.TABLE);
DbTestUtil.deleteTable(TopicsTables.BlockedTopicsContract.TABLE);
+ DbTestUtil.deleteTable(TopicsTables.EpochOriginContract.TABLE);
}
@Test
@@ -838,4 +839,37 @@
// Nothing will happen as no satisfied entry to delete
assertThat(topicsMapFromDb1).isEqualTo(expectedTopicsMap1);
}
+
+ @Test
+ public void testPersistAndRetrieveEpochOrigin() {
+ final long epochOrigin = 1234567890L;
+
+ mTopicsDao.persistEpochOrigin(epochOrigin);
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin);
+ }
+
+ // TODO(b/230669931): Add test to check SQLException when it's enabled in TopicsDao.
+ @Test
+ public void testPersistAndRetrieveEpochOrigin_multipleInsertion() {
+ final long epochOrigin1 = 1L;
+ final long epochOrigin2 = 2L;
+
+ mTopicsDao.persistEpochOrigin(epochOrigin1);
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin1);
+
+ // Persist a different origin when there is an existing origin will not change the existing
+ // origin.
+ mTopicsDao.persistEpochOrigin(epochOrigin2);
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin1);
+
+ // Persist same origin
+ mTopicsDao.persistEpochOrigin(epochOrigin1);
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin1);
+ }
+
+ @Test
+ public void testPersistAndRetrieveEpochOrigin_EmptyTable() {
+ // Should return -1 if no origin is persisted
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(-1);
+ }
}
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java
index 1279ee1..c30babe 100644
--- a/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java
@@ -19,6 +19,7 @@
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -38,6 +39,7 @@
import com.android.adservices.data.topics.TopicsTables;
import com.android.adservices.service.Flags;
import com.android.adservices.service.FlagsFactory;
+import com.android.adservices.service.stats.Clock;
import com.android.adservices.service.topics.classifier.Classifier;
import org.junit.Before;
@@ -66,6 +68,8 @@
public final class EpochManagerTest {
@SuppressWarnings({"unused"})
private static final String TAG = "EpochManagerTest";
+
+ private static final long TOPICS_EPOCH_JOB_PERIOD_MS = 7 * 86_400_000;
// TODO: (b/232807776) Replace below hardcoded taxonomy version and model version
private static final long TAXONOMY_VERSION = 1L;
private static final long MODEL_VERSION = 1L;
@@ -80,6 +84,7 @@
private EpochManager mEpochManager;
@Mock Classifier mMockClassifier;
+ @Mock Clock mMockClock;
@Before
public void setup() {
@@ -88,7 +93,8 @@
mDbHelper = DbTestUtil.getDbHelperForTest();
mTopicsDao = new TopicsDao(mDbHelper);
mEpochManager =
- new EpochManager(mTopicsDao, mDbHelper, new Random(), mMockClassifier, mFlags);
+ new EpochManager(
+ mTopicsDao, mDbHelper, new Random(), mMockClassifier, mFlags, mMockClock);
// Erase all existing data.
DbTestUtil.deleteTable(TopicsTables.TaxonomyContract.TABLE);
@@ -98,6 +104,7 @@
DbTestUtil.deleteTable(TopicsTables.ReturnedTopicContract.TABLE);
DbTestUtil.deleteTable(TopicsTables.UsageHistoryContract.TABLE);
DbTestUtil.deleteTable(TopicsTables.AppUsageHistoryContract.TABLE);
+ DbTestUtil.deleteTable(TopicsTables.EpochOriginContract.TABLE);
}
@Test
@@ -188,7 +195,8 @@
mDbHelper,
new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
mMockClassifier,
- mFlags);
+ mFlags,
+ mMockClock);
Topic topic1 = Topic.create(/* topic */ 1, TAXONOMY_VERSION, MODEL_VERSION);
Topic topic2 = Topic.create(/* topic */ 2, TAXONOMY_VERSION, MODEL_VERSION);
@@ -227,7 +235,8 @@
mDbHelper,
new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
mMockClassifier,
- mFlags);
+ mFlags,
+ mMockClock);
Topic topic1 = Topic.create(/* topic */ 1, TAXONOMY_VERSION, MODEL_VERSION);
Topic topic2 = Topic.create(/* topic */ 2, TAXONOMY_VERSION, MODEL_VERSION);
@@ -251,7 +260,8 @@
mDbHelper,
new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
mMockClassifier,
- mFlags);
+ mFlags,
+ mMockClock);
// Note: we iterate over the appSdksUsageMap. For the test to be deterministic, we use
// LinkedHashMap so that the order of iteration is defined.
@@ -372,7 +382,13 @@
when(mockedFlags.getNumberOfEpochsToKeepInHistory()).thenReturn(3);
EpochManager epochManager =
- new EpochManager(mTopicsDao, mDbHelper, new Random(), mMockClassifier, mockedFlags);
+ new EpochManager(
+ mTopicsDao,
+ mDbHelper,
+ new Random(),
+ mMockClassifier,
+ mockedFlags,
+ mMockClock);
final long currentEpoch = 6L;
final int epochLookBackNumberForGarbageCollection = 3;
@@ -438,10 +454,11 @@
mDbHelper,
new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
mMockClassifier,
- mFlags));
+ mFlags,
+ mMockClock));
// Mock EpochManager for getCurrentEpochId()
final long epochId = 1L;
- when(epochManager.getCurrentEpochId()).thenReturn(epochId);
+ doReturn(epochId).when(epochManager).getCurrentEpochId();
Topic topic1 = Topic.create(/* topic */ 1, TAXONOMY_VERSION, MODEL_VERSION);
Topic topic2 = Topic.create(/* topic */ 2, TAXONOMY_VERSION, MODEL_VERSION);
@@ -612,12 +629,17 @@
EpochManager epochManager =
Mockito.spy(
new EpochManager(
- topicsDao, mDbHelper, new Random(), mMockClassifier, mFlags));
+ topicsDao,
+ mDbHelper,
+ new Random(),
+ mMockClassifier,
+ mFlags,
+ mMockClock));
// To mimic the scenario that there was no usage in last epoch.
// i.e. current epoch id is 2, with some usages, while epoch id = 1 has no usage.
final long epochId = 2L;
- when(epochManager.getCurrentEpochId()).thenReturn(epochId);
+ doReturn(epochId).when(epochManager).getCurrentEpochId();
// Note: we iterate over the appSdksUsageMap. For the test to be deterministic, we use
// LinkedHashMap so that the order of iteration is defined.
@@ -739,7 +761,8 @@
Flags flags = mock(Flags.class);
when(flags.getTopicsNumberOfTopTopics()).thenReturn(3);
EpochManager epochManager =
- new EpochManager(mTopicsDao, mDbHelper, new Random(), mMockClassifier, flags);
+ new EpochManager(
+ mTopicsDao, mDbHelper, new Random(), mMockClassifier, flags, mMockClock);
final String app = "app";
@@ -786,4 +809,38 @@
verify(flags, times(6)).getTopicsNumberOfTopTopics();
}
+
+ @Test
+ public void testGetCurrentEpochId() {
+ Flags flags = mock(Flags.class);
+ when(flags.getTopicsEpochJobPeriodMs()).thenReturn(TOPICS_EPOCH_JOB_PERIOD_MS);
+ // Initialize a local instance of epochManager to use mocked Flags.
+ EpochManager epochManager =
+ new EpochManager(
+ mTopicsDao, mDbHelper, new Random(), mMockClassifier, flags, mMockClock);
+
+ // Mock clock so that:
+ // 1st call: There is no origin and will set 0 as origin.
+ // 2nd call: The beginning of next epoch
+ // 3rd call: In the middle of the epoch after to test if current time is at somewhere
+ // between two epochs.
+ when(mMockClock.currentTimeMillis())
+ .thenReturn(
+ 0L, TOPICS_EPOCH_JOB_PERIOD_MS, (long) (2.5 * TOPICS_EPOCH_JOB_PERIOD_MS));
+
+ // Origin doesn't exist
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(-1);
+ assertThat(epochManager.getCurrentEpochId()).isEqualTo(0L);
+ // Origin has been persisted
+ assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(0L);
+
+ // 2nd call is on the start of next epoch (epochId = 1)
+ assertThat(epochManager.getCurrentEpochId()).isEqualTo(1L);
+
+ // 3rd call is in the middle of the epoch after (epochId = 2)
+ assertThat(epochManager.getCurrentEpochId()).isEqualTo(2L);
+
+ verify(flags, times(3)).getTopicsEpochJobPeriodMs();
+ verify(mMockClock, times(3)).currentTimeMillis();
+ }
}