• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 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.service.topics;
18 
19 import android.annotation.NonNull;
20 import android.content.Context;
21 import android.database.sqlite.SQLiteDatabase;
22 import android.os.Build;
23 import android.text.TextUtils;
24 import android.util.Pair;
25 
26 import androidx.annotation.Nullable;
27 import androidx.annotation.RequiresApi;
28 
29 import com.android.adservices.LoggerFactory;
30 import com.android.adservices.data.DbHelper;
31 import com.android.adservices.data.topics.Topic;
32 import com.android.adservices.data.topics.TopicsDao;
33 import com.android.adservices.data.topics.TopicsTables;
34 import com.android.adservices.service.Flags;
35 import com.android.adservices.service.FlagsFactory;
36 import com.android.adservices.service.stats.Clock;
37 import com.android.adservices.service.topics.classifier.Classifier;
38 import com.android.adservices.service.topics.classifier.ClassifierManager;
39 import com.android.internal.annotations.GuardedBy;
40 import com.android.internal.annotations.VisibleForTesting;
41 import com.android.internal.util.Preconditions;
42 
43 import java.io.PrintWriter;
44 import java.time.Instant;
45 import java.util.HashMap;
46 import java.util.HashSet;
47 import java.util.List;
48 import java.util.Map;
49 import java.util.Objects;
50 import java.util.Random;
51 import java.util.Set;
52 
53 /** A class to manage Epoch computation. */
54 // TODO(b/269798827): Enable for R.
55 @RequiresApi(Build.VERSION_CODES.S)
56 public class EpochManager {
57     private static final LoggerFactory.Logger sLogger = LoggerFactory.getTopicsLogger();
58     // The tables to do garbage collection for old epochs
59     // and its corresponding epoch_id column name.
60     // Pair<Table Name, Column Name>
61     private static final Pair<String, String>[] TABLE_INFO_FOR_EPOCH_GARBAGE_COLLECTION =
62             new Pair[] {
63                 Pair.create(
64                         TopicsTables.AppClassificationTopicsContract.TABLE,
65                         TopicsTables.AppClassificationTopicsContract.EPOCH_ID),
66                 Pair.create(
67                         TopicsTables.TopTopicsContract.TABLE,
68                         TopicsTables.TopTopicsContract.EPOCH_ID),
69                 Pair.create(
70                         TopicsTables.ReturnedTopicContract.TABLE,
71                         TopicsTables.ReturnedTopicContract.EPOCH_ID),
72                 Pair.create(
73                         TopicsTables.UsageHistoryContract.TABLE,
74                         TopicsTables.UsageHistoryContract.EPOCH_ID),
75                 Pair.create(
76                         TopicsTables.AppUsageHistoryContract.TABLE,
77                         TopicsTables.AppUsageHistoryContract.EPOCH_ID),
78                 Pair.create(
79                         TopicsTables.TopicContributorsContract.TABLE,
80                         TopicsTables.TopicContributorsContract.EPOCH_ID)
81             };
82 
83     /**
84      * The string to annotate that the topic is a padded topic in {@code TopicContributors} table.
85      * After the computation of {@code TopicContributors} table, if there is a top topic without
86      * contributors, it must be a padded topic. Persist {@code Entry{Topic,
87      * PADDED_TOP_TOPICS_STRING}} into {@code TopicContributors} table.
88      *
89      * <p>The reason to persist {@code Entry{Topic, PADDED_TOP_TOPICS_STRING}} is because topics
90      * need to be assigned to newly installed app. Moreover, non-random top topics without
91      * contributors, due to app uninstallations, are filtered out as candidate topics to assign
92      * with. Generally, a padded topic should have no contributors, but it should NOT be filtered
93      * out as a non-random top topics without contributors. Based on these facts, {@code
94      * Entry{Topic, PADDED_TOP_TOPICS_STRING}} is persisted to annotate that do NOT remove this
95      * padded topic though it has no contributors.
96      *
97      * <p>Put a "!" at last to avoid a spoof app to name itself with {@code
98      * PADDED_TOP_TOPICS_STRING}. Refer to
99      * https://developer.android.com/studio/build/configure-app-module, application name can only
100      * contain [a-zA-Z0-9_].
101      */
102     @VisibleForTesting
103     public static final String PADDED_TOP_TOPICS_STRING = "no_contributors_due_to_padding!";
104 
105     private static final Object SINGLETON_LOCK = new Object();
106 
107     @GuardedBy("SINGLETON_LOCK")
108     private static EpochManager sSingleton;
109 
110     private final TopicsDao mTopicsDao;
111     private final DbHelper mDbHelper;
112     private final Random mRandom;
113     private final Classifier mClassifier;
114     private final Flags mFlags;
115     // Use Clock.SYSTEM_CLOCK except in unit tests, which pass in a local instance of Clock to mock.
116     private final Clock mClock;
117 
118     @VisibleForTesting
EpochManager( @onNull TopicsDao topicsDao, @NonNull DbHelper dbHelper, @NonNull Random random, @NonNull Classifier classifier, Flags flags, @NonNull Clock clock)119     EpochManager(
120             @NonNull TopicsDao topicsDao,
121             @NonNull DbHelper dbHelper,
122             @NonNull Random random,
123             @NonNull Classifier classifier,
124             Flags flags,
125             @NonNull Clock clock) {
126         mTopicsDao = topicsDao;
127         mDbHelper = dbHelper;
128         mRandom = random;
129         mClassifier = classifier;
130         mFlags = flags;
131         mClock = clock;
132     }
133 
134     /** Returns an instance of the EpochManager given a context. */
135     @NonNull
getInstance(@onNull Context context)136     public static EpochManager getInstance(@NonNull Context context) {
137         synchronized (SINGLETON_LOCK) {
138             if (sSingleton == null) {
139                 sSingleton =
140                         new EpochManager(
141                                 TopicsDao.getInstance(context),
142                                 DbHelper.getInstance(context),
143                                 new Random(),
144                                 ClassifierManager.getInstance(context),
145                                 FlagsFactory.getFlags(),
146                                 Clock.SYSTEM_CLOCK);
147             }
148             return sSingleton;
149         }
150     }
151 
152     /** Offline Epoch Processing. For more details, see go/rb-topics-epoch-computation */
processEpoch()153     public void processEpoch() {
154         SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
155         if (db == null) {
156             return;
157         }
158 
159         // This cross db and java boundaries multiple times, so we need to have a db transaction.
160         sLogger.d("Start of Epoch Computation");
161         db.beginTransaction();
162 
163         long currentEpochId = getCurrentEpochId();
164         sLogger.d("EpochManager.processEpoch for the current epochId %d", currentEpochId);
165 
166         try {
167             // Step 0: erase outdated epoch's data, i.e. epoch earlier than
168             // (currentEpoch - numberOfLookBackEpochs) (inclusive)
169             garbageCollectOutdatedEpochData(currentEpochId);
170 
171             // Step 1: Compute the UsageMap from the UsageHistory table.
172             // appSdksUsageMap = Map<App, List<SDK>> has the app and its SDKs that called Topics API
173             // in the current Epoch.
174             Map<String, List<String>> appSdksUsageMap =
175                     mTopicsDao.retrieveAppSdksUsageMap(currentEpochId);
176             sLogger.v("appSdksUsageMap size is  %d", appSdksUsageMap.size());
177 
178             // Step 2: Compute the Map from App to its classification topics.
179             // Only produce for apps that called the Topics API in the current Epoch.
180             // appClassificationTopicsMap = Map<App, List<Topics>>
181             Map<String, List<Topic>> appClassificationTopicsMap =
182                     mClassifier.classify(appSdksUsageMap.keySet());
183             sLogger.v("appClassificationTopicsMap size is %d", appClassificationTopicsMap.size());
184 
185             // Then save app-topics Map into DB
186             mTopicsDao.persistAppClassificationTopics(currentEpochId, appClassificationTopicsMap);
187 
188             // Step 3: Compute the Callers can learn map for this epoch.
189             // This is similar to the Callers Can Learn table in the explainer.
190             Map<Topic, Set<String>> callersCanLearnThisEpochMap =
191                     computeCallersCanLearnMap(appSdksUsageMap, appClassificationTopicsMap);
192             sLogger.v(
193                     "callersCanLearnThisEpochMap size is  %d", callersCanLearnThisEpochMap.size());
194 
195             // And then save this CallersCanLearnMap to DB.
196             mTopicsDao.persistCallerCanLearnTopics(currentEpochId, callersCanLearnThisEpochMap);
197 
198             // Step 4: For each topic, retrieve the callers (App or SDK) that can learn about that
199             // topic. We look at last 3 epochs. More specifically, epochs in
200             // [currentEpochId - 2, currentEpochId]. (inclusive)
201             // Return callersCanLearnMap = Map<Topic, Set<Caller>>  where Caller = App or Sdk.
202             Map<Topic, Set<String>> callersCanLearnMap =
203                     mTopicsDao.retrieveCallerCanLearnTopicsMap(
204                             currentEpochId, mFlags.getTopicsNumberOfLookBackEpochs());
205             sLogger.v("callersCanLearnMap size is %d", callersCanLearnMap.size());
206 
207             // Step 5: Retrieve the Top Topics. This will return a list of 5 top topics and
208             // the 6th topic which is selected randomly. We can refer this 6th topic as the
209             // random-topic.
210             List<Topic> topTopics =
211                     mClassifier.getTopTopics(
212                             appClassificationTopicsMap,
213                             mFlags.getTopicsNumberOfTopTopics(),
214                             mFlags.getTopicsNumberOfRandomTopics());
215             // Abort the computation if empty list of top topics is returned from classifier.
216             // This could happen if there is no usage of the Topics API in the last epoch.
217             if (topTopics.isEmpty()) {
218                 sLogger.w(
219                         "Empty list of top topics is returned from classifier. Aborting the"
220                                 + " computation!");
221                 db.setTransactionSuccessful();
222                 return;
223             }
224             sLogger.v("topTopics are  %s", topTopics.toString());
225 
226             // Then save Top Topics into DB
227             mTopicsDao.persistTopTopics(currentEpochId, topTopics);
228 
229             // Compute TopicToContributors mapping for top topics. In an epoch, an app is a
230             // contributor to a topic if the app has called Topics API in this epoch and is
231             // classified to the topic.
232             // Do this only when feature is enabled.
233             Map<Integer, Set<String>> topTopicsToContributorsMap =
234                     computeTopTopicsToContributorsMap(appClassificationTopicsMap, topTopics);
235             // Then save Topic Contributors into DB
236             mTopicsDao.persistTopicContributors(currentEpochId, topTopicsToContributorsMap);
237 
238             // Step 6: Assign topics to apps and SDK from the global top topics.
239             // Currently, hard-code the taxonomyVersion and the modelVersion.
240             // Return returnedAppSdkTopics = Map<Pair<App, Sdk>, Topic>
241             Map<Pair<String, String>, Topic> returnedAppSdkTopics =
242                     computeReturnedAppSdkTopics(callersCanLearnMap, appSdksUsageMap, topTopics);
243             sLogger.v("returnedAppSdkTopics size is  %d", returnedAppSdkTopics.size());
244 
245             // And persist the map to DB so that we can reuse later.
246             mTopicsDao.persistReturnedAppTopicsMap(currentEpochId, returnedAppSdkTopics);
247 
248             // Mark the transaction successful.
249             db.setTransactionSuccessful();
250         } finally {
251             db.endTransaction();
252             sLogger.d("End of Epoch Computation");
253         }
254     }
255 
256     /**
257      * Record the call from App and Sdk to usage history. This UsageHistory will be used to
258      * determine if a caller (app or sdk) has observed a topic before.
259      *
260      * @param app the app
261      * @param sdk the sdk of the app. In case the app calls the Topics API directly, the sdk ==
262      *     empty string.
263      */
recordUsageHistory(String app, String sdk)264     public void recordUsageHistory(String app, String sdk) {
265         long epochId = getCurrentEpochId();
266         // TODO(b/223159123): Do we need to filter out this log in prod build?
267         sLogger.v(
268                 "EpochManager.recordUsageHistory for current EpochId = %d for %s, %s",
269                 epochId, app, sdk);
270         mTopicsDao.recordUsageHistory(epochId, app, sdk);
271         mTopicsDao.recordAppUsageHistory(epochId, app);
272     }
273 
274     /**
275      * Determine the learn-ability of a topic to a certain caller.
276      *
277      * @param topic the topic to check the learn-ability
278      * @param caller the caller to check whether it can learn the given topic
279      * @param callersCanLearnMap the map that stores topic->caller mapping which shows a topic can
280      *     be learnt by a caller
281      * @param topTopics a {@link List} of top topics
282      * @param numberOfTopTopics number of regular topics in top topics
283      * @return a {@code boolean} that indicates if the caller can learn the topic
284      */
285     // TODO(b/236834213): Create a class for Top Topics
isTopicLearnableByCaller( @onNull Topic topic, @NonNull String caller, @NonNull Map<Topic, Set<String>> callersCanLearnMap, @NonNull List<Topic> topTopics, int numberOfTopTopics)286     public static boolean isTopicLearnableByCaller(
287             @NonNull Topic topic,
288             @NonNull String caller,
289             @NonNull Map<Topic, Set<String>> callersCanLearnMap,
290             @NonNull List<Topic> topTopics,
291             int numberOfTopTopics) {
292         // If a topic is the random topic in top topic list, it can be learnt by any caller.
293         int index = topTopics.lastIndexOf(topic);
294         // Regular top topics are placed in the front of the list. Topics after are random topics.
295         if (index >= numberOfTopTopics) {
296             return true;
297         }
298 
299         return callersCanLearnMap.containsKey(topic)
300                 && callersCanLearnMap.get(topic).contains(caller);
301     }
302 
303     /**
304      * Get the ID of current epoch.
305      *
306      * <p>The origin's timestamp is saved in the database. If the origin doesn't exist, it means the
307      * user never calls Topics API and the origin will be returned with -1. In this case, set
308      * current time as origin and persist it into database.
309      *
310      * @return a non-negative epoch ID of current epoch.
311      */
312     // TODO(b/237119788): Cache origin in cache manager.
313     // TODO(b/237119790): Set origin to sometime after midnight to get better maintenance timing.
getCurrentEpochId()314     public long getCurrentEpochId() {
315         long origin = mTopicsDao.retrieveEpochOrigin();
316         long currentTimeStamp = mClock.currentTimeMillis();
317         long epochJobPeriodsMs = mFlags.getTopicsEpochJobPeriodMs();
318 
319         // If origin doesn't exist in database, set current timestamp as origin.
320         if (origin == -1) {
321             origin = currentTimeStamp;
322             mTopicsDao.persistEpochOrigin(origin);
323             sLogger.d(
324                     "Origin isn't found! Set current time %s as origin.",
325                     Instant.ofEpochMilli(origin).toString());
326         }
327 
328         sLogger.v("Epoch length is  %d", epochJobPeriodsMs);
329         return (long) Math.floor((currentTimeStamp - origin) / (double) epochJobPeriodsMs);
330     }
331 
332     // Return a Map from Topic to set of App or Sdk that can learn about that topic.
333     // This is similar to the table Can Learn Topic in the explainer.
334     // Return Map<Topic, Set<Caller>>  where Caller = App or Sdk.
335     @VisibleForTesting
336     @NonNull
computeCallersCanLearnMap( @onNull Map<String, List<String>> appSdksUsageMap, @NonNull Map<String, List<Topic>> appClassificationTopicsMap)337     static Map<Topic, Set<String>> computeCallersCanLearnMap(
338             @NonNull Map<String, List<String>> appSdksUsageMap,
339             @NonNull Map<String, List<Topic>> appClassificationTopicsMap) {
340         Objects.requireNonNull(appSdksUsageMap);
341         Objects.requireNonNull(appClassificationTopicsMap);
342 
343         // Map from Topic to set of App or Sdk that can learn about that topic.
344         // This is similar to the table Can Learn Topic in the explainer.
345         // Map<Topic, Set<Caller>>  where Caller = App or Sdk.
346         Map<Topic, Set<String>> callersCanLearnMap = new HashMap<>();
347 
348         for (Map.Entry<String, List<Topic>> entry : appClassificationTopicsMap.entrySet()) {
349             String app = entry.getKey();
350             List<Topic> appTopics = entry.getValue();
351             if (appTopics == null) {
352                 sLogger.e("Can't find the Classification Topics for app = " + app);
353                 continue;
354             }
355 
356             for (Topic topic : appTopics) {
357                 if (!callersCanLearnMap.containsKey(topic)) {
358                     callersCanLearnMap.put(topic, new HashSet<>());
359                 }
360 
361                 // All SDKs in the app can learn this topic too.
362                 for (String sdk : appSdksUsageMap.get(app)) {
363                     if (TextUtils.isEmpty(sdk)) {
364                         // Empty sdk means the app called the Topics API directly.
365                         // Caller = app
366                         // Then the app can learn its topic.
367                         callersCanLearnMap.get(topic).add(app);
368                     } else {
369                         // Caller = sdk
370                         callersCanLearnMap.get(topic).add(sdk);
371                     }
372                 }
373             }
374         }
375 
376         return callersCanLearnMap;
377     }
378 
379     // Inputs:
380     // callersCanLearnMap = Map<Topic, Set<Caller>> map from topic to set of callers that can learn
381     // about the topic. Caller = App or Sdk.
382     // appSdksUsageMap = Map<App, List<SDK>> has the app and its SDKs that called Topics API
383     // in the current Epoch.
384     // topTopics = List<Topic> list of top 5 topics and 1 random topic.
385     //
386     // Return returnedAppSdkTopics = Map<Pair<App, Sdk>, Topic>
387     @VisibleForTesting
388     @NonNull
computeReturnedAppSdkTopics( @onNull Map<Topic, Set<String>> callersCanLearnMap, @NonNull Map<String, List<String>> appSdksUsageMap, @NonNull List<Topic> topTopics)389     Map<Pair<String, String>, Topic> computeReturnedAppSdkTopics(
390             @NonNull Map<Topic, Set<String>> callersCanLearnMap,
391             @NonNull Map<String, List<String>> appSdksUsageMap,
392             @NonNull List<Topic> topTopics) {
393         Map<Pair<String, String>, Topic> returnedAppSdkTopics = new HashMap<>();
394 
395         for (Map.Entry<String, List<String>> appSdks : appSdksUsageMap.entrySet()) {
396             Topic returnedTopic = selectRandomTopic(topTopics);
397             String app = appSdks.getKey();
398 
399             // Check if the app can learn this topic.
400             if (isTopicLearnableByCaller(
401                     returnedTopic,
402                     app,
403                     callersCanLearnMap,
404                     topTopics,
405                     mFlags.getTopicsNumberOfTopTopics())) {
406                 // The app calls Topics API directly. In this case, we set the sdk == empty string.
407                 returnedAppSdkTopics.put(Pair.create(app, /* empty Sdk */ ""), returnedTopic);
408                 // TODO(b/223159123): Do we need to filter out this log in prod build?
409                 sLogger.v(
410                         "CacheManager.computeReturnedAppSdkTopics. Topic %s is returned for"
411                                 + " %s",
412                         returnedTopic, app);
413             }
414 
415             // Then check all SDKs of the app.
416             for (String sdk : appSdks.getValue()) {
417                 if (isTopicLearnableByCaller(
418                         returnedTopic,
419                         sdk,
420                         callersCanLearnMap,
421                         topTopics,
422                         mFlags.getTopicsNumberOfTopTopics())) {
423                     returnedAppSdkTopics.put(Pair.create(app, sdk), returnedTopic);
424                     // TODO(b/223159123): Do we need to filter out this log in prod build?
425                     sLogger.v(
426                             "CacheManager.computeReturnedAppSdkTopics. Topic %s is returned"
427                                     + " for %s, %s",
428                             returnedTopic, app, sdk);
429                 }
430             }
431         }
432 
433         return returnedAppSdkTopics;
434     }
435 
436     // Return a random topics from the Top Topics.
437     // The Top Topics include the Top 5 Topics and one random topic from the Taxonomy.
438     @VisibleForTesting
439     @NonNull
selectRandomTopic(List<Topic> topTopics)440     Topic selectRandomTopic(List<Topic> topTopics) {
441         Preconditions.checkArgument(
442                 topTopics.size()
443                         == mFlags.getTopicsNumberOfTopTopics()
444                                 + mFlags.getTopicsNumberOfRandomTopics());
445         int random = mRandom.nextInt(100);
446 
447         // First random topic would be after numberOfTopTopics.
448         int randomTopicIndex = mFlags.getTopicsNumberOfTopTopics();
449         // For 5%, get the random topic.
450         if (random < mFlags.getTopicsPercentageForRandomTopic()) {
451             // The random topic is the last one on the list.
452             return topTopics.get(randomTopicIndex);
453         }
454 
455         // For 95%, pick randomly one out of first n top topics.
456         return topTopics.get(random % randomTopicIndex);
457     }
458 
459     // To garbage collect data for old epochs.
460     @VisibleForTesting
garbageCollectOutdatedEpochData(long currentEpochID)461     void garbageCollectOutdatedEpochData(long currentEpochID) {
462         int epochLookBackNumberForGarbageCollection = mFlags.getNumberOfEpochsToKeepInHistory();
463         // Assume current Epoch is T, and the earliest epoch should be kept is T-3
464         // Then any epoch data older than T-3-1 = T-4, including T-4 should be deleted.
465         long epochToDeleteFrom = currentEpochID - epochLookBackNumberForGarbageCollection - 1;
466         // To do garbage collection for each table
467         for (Pair<String, String> tableColumnPair : TABLE_INFO_FOR_EPOCH_GARBAGE_COLLECTION) {
468             mTopicsDao.deleteDataOfOldEpochs(
469                     tableColumnPair.first, tableColumnPair.second, epochToDeleteFrom);
470         }
471 
472         // In app installation, we need to assign topics to newly installed app-sdk caller. In order
473         // to check topic learnability of the sdk, CallerCanLearnTopicsContract needs to persist
474         // numberOfLookBackEpochs more epochs. For example, assume current epoch is T. In app
475         // installation, topics will be assigned to T-1, T-2 and T-3. In order to check learnability
476         // at Epoch T-3, we need to check CallerCanLearnTopicsContract of epoch T-4, T-5 and T-6.
477         long epochToDeleteFromForCallerCanLearn =
478                 currentEpochID - epochLookBackNumberForGarbageCollection * 2L - 1;
479         mTopicsDao.deleteDataOfOldEpochs(
480                 TopicsTables.CallerCanLearnTopicsContract.TABLE,
481                 TopicsTables.CallerCanLearnTopicsContract.EPOCH_ID,
482                 epochToDeleteFromForCallerCanLearn);
483     }
484 
485     // Compute the mapping of topic to its contributor apps. In an epoch, an app is a contributor to
486     // a topic if the app has called Topics API in this epoch and is classified to the topic. Only
487     // computed for top topics.
488     @VisibleForTesting
computeTopTopicsToContributorsMap( @onNull Map<String, List<Topic>> appClassificationTopicsMap, @NonNull List<Topic> topTopics)489     Map<Integer, Set<String>> computeTopTopicsToContributorsMap(
490             @NonNull Map<String, List<Topic>> appClassificationTopicsMap,
491             @NonNull List<Topic> topTopics) {
492         Map<Integer, Set<String>> topicToContributorMap = new HashMap<>();
493 
494         for (Map.Entry<String, List<Topic>> appTopics : appClassificationTopicsMap.entrySet()) {
495             String app = appTopics.getKey();
496 
497             for (Topic topic : appTopics.getValue()) {
498                 // Only compute for top topics.
499                 if (topTopics.contains(topic)) {
500                     int topicId = topic.getTopic();
501                     topicToContributorMap.putIfAbsent(topicId, new HashSet<>());
502                     topicToContributorMap.get(topicId).add(app);
503                 }
504             }
505         }
506 
507         // At last, check whether there is any top topics without contributors. If so, annotate it
508         // with PADDED_TOP_TOPICS_STRING in the map. See PADDED_TOP_TOPICS_STRING for more details.
509         for (int i = 0; i < mFlags.getTopicsNumberOfTopTopics(); i++) {
510             Topic topTopic = topTopics.get(i);
511             topicToContributorMap.putIfAbsent(
512                     topTopic.getTopic(), Set.of(PADDED_TOP_TOPICS_STRING));
513         }
514 
515         return topicToContributorMap;
516     }
517 
dump(@onNull PrintWriter writer, @Nullable String[] args)518     public void dump(@NonNull PrintWriter writer, @Nullable String[] args) {
519         writer.println("==== EpochManager Dump ====");
520         long epochId = getCurrentEpochId();
521         writer.println(String.format("Current epochId is %d", epochId));
522     }
523 }
524