Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: keep profile info in cache to avoid long initial feed load times

+37 -22
+35 -20
internal/firehose/index.go
··· 1016 1016 return item, nil 1017 1017 } 1018 1018 1019 - // GetProfile fetches a profile, using cache when possible 1019 + // GetProfile fetches a profile, using cache when possible. The persistent 1020 + // SQLite store has no TTL — the profile watcher keeps it fresh via the 1021 + // firehose. Only a completely unknown DID triggers an API fetch. 1020 1022 func (idx *FeedIndex) GetProfile(ctx context.Context, did string) (*atproto.Profile, error) { 1021 - // Check in-memory cache first 1023 + // Check in-memory cache first (TTL used only for memory management) 1022 1024 idx.profileCacheMu.RLock() 1023 1025 if cached, ok := idx.profileCache[did]; ok && time.Now().Before(cached.ExpiresAt) { 1024 1026 idx.profileCacheMu.RUnlock() ··· 1026 1028 } 1027 1029 idx.profileCacheMu.RUnlock() 1028 1030 1029 - // Check persistent cache 1030 - var dataStr, expiresAtStr string 1031 - err := idx.db.QueryRowContext(ctx, `SELECT data, expires_at FROM profiles WHERE did = ?`, did).Scan(&dataStr, &expiresAtStr) 1031 + // Check persistent store — no TTL, firehose keeps it fresh 1032 + var dataStr string 1033 + err := idx.db.QueryRowContext(ctx, `SELECT data FROM profiles WHERE did = ?`, did).Scan(&dataStr) 1032 1034 if err == nil { 1033 - expiresAt, _ := time.Parse(time.RFC3339Nano, expiresAtStr) 1034 - if time.Now().Before(expiresAt) { 1035 - cached := &CachedProfile{} 1036 - if err := json.Unmarshal([]byte(dataStr), cached); err == nil { 1037 - idx.profileCacheMu.Lock() 1038 - idx.profileCache[did] = cached 1039 - idx.profileCacheMu.Unlock() 1040 - return cached.Profile, nil 1041 - } 1035 + cached := &CachedProfile{} 1036 + if err := json.Unmarshal([]byte(dataStr), cached); err == nil { 1037 + // Promote to in-memory cache 1038 + cached.ExpiresAt = time.Now().Add(idx.profileTTL) 1039 + idx.profileCacheMu.Lock() 1040 + idx.profileCache[did] = cached 1041 + idx.profileCacheMu.Unlock() 1042 + return cached.Profile, nil 1042 1043 } 1043 1044 } 1044 1045 1045 - // Fetch from API 1046 + // Unknown DID — fetch from API 1046 1047 profile, err := idx.publicClient.GetProfile(ctx, did) 1047 1048 if err != nil { 1048 1049 return nil, err 1049 1050 } 1050 1051 1051 - // Cache the result 1052 + idx.storeProfile(ctx, did, profile) 1053 + return profile, nil 1054 + } 1055 + 1056 + // storeProfile writes a profile to both in-memory and persistent caches. 1057 + func (idx *FeedIndex) storeProfile(ctx context.Context, did string, profile *atproto.Profile) { 1052 1058 now := time.Now() 1053 1059 cached := &CachedProfile{ 1054 1060 Profile: profile, ··· 1056 1062 ExpiresAt: now.Add(idx.profileTTL), 1057 1063 } 1058 1064 1059 - // Update in-memory cache 1060 1065 idx.profileCacheMu.Lock() 1061 1066 idx.profileCache[did] = cached 1062 1067 idx.profileCacheMu.Unlock() 1063 1068 1064 - // Persist to database 1065 1069 data, _ := json.Marshal(cached) 1066 1070 _, _ = idx.db.ExecContext(ctx, `INSERT OR REPLACE INTO profiles (did, data, expires_at) VALUES (?, ?, ?)`, 1067 1071 did, string(data), cached.ExpiresAt.Format(time.RFC3339Nano)) 1068 - 1069 - return profile, nil 1070 1072 } 1071 1073 1072 1074 // InvalidateProfile removes a DID's profile from both the in-memory and persistent ··· 1077 1079 idx.profileCacheMu.Unlock() 1078 1080 1079 1081 _, _ = idx.db.Exec(`DELETE FROM profiles WHERE did = ?`, did) 1082 + } 1083 + 1084 + // RefreshProfile fetches a profile from the API and stores it in both caches. 1085 + // Used by the profile watcher to keep the cache warm on firehose events. 1086 + func (idx *FeedIndex) RefreshProfile(ctx context.Context, did string) { 1087 + profile, err := idx.publicClient.GetProfile(ctx, did) 1088 + if err != nil { 1089 + log.Warn().Err(err).Str("did", did).Msg("profile refresh: failed to fetch, invalidating instead") 1090 + idx.InvalidateProfile(did) 1091 + return 1092 + } 1093 + 1094 + idx.storeProfile(ctx, did, profile) 1080 1095 } 1081 1096 1082 1097 // GetKnownDIDs returns all DIDs that have created Arabica records
+2 -2
internal/firehose/profile_watcher.go
··· 278 278 return 279 279 } 280 280 if event.Commit.Operation == "create" || event.Commit.Operation == "update" { 281 - pw.index.InvalidateProfile(event.DID) 282 - log.Debug().Str("did", event.DID).Msg("profile watcher: invalidated profile cache") 281 + pw.index.RefreshProfile(context.Background(), event.DID) 282 + log.Debug().Str("did", event.DID).Msg("profile watcher: refreshed profile cache") 283 283 } 284 284 }