github.com/keybase/client/go@v0.0.0-20241007131713-f10651d043c8/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 }