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