github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/avatars/fullcaching.go (about) 1 package avatars 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/url" 9 "os" 10 "path/filepath" 11 "sync" 12 "time" 13 14 "github.com/keybase/client/go/libkb" 15 "github.com/keybase/client/go/lru" 16 "github.com/keybase/client/go/protocol/keybase1" 17 ) 18 19 type avatarLoadPair struct { 20 name string 21 format keybase1.AvatarFormat 22 path string 23 remoteURL *string 24 } 25 26 type avatarLoadSpec struct { 27 hits []avatarLoadPair 28 misses []avatarLoadPair 29 stales []avatarLoadPair 30 } 31 32 func (a avatarLoadSpec) details(l []avatarLoadPair) (names []string, formats []keybase1.AvatarFormat) { 33 fmap := make(map[keybase1.AvatarFormat]bool) 34 umap := make(map[string]bool) 35 for _, m := range l { 36 umap[m.name] = true 37 fmap[m.format] = true 38 } 39 for u := range umap { 40 names = append(names, u) 41 } 42 for f := range fmap { 43 formats = append(formats, f) 44 } 45 return names, formats 46 } 47 48 func (a avatarLoadSpec) missDetails() ([]string, []keybase1.AvatarFormat) { 49 return a.details(a.misses) 50 } 51 52 func (a avatarLoadSpec) staleDetails() ([]string, []keybase1.AvatarFormat) { 53 return a.details(a.stales) 54 } 55 56 func (a avatarLoadSpec) staleKnownURL(name string, format keybase1.AvatarFormat) *string { 57 for _, stale := range a.stales { 58 if stale.name == name && stale.format == format { 59 return stale.remoteURL 60 } 61 } 62 return nil 63 } 64 65 type populateArg struct { 66 name string 67 format keybase1.AvatarFormat 68 url keybase1.AvatarUrl 69 } 70 71 type remoteFetchArg struct { 72 names []string 73 formats []keybase1.AvatarFormat 74 cb chan keybase1.LoadAvatarsRes 75 errCb chan error 76 } 77 78 type lruEntry struct { 79 Path string 80 URL *string 81 } 82 83 func (l lruEntry) GetPath() string { 84 return l.Path 85 } 86 87 type FullCachingSource struct { 88 libkb.Contextified 89 sync.Mutex 90 started bool 91 diskLRU *lru.DiskLRU 92 diskLRUCleanerCancel context.CancelFunc 93 staleThreshold time.Duration 94 simpleSource libkb.AvatarLoaderSource 95 96 populateCacheCh chan populateArg 97 98 prepareDirs sync.Once 99 100 usersMissBatch func(interface{}) 101 teamsMissBatch func(interface{}) 102 usersStaleBatch func(interface{}) 103 teamsStaleBatch func(interface{}) 104 105 // testing 106 populateSuccessCh chan struct{} 107 tempDir string 108 } 109 110 var _ libkb.AvatarLoaderSource = (*FullCachingSource)(nil) 111 112 func NewFullCachingSource(g *libkb.GlobalContext, staleThreshold time.Duration, size int) *FullCachingSource { 113 s := &FullCachingSource{ 114 Contextified: libkb.NewContextified(g), 115 diskLRU: lru.NewDiskLRU("avatars", 1, size), 116 staleThreshold: staleThreshold, 117 simpleSource: NewSimpleSource(), 118 } 119 batcher := func(intBatched interface{}, intSingle interface{}) interface{} { 120 reqs, _ := intBatched.([]remoteFetchArg) 121 single, _ := intSingle.(remoteFetchArg) 122 return append(reqs, single) 123 } 124 reset := func() interface{} { 125 return []remoteFetchArg{} 126 } 127 actor := func(loadFn func(libkb.MetaContext, []string, []keybase1.AvatarFormat) (keybase1.LoadAvatarsRes, error)) func(interface{}) { 128 return func(intBatched interface{}) { 129 reqs, _ := intBatched.([]remoteFetchArg) 130 s.makeRemoteFetchRequests(reqs, loadFn) 131 } 132 } 133 usersMissBatch, _ := libkb.ThrottleBatch( 134 actor(s.simpleSource.LoadUsers), batcher, reset, 100*time.Millisecond, false, 135 ) 136 teamsMissBatch, _ := libkb.ThrottleBatch( 137 actor(s.simpleSource.LoadTeams), batcher, reset, 100*time.Millisecond, false, 138 ) 139 usersStaleBatch, _ := libkb.ThrottleBatch( 140 actor(s.simpleSource.LoadUsers), batcher, reset, 5000*time.Millisecond, false, 141 ) 142 teamsStaleBatch, _ := libkb.ThrottleBatch( 143 actor(s.simpleSource.LoadTeams), batcher, reset, 5000*time.Millisecond, false, 144 ) 145 s.usersMissBatch = usersMissBatch 146 s.teamsMissBatch = teamsMissBatch 147 s.usersStaleBatch = usersStaleBatch 148 s.teamsStaleBatch = teamsStaleBatch 149 return s 150 } 151 152 func (c *FullCachingSource) makeRemoteFetchRequests(reqs []remoteFetchArg, 153 loadFn func(libkb.MetaContext, []string, []keybase1.AvatarFormat) (keybase1.LoadAvatarsRes, error)) { 154 mctx := libkb.NewMetaContextBackground(c.G()) 155 namesSet := make(map[string]bool) 156 formatsSet := make(map[keybase1.AvatarFormat]bool) 157 for _, req := range reqs { 158 for _, name := range req.names { 159 namesSet[name] = true 160 } 161 for _, format := range req.formats { 162 formatsSet[format] = true 163 } 164 } 165 genErrors := func(err error) { 166 for _, req := range reqs { 167 req.errCb <- err 168 } 169 } 170 extractRes := func(req remoteFetchArg, ires keybase1.LoadAvatarsRes) (res keybase1.LoadAvatarsRes) { 171 res.Picmap = make(map[string]map[keybase1.AvatarFormat]keybase1.AvatarUrl) 172 for _, name := range req.names { 173 iformats, ok := ires.Picmap[name] 174 if !ok { 175 continue 176 } 177 if _, ok := res.Picmap[name]; !ok { 178 res.Picmap[name] = make(map[keybase1.AvatarFormat]keybase1.AvatarUrl) 179 } 180 for _, format := range req.formats { 181 res.Picmap[name][format] = iformats[format] 182 } 183 } 184 return res 185 } 186 names := make([]string, 0, len(namesSet)) 187 formats := make([]keybase1.AvatarFormat, 0, len(formatsSet)) 188 for name := range namesSet { 189 names = append(names, name) 190 } 191 for format := range formatsSet { 192 formats = append(formats, format) 193 } 194 c.debug(mctx, "makeRemoteFetchRequests: names: %d formats: %d", len(names), len(formats)) 195 res, err := loadFn(mctx, names, formats) 196 if err != nil { 197 genErrors(err) 198 return 199 } 200 for _, req := range reqs { 201 req.cb <- extractRes(req, res) 202 } 203 } 204 205 func (c *FullCachingSource) StartBackgroundTasks(mctx libkb.MetaContext) { 206 defer mctx.Trace("FullCachingSource.StartBackgroundTasks", nil)() 207 c.Lock() 208 defer c.Unlock() 209 if c.started { 210 return 211 } 212 c.started = true 213 go c.monitorAppState(mctx) 214 c.populateCacheCh = make(chan populateArg, 100) 215 for i := 0; i < 10; i++ { 216 go c.populateCacheWorker(mctx) 217 } 218 mctx, cancel := mctx.WithContextCancel() 219 c.diskLRUCleanerCancel = cancel 220 go lru.CleanOutOfSyncWithDelay(mctx, c.diskLRU, c.getCacheDir(mctx), 10*time.Second) 221 } 222 223 func (c *FullCachingSource) StopBackgroundTasks(mctx libkb.MetaContext) { 224 defer mctx.Trace("FullCachingSource.StopBackgroundTasks", nil)() 225 c.Lock() 226 defer c.Unlock() 227 if !c.started { 228 return 229 } 230 c.started = false 231 close(c.populateCacheCh) 232 if c.diskLRUCleanerCancel != nil { 233 c.diskLRUCleanerCancel() 234 } 235 if err := c.diskLRU.Flush(mctx.Ctx(), mctx.G()); err != nil { 236 c.debug(mctx, "StopBackgroundTasks: unable to flush diskLRU %v", err) 237 } 238 } 239 240 func (c *FullCachingSource) debug(m libkb.MetaContext, msg string, args ...interface{}) { 241 m.Debug("Avatars.FullCachingSource: %s", fmt.Sprintf(msg, args...)) 242 } 243 244 func (c *FullCachingSource) avatarKey(name string, format keybase1.AvatarFormat) string { 245 return fmt.Sprintf("%s:%s", name, format.String()) 246 } 247 248 func (c *FullCachingSource) isStale(m libkb.MetaContext, item lru.DiskLRUEntry) bool { 249 return m.G().GetClock().Now().Sub(item.Ctime) > c.staleThreshold 250 } 251 252 func (c *FullCachingSource) monitorAppState(m libkb.MetaContext) { 253 c.debug(m, "monitorAppState: starting up") 254 state := keybase1.MobileAppState_FOREGROUND 255 for { 256 state = <-m.G().MobileAppState.NextUpdate(&state) 257 if state == keybase1.MobileAppState_BACKGROUND { 258 c.debug(m, "monitorAppState: backgrounded") 259 if err := c.diskLRU.Flush(m.Ctx(), m.G()); err != nil { 260 c.debug(m, "monitorAppState: unable to flush diskLRU %v", err) 261 } 262 } 263 } 264 } 265 266 func (c *FullCachingSource) processLRUHit(entry lru.DiskLRUEntry) (res lruEntry) { 267 var ok bool 268 if _, ok = entry.Value.(map[string]interface{}); ok { 269 jstr, _ := json.Marshal(entry.Value) 270 _ = json.Unmarshal(jstr, &res) 271 return res 272 } 273 path, _ := entry.Value.(string) 274 res.Path = path 275 return res 276 } 277 278 func (c *FullCachingSource) specLoad(m libkb.MetaContext, names []string, formats []keybase1.AvatarFormat) (res avatarLoadSpec, err error) { 279 for _, name := range names { 280 for _, format := range formats { 281 key := c.avatarKey(name, format) 282 found, ientry, err := c.diskLRU.Get(m.Ctx(), m.G(), key) 283 if err != nil { 284 return res, err 285 } 286 lp := avatarLoadPair{ 287 name: name, 288 format: format, 289 } 290 291 // If we found something in the index, let's make sure we have it on the disk as well. 292 entry := c.processLRUHit(ientry) 293 if found { 294 lp.path = c.normalizeFilenameFromCache(m, entry.Path) 295 lp.remoteURL = entry.URL 296 var file *os.File 297 if file, err = os.Open(lp.path); err != nil { 298 c.debug(m, "specLoad: error loading hit: file: %s err: %s", lp.path, err) 299 if err := c.diskLRU.Remove(m.Ctx(), m.G(), key); err != nil { 300 c.debug(m, "specLoad: unable to remove from LRU %v", err) 301 } 302 // Not a true hit if we don't have it on the disk as well 303 found = false 304 } else { 305 file.Close() 306 } 307 } 308 if found { 309 if c.isStale(m, ientry) { 310 res.stales = append(res.stales, lp) 311 } else { 312 res.hits = append(res.hits, lp) 313 } 314 } else { 315 res.misses = append(res.misses, lp) 316 } 317 } 318 } 319 return res, nil 320 } 321 322 func (c *FullCachingSource) getCacheDir(m libkb.MetaContext) string { 323 if len(c.tempDir) > 0 { 324 return c.tempDir 325 } 326 return filepath.Join(m.G().GetCacheDir(), "avatars") 327 } 328 329 func (c *FullCachingSource) getFullFilename(fileName string) string { 330 return fileName + ".avatar" 331 } 332 333 // normalizeFilenameFromCache substitutes the existing cache dir value into the 334 // file path since it's possible for the path to the cache dir to change, 335 // especially on mobile. 336 func (c *FullCachingSource) normalizeFilenameFromCache(mctx libkb.MetaContext, file string) string { 337 file = filepath.Base(file) 338 return filepath.Join(c.getCacheDir(mctx), file) 339 } 340 341 func (c *FullCachingSource) commitAvatarToDisk(m libkb.MetaContext, data io.ReadCloser, previousPath string) (path string, err error) { 342 c.prepareDirs.Do(func() { 343 err := os.MkdirAll(c.getCacheDir(m), os.ModePerm) 344 c.debug(m, "creating directory for avatars %q: %v", c.getCacheDir(m), err) 345 }) 346 347 var file *os.File 348 shouldRename := false 349 if len(previousPath) > 0 { 350 // We already have the image, let's re-use the same file 351 c.debug(m, "commitAvatarToDisk: using previous path: %s", previousPath) 352 if file, err = os.OpenFile(previousPath, os.O_RDWR, os.ModeAppend); err != nil { 353 // NOTE: Even if we don't have this file anymore (e.g. user 354 // raced us to remove it manually), OpenFile will not error 355 // out, but create a new file on given path. 356 return path, err 357 } 358 path = file.Name() 359 } else { 360 if file, err = os.CreateTemp(c.getCacheDir(m), "avatar"); err != nil { 361 return path, err 362 } 363 shouldRename = true 364 } 365 _, err = io.Copy(file, data) 366 file.Close() 367 if err != nil { 368 return path, err 369 } 370 // Rename with correct extension 371 if shouldRename { 372 path = c.getFullFilename(file.Name()) 373 if err = os.Rename(file.Name(), path); err != nil { 374 return path, err 375 } 376 } 377 return path, nil 378 } 379 380 func (c *FullCachingSource) removeFile(m libkb.MetaContext, ent *lru.DiskLRUEntry) { 381 if ent != nil { 382 lentry := c.processLRUHit(*ent) 383 file := c.normalizeFilenameFromCache(m, lentry.GetPath()) 384 if err := os.Remove(file); err != nil { 385 c.debug(m, "removeFile: failed to remove: file: %s err: %s", file, err) 386 } else { 387 c.debug(m, "removeFile: successfully removed: %s", file) 388 } 389 } 390 } 391 392 func (c *FullCachingSource) populateCacheWorker(m libkb.MetaContext) { 393 for arg := range c.populateCacheCh { 394 c.debug(m, "populateCacheWorker: fetching: name: %s format: %s url: %s", arg.name, 395 arg.format, arg.url) 396 // Grab image data first 397 url := arg.url.String() 398 resp, err := libkb.ProxyHTTPGet(m.G(), m.G().GetEnv(), url, "FullCachingSource: Avatar") 399 if err != nil { 400 c.debug(m, "populateCacheWorker: failed to download avatar: %s", err) 401 continue 402 } 403 // Find any previous path we stored this image at on the disk 404 var previousEntry lruEntry 405 var previousPath string 406 key := c.avatarKey(arg.name, arg.format) 407 found, ent, err := c.diskLRU.Get(m.Ctx(), m.G(), key) 408 if err != nil { 409 c.debug(m, "populateCacheWorker: failed to read previous entry in LRU: %s", err) 410 err = libkb.DiscardAndCloseBody(resp) 411 if err != nil { 412 c.debug(m, "populateCacheWorker: error closing body: %+v", err) 413 } 414 continue 415 } 416 if found { 417 previousEntry = c.processLRUHit(ent) 418 previousPath = c.normalizeFilenameFromCache(m, previousEntry.Path) 419 } 420 421 // Save to disk 422 path, err := c.commitAvatarToDisk(m, resp.Body, previousPath) 423 discardErr := libkb.DiscardAndCloseBody(resp) 424 if discardErr != nil { 425 c.debug(m, "populateCacheWorker: error closing body: %+v", discardErr) 426 } 427 if err != nil { 428 c.debug(m, "populateCacheWorker: failed to write to disk: %s", err) 429 continue 430 } 431 v := lruEntry{ 432 Path: path, 433 URL: &url, 434 } 435 evicted, err := c.diskLRU.Put(m.Ctx(), m.G(), key, v) 436 if err != nil { 437 c.debug(m, "populateCacheWorker: failed to put into LRU: %s", err) 438 continue 439 } 440 // Remove any evicted file (if there is one) 441 c.removeFile(m, evicted) 442 443 if c.populateSuccessCh != nil { 444 c.populateSuccessCh <- struct{}{} 445 } 446 } 447 } 448 449 func (c *FullCachingSource) dispatchPopulateFromRes(m libkb.MetaContext, res keybase1.LoadAvatarsRes, 450 spec avatarLoadSpec) { 451 c.Lock() 452 defer c.Unlock() 453 if !c.started { 454 return 455 } 456 for name, rec := range res.Picmap { 457 for format, url := range rec { 458 if url != "" { 459 knownURL := spec.staleKnownURL(name, format) 460 if knownURL == nil || *knownURL != url.String() { 461 c.populateCacheCh <- populateArg{ 462 name: name, 463 format: format, 464 url: url, 465 } 466 } else { 467 c.debug(m, "dispatchPopulateFromRes: skipping name: %s format: %s, stale known", name, 468 format) 469 } 470 } 471 } 472 } 473 } 474 475 func (c *FullCachingSource) makeURL(m libkb.MetaContext, path string) keybase1.AvatarUrl { 476 raw := fmt.Sprintf("file://%s", fileUrlize(path)) 477 u, err := url.Parse(raw) 478 if err != nil { 479 c.debug(m, "makeURL: invalid URL: %s", err) 480 return keybase1.MakeAvatarURL("") 481 } 482 final := fmt.Sprintf("file://%s", u.EscapedPath()) 483 return keybase1.MakeAvatarURL(final) 484 } 485 486 func (c *FullCachingSource) mergeRes(res *keybase1.LoadAvatarsRes, m keybase1.LoadAvatarsRes) { 487 for username, rec := range m.Picmap { 488 for format, url := range rec { 489 res.Picmap[username][format] = url 490 } 491 } 492 } 493 494 func (c *FullCachingSource) loadNames(m libkb.MetaContext, names []string, formats []keybase1.AvatarFormat, 495 users bool) (res keybase1.LoadAvatarsRes, err error) { 496 loadSpec, err := c.specLoad(m, names, formats) 497 if err != nil { 498 return res, err 499 } 500 c.debug(m, "loadNames: hits: %d stales: %d misses: %d", len(loadSpec.hits), len(loadSpec.stales), 501 len(loadSpec.misses)) 502 503 // Fill in the hits 504 allocRes(&res, names) 505 for _, hit := range loadSpec.hits { 506 res.Picmap[hit.name][hit.format] = c.makeURL(m, hit.path) 507 } 508 // Fill in stales 509 for _, stale := range loadSpec.stales { 510 res.Picmap[stale.name][stale.format] = c.makeURL(m, stale.path) 511 } 512 513 // Go get the misses 514 missNames, missFormats := loadSpec.missDetails() 515 if len(missNames) > 0 { 516 var loadRes keybase1.LoadAvatarsRes 517 cb := make(chan keybase1.LoadAvatarsRes, 1) 518 errCb := make(chan error, 1) 519 arg := remoteFetchArg{ 520 names: missNames, 521 formats: missFormats, 522 cb: cb, 523 errCb: errCb, 524 } 525 if users { 526 c.usersMissBatch(arg) 527 } else { 528 c.teamsMissBatch(arg) 529 } 530 select { 531 case loadRes = <-cb: 532 case err = <-errCb: 533 } 534 if err == nil { 535 c.mergeRes(&res, loadRes) 536 c.dispatchPopulateFromRes(m, loadRes, loadSpec) 537 } else { 538 c.debug(m, "loadNames: failed to load server miss reqs: %s", err) 539 } 540 } 541 // Spawn off a goroutine to reload stales 542 staleNames, staleFormats := loadSpec.staleDetails() 543 if len(staleNames) > 0 { 544 go func() { 545 m := m.BackgroundWithLogTags() 546 c.debug(m, "loadNames: spawning stale background load: names: %d", 547 len(staleNames)) 548 var loadRes keybase1.LoadAvatarsRes 549 cb := make(chan keybase1.LoadAvatarsRes, 1) 550 errCb := make(chan error, 1) 551 arg := remoteFetchArg{ 552 names: staleNames, 553 formats: staleFormats, 554 cb: cb, 555 errCb: errCb, 556 } 557 if users { 558 c.usersStaleBatch(arg) 559 } else { 560 c.teamsStaleBatch(arg) 561 } 562 select { 563 case loadRes = <-cb: 564 case err = <-errCb: 565 } 566 if err == nil { 567 c.dispatchPopulateFromRes(m, loadRes, loadSpec) 568 } else { 569 c.debug(m, "loadNames: failed to load server stale reqs: %s", err) 570 } 571 }() 572 } 573 return res, nil 574 } 575 576 func (c *FullCachingSource) clearName(m libkb.MetaContext, name string, formats []keybase1.AvatarFormat) (err error) { 577 for _, format := range formats { 578 key := c.avatarKey(name, format) 579 found, ent, err := c.diskLRU.Get(m.Ctx(), m.G(), key) 580 if err != nil { 581 return err 582 } 583 if found { 584 c.removeFile(m, &ent) 585 if err := c.diskLRU.Remove(m.Ctx(), m.G(), key); err != nil { 586 return err 587 } 588 } 589 } 590 return nil 591 } 592 593 func (c *FullCachingSource) LoadUsers(m libkb.MetaContext, usernames []string, formats []keybase1.AvatarFormat) (res keybase1.LoadAvatarsRes, err error) { 594 defer m.Trace("FullCachingSource.LoadUsers", &err)() 595 return c.loadNames(m, usernames, formats, true) 596 } 597 598 func (c *FullCachingSource) LoadTeams(m libkb.MetaContext, teams []string, formats []keybase1.AvatarFormat) (res keybase1.LoadAvatarsRes, err error) { 599 defer m.Trace("FullCachingSource.LoadTeams", &err)() 600 return c.loadNames(m, teams, formats, false) 601 } 602 603 func (c *FullCachingSource) ClearCacheForName(m libkb.MetaContext, name string, formats []keybase1.AvatarFormat) (err error) { 604 defer m.Trace(fmt.Sprintf("FullCachingSource.ClearCacheForUser(%q,%v)", name, formats), &err)() 605 return c.clearName(m, name, formats) 606 } 607 608 func (c *FullCachingSource) OnDbNuke(m libkb.MetaContext) error { 609 if c.diskLRU != nil { 610 if err := c.diskLRU.CleanOutOfSync(m, c.getCacheDir(m)); err != nil { 611 c.debug(m, "unable to run clean: %v", err) 612 } 613 } 614 return nil 615 }