Autopsy  4.6.0
Graphical digital forensics platform for The Sleuth Kit and other tools.
EnterpriseHealthMonitor.java
Go to the documentation of this file.
1 /*
2  * Autopsy Forensic Browser
3  *
4  * Copyright 2018 Basis Technology Corp.
5  * Contact: carrier <at> sleuthkit <dot> org
6  *
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  * http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  */
19 package org.sleuthkit.autopsy.healthmonitor;
20 
21 import com.google.common.util.concurrent.ThreadFactoryBuilder;
22 import java.beans.PropertyChangeEvent;
23 import java.beans.PropertyChangeListener;
24 import java.sql.Connection;
25 import java.sql.DriverManager;
26 import java.sql.PreparedStatement;
27 import java.sql.ResultSet;
28 import java.sql.SQLException;
29 import java.sql.Statement;
30 import java.util.Map;
31 import java.util.List;
32 import java.util.HashMap;
33 import java.util.UUID;
34 import java.util.concurrent.ExecutorService;
35 import java.util.concurrent.Executors;
36 import java.util.concurrent.ScheduledThreadPoolExecutor;
37 import java.util.concurrent.TimeUnit;
38 import java.util.concurrent.atomic.AtomicBoolean;
39 import java.util.logging.Level;
40 import org.apache.commons.dbcp2.BasicDataSource;
49 import org.sleuthkit.datamodel.CaseDbConnectionInfo;
50 import org.sleuthkit.datamodel.CaseDbSchemaVersionNumber;
51 import org.sleuthkit.datamodel.Image;
52 import org.sleuthkit.datamodel.SleuthkitCase;
53 import org.sleuthkit.datamodel.TskCoreException;
54 
55 
63 public final class EnterpriseHealthMonitor implements PropertyChangeListener {
64 
65  private final static Logger logger = Logger.getLogger(EnterpriseHealthMonitor.class.getName());
66  private final static String DATABASE_NAME = "EnterpriseHealthMonitor";
67  private final static String MODULE_NAME = "EnterpriseHealthMonitor";
68  private final static String IS_ENABLED_KEY = "is_enabled";
69  private final static long DATABASE_WRITE_INTERVAL = 60; // Minutes
70  public static final CaseDbSchemaVersionNumber CURRENT_DB_SCHEMA_VERSION
71  = new CaseDbSchemaVersionNumber(1, 0);
72 
73  private static final AtomicBoolean isEnabled = new AtomicBoolean(false);
75 
76  private final ExecutorService healthMonitorExecutor;
77  private static final String HEALTH_MONITOR_EVENT_THREAD_NAME = "Health-Monitor-Event-Listener-%d";
78 
79  private ScheduledThreadPoolExecutor healthMonitorOutputTimer;
80  private final Map<String, TimingInfo> timingInfoMap;
81  private static final int CONN_POOL_SIZE = 10;
82  private BasicDataSource connectionPool = null;
83  private String hostName;
84 
85  private EnterpriseHealthMonitor() throws HealthMonitorException {
86 
87  // Create the map to collect timing metrics. The map will exist regardless
88  // of whether the monitor is enabled.
89  timingInfoMap = new HashMap<>();
90 
91  // Set up the executor to handle case events
92  healthMonitorExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat(HEALTH_MONITOR_EVENT_THREAD_NAME).build());
93 
94  // Get the host name
95  try {
96  hostName = java.net.InetAddress.getLocalHost().getHostName();
97  } catch (java.net.UnknownHostException ex) {
98  // Continue on, but log the error and generate a UUID to use for this session
99  hostName = UUID.randomUUID().toString();
100  logger.log(Level.SEVERE, "Unable to look up host name - falling back to UUID " + hostName, ex);
101  }
102 
103  // Read from module settings to determine if the module is enabled
104  if (ModuleSettings.settingExists(MODULE_NAME, IS_ENABLED_KEY)) {
105  if(ModuleSettings.getConfigSetting(MODULE_NAME, IS_ENABLED_KEY).equals("true")){
106  isEnabled.set(true);
107  try {
108  activateMonitor();
109  } catch (HealthMonitorException ex) {
110  // If we failed to activate it, then disable the monitor
111  logger.log(Level.SEVERE, "Health monitor activation failed - disabling health monitor");
112  setEnabled(false);
113  throw ex;
114  }
115  return;
116  }
117  }
118  isEnabled.set(false);
119  }
120 
126  synchronized static EnterpriseHealthMonitor getInstance() throws HealthMonitorException {
127  if (instance == null) {
128  instance = new EnterpriseHealthMonitor();
130  }
131  return instance;
132  }
133 
140  private synchronized void activateMonitor() throws HealthMonitorException {
141 
142  logger.log(Level.INFO, "Activating Servies Health Monitor");
143 
145  throw new HealthMonitorException("Multi user mode is not enabled - can not activate health monitor");
146  }
147 
148  // Set up database (if needed)
150  if(lock == null) {
151  throw new HealthMonitorException("Error getting database lock");
152  }
153 
154  // Check if the database exists
155  if (! databaseExists()) {
156 
157  // If not, create a new one
158  createDatabase();
159  }
160 
161  if( ! databaseIsInitialized()) {
163  }
164 
166  throw new HealthMonitorException("Error releasing database lock", ex);
167  }
168 
169  // Clear out any old data
170  timingInfoMap.clear();
171 
172  // Start the timer for database writes
173  startTimer();
174  }
175 
183  private synchronized void deactivateMonitor() throws HealthMonitorException {
184 
185  logger.log(Level.INFO, "Deactivating Servies Health Monitor");
186 
187  // Clear out the collected data
188  timingInfoMap.clear();
189 
190  // Stop the timer
191  stopTimer();
192 
193  // Shut down the connection pool
195  }
196 
200  private synchronized void startTimer() {
201  // Make sure the previous executor (if it exists) has been stopped
202  stopTimer();
203 
204  healthMonitorOutputTimer = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder().setNameFormat("health_monitor_timer").build());
205  healthMonitorOutputTimer.scheduleWithFixedDelay(new DatabaseWriteTask(), DATABASE_WRITE_INTERVAL, DATABASE_WRITE_INTERVAL, TimeUnit.MINUTES);
206  }
207 
211  private synchronized void stopTimer() {
212  if(healthMonitorOutputTimer != null) {
213  ThreadUtils.shutDownTaskExecutor(healthMonitorOutputTimer);
214  }
215  }
216 
221  static synchronized void startUpIfEnabled() throws HealthMonitorException {
222  getInstance();
223  }
224 
230  static synchronized void setEnabled(boolean enabled) throws HealthMonitorException {
231  if(enabled == isEnabled.get()) {
232  // The setting has not changed, so do nothing
233  return;
234  }
235 
236  if(enabled) {
237  getInstance().activateMonitor();
238 
239  // If activateMonitor fails, we won't update either of these
240  ModuleSettings.setConfigSetting(MODULE_NAME, IS_ENABLED_KEY, "true");
241  isEnabled.set(true);
242  } else {
243  ModuleSettings.setConfigSetting(MODULE_NAME, IS_ENABLED_KEY, "false");
244  isEnabled.set(false);
245  getInstance().deactivateMonitor();
246  }
247  }
248 
259  public static TimingMetric getTimingMetric(String name) {
260  if(isEnabled.get()) {
261  return new TimingMetric(name);
262  }
263  return null;
264  }
265 
273  public static void submitTimingMetric(TimingMetric metric) {
274  if(isEnabled.get() && (metric != null)) {
275  metric.stopTiming();
276  try {
277  getInstance().addTimingMetric(metric);
278  } catch (HealthMonitorException ex) {
279  // We don't want calling methods to have to check for exceptions, so just log it
280  logger.log(Level.SEVERE, "Error adding timing metric", ex);
281  }
282  }
283  }
284 
294  public static void submitNormalizedTimingMetric(TimingMetric metric, long normalization) {
295  if(isEnabled.get() && (metric != null)) {
296  metric.stopTiming();
297  try {
298  metric.normalize(normalization);
299  getInstance().addTimingMetric(metric);
300  } catch (HealthMonitorException ex) {
301  // We don't want calling methods to have to check for exceptions, so just log it
302  logger.log(Level.SEVERE, "Error adding timing metric", ex);
303  }
304  }
305  }
306 
311  private void addTimingMetric(TimingMetric metric) throws HealthMonitorException {
312 
313  // Do as little as possible within the synchronized block to minimize
314  // blocking with multiple threads.
315  synchronized(this) {
316  // There's a small check-then-act situation here where isEnabled
317  // may have changed before reaching this code. This is fine -
318  // the map still exists and any extra data added after the monitor
319  // is disabled will be deleted if the monitor is re-enabled. This
320  // seems preferable to doing another check on isEnabled within
321  // the synchronized block.
322  if(timingInfoMap.containsKey(metric.getName())) {
323  timingInfoMap.get(metric.getName()).addMetric(metric);
324  } else {
325  timingInfoMap.put(metric.getName(), new TimingInfo(metric));
326  }
327  }
328  }
329 
339  private void performDatabaseQuery() throws HealthMonitorException {
340  try {
341  SleuthkitCase skCase = Case.getOpenCase().getSleuthkitCase();
342  TimingMetric metric = EnterpriseHealthMonitor.getTimingMetric("Database: getImages query");
343  List<Image> images = skCase.getImages();
344 
345  // Through testing we found that this normalization gives us fairly
346  // consistent results for different numbers of data sources.
347  long normalization = images.size();
348  if (images.isEmpty()) {
349  normalization += 2;
350  } else if (images.size() == 1){
351  normalization += 3;
352  } else if (images.size() < 10) {
353  normalization += 5;
354  } else {
355  normalization += 7;
356  }
357 
359  } catch (NoCurrentCaseException ex) {
360  // If there's no case open, we just can't do the metrics.
361  } catch (TskCoreException ex) {
362  throw new HealthMonitorException("Error running getImages()", ex);
363  }
364  }
365 
370  private void gatherTimerBasedMetrics() throws HealthMonitorException {
371  // Time a database query
373  }
374 
379  private void writeCurrentStateToDatabase() throws HealthMonitorException {
380 
381  Map<String, TimingInfo> timingMapCopy;
382 
383  // Do as little as possible within the synchronized block since it will
384  // block threads attempting to record metrics.
385  synchronized(this) {
386  if(! isEnabled.get()) {
387  return;
388  }
389 
390  // Make a shallow copy of the timing map. The map should be small - one entry
391  // per metric name.
392  timingMapCopy = new HashMap<>(timingInfoMap);
393  timingInfoMap.clear();
394  }
395 
396  // Check if there's anything to report (right now we only have the timing map)
397  if(timingMapCopy.keySet().isEmpty()) {
398  return;
399  }
400 
401  logger.log(Level.INFO, "Writing health monitor metrics to database");
402 
403  // Write to the database
404  try (CoordinationService.Lock lock = getSharedDbLock()) {
405  if(lock == null) {
406  throw new HealthMonitorException("Error getting database lock");
407  }
408 
409  Connection conn = connect();
410  if(conn == null) {
411  throw new HealthMonitorException("Error getting database connection");
412  }
413 
414  // Add timing metrics to the database
415  String addTimingInfoSql = "INSERT INTO timing_data (name, host, timestamp, count, average, max, min) VALUES (?, ?, ?, ?, ?, ?, ?)";
416  try (PreparedStatement statement = conn.prepareStatement(addTimingInfoSql)) {
417 
418  for(String name:timingMapCopy.keySet()) {
419  TimingInfo info = timingMapCopy.get(name);
420 
421  statement.setString(1, name);
422  statement.setString(2, hostName);
423  statement.setLong(3, System.currentTimeMillis());
424  statement.setLong(4, info.getCount());
425  statement.setDouble(5, info.getAverage());
426  statement.setDouble(6, info.getMax());
427  statement.setDouble(7, info.getMin());
428 
429  statement.execute();
430  }
431 
432  } catch (SQLException ex) {
433  throw new HealthMonitorException("Error saving metric data to database", ex);
434  } finally {
435  try {
436  conn.close();
437  } catch (SQLException ex) {
438  logger.log(Level.SEVERE, "Error closing Connection.", ex);
439  }
440  }
442  throw new HealthMonitorException("Error releasing database lock", ex);
443  }
444  }
445 
452  private boolean databaseExists() throws HealthMonitorException {
453  try {
454  // Use the same database settings as the case
455  CaseDbConnectionInfo db = UserPreferences.getDatabaseConnectionInfo();
456  Class.forName("org.postgresql.Driver"); //NON-NLS
457  ResultSet rs = null;
458  try (Connection connection = DriverManager.getConnection("jdbc:postgresql://" + db.getHost() + ":" + db.getPort() + "/postgres", db.getUserName(), db.getPassword()); //NON-NLS
459  Statement statement = connection.createStatement();) {
460  String createCommand = "SELECT 1 AS result FROM pg_database WHERE datname='" + DATABASE_NAME + "'";
461  rs = statement.executeQuery(createCommand);
462  if(rs.next()) {
463  logger.log(Level.INFO, "Existing Enterprise Health Monitor database found");
464  return true;
465  }
466  } finally {
467  if(rs != null) {
468  rs.close();
469  }
470  }
471  } catch (UserPreferencesException | ClassNotFoundException | SQLException ex) {
472  throw new HealthMonitorException("Failed check for health monitor database", ex);
473  }
474  return false;
475  }
476 
481  private void createDatabase() throws HealthMonitorException {
482  try {
483  // Use the same database settings as the case
484  CaseDbConnectionInfo db = UserPreferences.getDatabaseConnectionInfo();
485  Class.forName("org.postgresql.Driver"); //NON-NLS
486  try (Connection connection = DriverManager.getConnection("jdbc:postgresql://" + db.getHost() + ":" + db.getPort() + "/postgres", db.getUserName(), db.getPassword()); //NON-NLS
487  Statement statement = connection.createStatement();) {
488  String createCommand = "CREATE DATABASE \"" + DATABASE_NAME + "\" OWNER \"" + db.getUserName() + "\""; //NON-NLS
489  statement.execute(createCommand);
490  }
491  logger.log(Level.INFO, "Created new health monitor database " + DATABASE_NAME);
492  } catch (UserPreferencesException | ClassNotFoundException | SQLException ex) {
493  throw new HealthMonitorException("Failed to delete health monitor database", ex);
494  }
495  }
496 
501  private void setupConnectionPool() throws HealthMonitorException {
502  try {
503  CaseDbConnectionInfo db = UserPreferences.getDatabaseConnectionInfo();
504 
505  connectionPool = new BasicDataSource();
506  connectionPool.setDriverClassName("org.postgresql.Driver");
507 
508  StringBuilder connectionURL = new StringBuilder();
509  connectionURL.append("jdbc:postgresql://");
510  connectionURL.append(db.getHost());
511  connectionURL.append(":");
512  connectionURL.append(db.getPort());
513  connectionURL.append("/");
514  connectionURL.append(DATABASE_NAME);
515 
516  connectionPool.setUrl(connectionURL.toString());
517  connectionPool.setUsername(db.getUserName());
518  connectionPool.setPassword(db.getPassword());
519 
520  // tweak pool configuration
521  connectionPool.setInitialSize(3); // start with 3 connections
522  connectionPool.setMaxIdle(CONN_POOL_SIZE); // max of 10 idle connections
523  connectionPool.setValidationQuery("SELECT version()");
524  } catch (UserPreferencesException ex) {
525  throw new HealthMonitorException("Error loading database configuration", ex);
526  }
527  }
528 
533  private void shutdownConnections() throws HealthMonitorException {
534  try {
535  synchronized(this) {
536  if(connectionPool != null){
537  connectionPool.close();
538  connectionPool = null; // force it to be re-created on next connect()
539  }
540  }
541  } catch (SQLException ex) {
542  throw new HealthMonitorException("Failed to close existing database connections.", ex); // NON-NLS
543  }
544  }
545 
552  private Connection connect() throws HealthMonitorException {
553  synchronized (this) {
554  if (connectionPool == null) {
556  }
557  }
558 
559  try {
560  return connectionPool.getConnection();
561  } catch (SQLException ex) {
562  throw new HealthMonitorException("Error getting connection from connection pool.", ex); // NON-NLS
563  }
564  }
565 
572  private boolean databaseIsInitialized() throws HealthMonitorException {
573  Connection conn = connect();
574  if(conn == null) {
575  throw new HealthMonitorException("Error getting database connection");
576  }
577  ResultSet resultSet = null;
578 
579  try (Statement statement = conn.createStatement()) {
580  resultSet = statement.executeQuery("SELECT value FROM db_info WHERE name='SCHEMA_VERSION'");
581  return resultSet.next();
582  } catch (SQLException ex) {
583  // This likely just means that the db_info table does not exist
584  return false;
585  } finally {
586  if(resultSet != null) {
587  try {
588  resultSet.close();
589  } catch (SQLException ex) {
590  logger.log(Level.SEVERE, "Error closing result set", ex);
591  }
592  }
593  try {
594  conn.close();
595  } catch (SQLException ex) {
596  logger.log(Level.SEVERE, "Error closing Connection.", ex);
597  }
598  }
599  }
600 
606  private CaseDbSchemaVersionNumber getVersion() throws HealthMonitorException {
607  Connection conn = connect();
608  if(conn == null) {
609  throw new HealthMonitorException("Error getting database connection");
610  }
611  ResultSet resultSet = null;
612 
613  try (Statement statement = conn.createStatement()) {
614  int minorVersion = 0;
615  int majorVersion = 0;
616  resultSet = statement.executeQuery("SELECT value FROM db_info WHERE name='SCHEMA_MINOR_VERSION'");
617  if (resultSet.next()) {
618  String minorVersionStr = resultSet.getString("value");
619  try {
620  minorVersion = Integer.parseInt(minorVersionStr);
621  } catch (NumberFormatException ex) {
622  throw new HealthMonitorException("Bad value for schema minor version (" + minorVersionStr + ") - database is corrupt");
623  }
624  }
625 
626  resultSet = statement.executeQuery("SELECT value FROM db_info WHERE name='SCHEMA_VERSION'");
627  if (resultSet.next()) {
628  String majorVersionStr = resultSet.getString("value");
629  try {
630  majorVersion = Integer.parseInt(majorVersionStr);
631  } catch (NumberFormatException ex) {
632  throw new HealthMonitorException("Bad value for schema version (" + majorVersionStr + ") - database is corrupt");
633  }
634  }
635 
636  return new CaseDbSchemaVersionNumber(majorVersion, minorVersion);
637  } catch (SQLException ex) {
638  throw new HealthMonitorException("Error initializing database", ex);
639  } finally {
640  if(resultSet != null) {
641  try {
642  resultSet.close();
643  } catch (SQLException ex) {
644  logger.log(Level.SEVERE, "Error closing result set", ex);
645  }
646  }
647  try {
648  conn.close();
649  } catch (SQLException ex) {
650  logger.log(Level.SEVERE, "Error closing Connection.", ex);
651  }
652  }
653  }
654 
659  private void initializeDatabaseSchema() throws HealthMonitorException {
660  Connection conn = connect();
661  if(conn == null) {
662  throw new HealthMonitorException("Error getting database connection");
663  }
664 
665  try (Statement statement = conn.createStatement()) {
666  conn.setAutoCommit(false);
667 
668  String createTimingTable =
669  "CREATE TABLE IF NOT EXISTS timing_data (" +
670  "id SERIAL PRIMARY KEY," +
671  "name text NOT NULL," +
672  "host text NOT NULL," +
673  "timestamp bigint NOT NULL," +
674  "count bigint NOT NULL," +
675  "average double precision NOT NULL," +
676  "max double precision NOT NULL," +
677  "min double precision NOT NULL" +
678  ")";
679  statement.execute(createTimingTable);
680 
681  String createDbInfoTable =
682  "CREATE TABLE IF NOT EXISTS db_info (" +
683  "id SERIAL PRIMARY KEY NOT NULL," +
684  "name text NOT NULL," +
685  "value text NOT NULL" +
686  ")";
687  statement.execute(createDbInfoTable);
688 
689  statement.execute("INSERT INTO db_info (name, value) VALUES ('SCHEMA_VERSION', '" + CURRENT_DB_SCHEMA_VERSION.getMajor() + "')");
690  statement.execute("INSERT INTO db_info (name, value) VALUES ('SCHEMA_MINOR_VERSION', '" + CURRENT_DB_SCHEMA_VERSION.getMinor() + "')");
691 
692  conn.commit();
693  } catch (SQLException ex) {
694  try {
695  conn.rollback();
696  } catch (SQLException ex2) {
697  logger.log(Level.SEVERE, "Rollback error");
698  }
699  throw new HealthMonitorException("Error initializing database", ex);
700  } finally {
701  try {
702  conn.close();
703  } catch (SQLException ex) {
704  logger.log(Level.SEVERE, "Error closing connection.", ex);
705  }
706  }
707  }
708 
713  static final class DatabaseWriteTask implements Runnable {
714 
718  @Override
719  public void run() {
720  try {
721  getInstance().gatherTimerBasedMetrics();
722  getInstance().writeCurrentStateToDatabase();
723  } catch (HealthMonitorException ex) {
724  logger.log(Level.SEVERE, "Error writing current metrics to database", ex); //NON-NLS
725  }
726  }
727  }
728 
729  @Override
730  public void propertyChange(PropertyChangeEvent evt) {
731 
732  switch (Case.Events.valueOf(evt.getPropertyName())) {
733 
734  case CURRENT_CASE:
735  if ((null == evt.getNewValue()) && (evt.getOldValue() instanceof Case)) {
736  // When a case is closed, write the current metrics to the database
737  healthMonitorExecutor.submit(new EnterpriseHealthMonitor.DatabaseWriteTask());
738  }
739  break;
740  }
741  }
742 
749  private CoordinationService.Lock getExclusiveDbLock() throws HealthMonitorException{
750  try {
752 
753  if(lock != null){
754  return lock;
755  }
756  throw new HealthMonitorException("Error acquiring database lock");
757  } catch (InterruptedException | CoordinationService.CoordinationServiceException ex){
758  throw new HealthMonitorException("Error acquiring database lock", ex);
759  }
760  }
761 
768  private CoordinationService.Lock getSharedDbLock() throws HealthMonitorException{
769  try {
770  String databaseNodeName = DATABASE_NAME;
772 
773  if(lock != null){
774  return lock;
775  }
776  throw new HealthMonitorException("Error acquiring database lock");
777  } catch (InterruptedException | CoordinationService.CoordinationServiceException ex){
778  throw new HealthMonitorException("Error acquiring database lock");
779  }
780  }
781 
790  private class TimingInfo {
791  private long count; // Number of metrics collected
792  private double sum; // Sum of the durations collected (nanoseconds)
793  private double max; // Maximum value found (nanoseconds)
794  private double min; // Minimum value found (nanoseconds)
795 
796  TimingInfo(TimingMetric metric) throws HealthMonitorException {
797  count = 1;
798  sum = metric.getDuration();
799  max = metric.getDuration();
800  min = metric.getDuration();
801  }
802 
810  void addMetric(TimingMetric metric) throws HealthMonitorException {
811 
812  // Keep track of needed info to calculate the average
813  count++;
814  sum += metric.getDuration();
815 
816  // Check if this is the longest duration seen
817  if(max < metric.getDuration()) {
818  max = metric.getDuration();
819  }
820 
821  // Check if this is the lowest duration seen
822  if(min > metric.getDuration()) {
823  min = metric.getDuration();
824  }
825  }
826 
831  double getAverage() {
832  return sum / count;
833  }
834 
839  double getMax() {
840  return max;
841  }
842 
847  double getMin() {
848  return min;
849  }
850 
855  long getCount() {
856  return count;
857  }
858  }
859 }
static CaseDbConnectionInfo getDatabaseConnectionInfo()
static void shutDownTaskExecutor(ExecutorService executor)
static void submitNormalizedTimingMetric(TimingMetric metric, long normalization)
Lock tryGetExclusiveLock(CategoryNode category, String nodePath, int timeOut, TimeUnit timeUnit)
static void addPropertyChangeListener(PropertyChangeListener listener)
Definition: Case.java:383
static String getConfigSetting(String moduleName, String settingName)
synchronized static Logger getLogger(String name)
Definition: Logger.java:124
Lock tryGetSharedLock(CategoryNode category, String nodePath, int timeOut, TimeUnit timeUnit)
static boolean settingExists(String moduleName, String settingName)

Copyright © 2012-2016 Basis Technology. Generated on: Mon May 7 2018
This work is licensed under a Creative Commons Attribution-Share Alike 3.0 United States License.