github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/kbfs/libpages/stats_mysql.go (about)

     1  // Copyright 2020 Keybase Inc. All rights reserved.
     2  // Use of this source code is governed by a BSD
     3  // license that can be found in the LICENSE file.
     4  
     5  package libpages
     6  
     7  import (
     8  	"context"
     9  	"database/sql"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/keybase/client/go/kbfs/data"
    14  	"github.com/keybase/client/go/kbfs/libkbfs"
    15  	"github.com/keybase/client/go/kbfs/tlf"
    16  	"go.uber.org/zap"
    17  )
    18  
    19  type mysqlActivityStatsStorer struct {
    20  	logger *zap.Logger
    21  	db     *sql.DB
    22  	clock  libkbfs.Clock
    23  
    24  	lock  sync.Mutex
    25  	tlfs  map[tlf.ID]time.Time
    26  	hosts map[string]time.Time
    27  }
    28  
    29  func newMySQLActivityStatsStorerNoStart(clock libkbfs.Clock,
    30  	db *sql.DB, logger *zap.Logger) *mysqlActivityStatsStorer {
    31  	return &mysqlActivityStatsStorer{
    32  		logger: logger,
    33  		db:     db,
    34  		clock:  clock,
    35  		tlfs:   make(map[tlf.ID]time.Time),
    36  		hosts:  make(map[string]time.Time),
    37  	}
    38  }
    39  
    40  // NewMySQLActivityStatsStorer creates an ActivityStatsStorer that stores
    41  // activities on a MySQL database.
    42  func NewMySQLActivityStatsStorer(
    43  	db *sql.DB, logger *zap.Logger) ActivityStatsStorer {
    44  	s := newMySQLActivityStatsStorerNoStart(data.WallClock{}, db, logger)
    45  	// TODO shutdown()
    46  	go s.insertLoop(context.Background())
    47  	return s
    48  }
    49  
    50  func (s *mysqlActivityStatsStorer) createTablesIfNotExists(
    51  	ctx context.Context) (err error) {
    52  	if _, err = s.db.ExecContext(ctx, `
    53          CREATE TABLE IF NOT EXISTS stats_tlf (
    54            id          bigint unsigned NOT NULL AUTO_INCREMENT,
    55            tlf_id      char(32)        NOT NULL,
    56            active_time datetime(3)     NOT NULL,
    57  
    58            PRIMARY KEY                 (id),
    59            UNIQUE KEY  idx_tlf_id      (tlf_id),
    60            KEY         idx_active_time (active_time)
    61          ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    62      `); err != nil {
    63  		return err
    64  	}
    65  	if _, err = s.db.ExecContext(ctx, `
    66          CREATE TABLE IF NOT EXISTS stats_host (
    67            id          bigint unsigned NOT NULL AUTO_INCREMENT,
    68            -- max key length is 767. floor(767/4)==191
    69            domain      varchar(191)    NOT NULL, 
    70            active_time datetime(3)     NOT NULL,
    71  
    72            PRIMARY KEY                 (id),
    73            UNIQUE KEY  idx_domain      (domain),
    74            KEY         idx_active_time (active_time)
    75          ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    76      `); err != nil {
    77  		return err
    78  	}
    79  
    80  	return nil
    81  }
    82  
    83  const mysqlStatFlushTimeout = time.Minute / 2
    84  
    85  func (s *mysqlActivityStatsStorer) flushInserts() {
    86  	s.lock.Lock()
    87  	tlfs := s.tlfs
    88  	hosts := s.hosts
    89  	s.tlfs = make(map[tlf.ID]time.Time)
    90  	s.hosts = make(map[string]time.Time)
    91  	s.lock.Unlock()
    92  
    93  	ctx, cancel := context.WithTimeout(context.Background(), mysqlStatFlushTimeout)
    94  	defer cancel()
    95  
    96  	for tlfID, t := range tlfs {
    97  		if _, err := s.db.ExecContext(ctx, `
    98              INSERT INTO stats_tlf (tlf_id, active_time)
    99                  VALUES (?, ?)
   100              ON DUPLICATE KEY UPDATE
   101                  active_time = GREATEST(active_time, ?)`,
   102  			tlfID.String(), t, t); err != nil {
   103  			s.logger.Warn("INSERT INTO stats_tlf", zap.Error(err))
   104  		}
   105  	}
   106  	for host, t := range hosts {
   107  		if _, err := s.db.ExecContext(ctx, `
   108              INSERT INTO stats_host (domain, active_time)
   109                  VALUES (?, ?) 
   110              ON DUPLICATE KEY UPDATE
   111                  active_time = GREATEST(active_time, ?)`,
   112  			host, t, t); err != nil {
   113  			s.logger.Warn("INSERT INTO stats_host", zap.Error(err))
   114  		}
   115  	}
   116  }
   117  
   118  func (s *mysqlActivityStatsStorer) getActiveTlfs(
   119  	ctx context.Context, since time.Time) (int, error) {
   120  	var count int
   121  	if err := s.db.QueryRowContext(ctx,
   122  		"SELECT COUNT(*) FROM stats_tlf where active_time > ?",
   123  		since).Scan(&count); err != nil {
   124  		return 0, err
   125  	}
   126  
   127  	return count, nil
   128  }
   129  
   130  func (s *mysqlActivityStatsStorer) getActiveHosts(
   131  	ctx context.Context, since time.Time) (int, error) {
   132  	var count int
   133  	if err := s.db.QueryRowContext(ctx,
   134  		"SELECT COUNT(*) FROM stats_host where active_time > ?",
   135  		since).Scan(&count); err != nil {
   136  		return 0, err
   137  	}
   138  
   139  	return count, nil
   140  }
   141  
   142  func (s *mysqlActivityStatsStorer) stageInserts(tlfID tlf.ID, host string) {
   143  	s.lock.Lock()
   144  	defer s.lock.Unlock()
   145  	s.tlfs[tlfID] = s.clock.Now()
   146  	s.hosts[host] = s.clock.Now()
   147  }
   148  
   149  const mysqlStatInsertInterval = 4 * time.Second
   150  
   151  func (s *mysqlActivityStatsStorer) insertLoop(ctx context.Context) {
   152  	ticker := time.NewTicker(mysqlStatInsertInterval)
   153  	for {
   154  		select {
   155  		case <-ticker.C:
   156  			s.flushInserts()
   157  		case <-ctx.Done():
   158  			return
   159  		}
   160  	}
   161  }
   162  
   163  // RecordActives implement the ActivityStatsStorer interface.
   164  func (s *mysqlActivityStatsStorer) RecordActives(tlfID tlf.ID, host string) {
   165  	s.stageInserts(tlfID, host)
   166  }
   167  
   168  const mysqlGetActivesTimeout = 4 * time.Second
   169  
   170  // RecordActives implement the ActivesGetter interface.
   171  func (s *mysqlActivityStatsStorer) GetActives(dur time.Duration) (
   172  	activeTlfs int, activeHosts int, err error) {
   173  	ctx, cancel := context.WithTimeout(context.Background(), mysqlGetActivesTimeout)
   174  	defer cancel()
   175  	since := s.clock.Now().Add(-dur)
   176  	if activeTlfs, err = s.getActiveTlfs(ctx, since); err != nil {
   177  		return 0, 0, err
   178  	}
   179  	if activeHosts, err = s.getActiveHosts(ctx, since); err != nil {
   180  		return 0, 0, err
   181  	}
   182  	return activeTlfs, activeHosts, nil
   183  }
   184  
   185  // GetActivesGetter implement the ActivityStatsStorer interface.
   186  func (s *mysqlActivityStatsStorer) GetActivesGetter() (ActivesGetter, error) {
   187  	return s, nil
   188  }