l-ai-chat-client-go/participant_cache.go
Kaien ba2fa0aa2d feat: participant SDK APIs and bounded cache
Wrap Stage 7.1 participant RPCs and add a version-aware ParticipantCache
with batch hydration helpers for agents and services.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 13:49:19 +03:00

194 lines
4.6 KiB
Go

package chatclient
import (
"context"
"sync"
"time"
pb "gitea.fe.kaien.ru/kaien/l-ai-chat-proto"
)
// ParticipantCache is a bounded in-memory cache keyed by author_id.
// Callers should prefer BatchGetParticipants over N+1 GetParticipant.
type ParticipantCache struct {
mu sync.RWMutex
byID map[string]*pb.Participant
maxSize int
}
// NewParticipantCache creates a cache. maxSize <= 0 defaults to 512.
func NewParticipantCache(maxSize int) *ParticipantCache {
if maxSize <= 0 {
maxSize = 512
}
return &ParticipantCache{
byID: make(map[string]*pb.Participant),
maxSize: maxSize,
}
}
// Get returns a cached participant if present.
func (c *ParticipantCache) Get(authorID string) (*pb.Participant, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
p, ok := c.byID[authorID]
return p, ok
}
// Put stores a participant, preferring newer profile/presence versions.
func (c *ParticipantCache) Put(p *pb.Participant) {
if p == nil || p.GetAuthorId() == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if existing, ok := c.byID[p.GetAuthorId()]; ok {
if p.GetProfileVersion() < existing.GetProfileVersion() {
// Keep newer profile fields but allow presence-only updates below.
merged := cloneParticipant(existing)
if p.GetPresenceVersion() > existing.GetPresenceVersion() {
merged.Online = p.GetOnline()
merged.PresenceVersion = p.GetPresenceVersion()
merged.LastSeenAt = p.GetLastSeenAt()
}
c.byID[p.GetAuthorId()] = merged
return
}
if p.GetProfileVersion() == existing.GetProfileVersion() &&
p.GetPresenceVersion() < existing.GetPresenceVersion() {
return
}
}
if len(c.byID) >= c.maxSize {
// Simple eviction: clear half (bounded cache, not LRU).
n := 0
for id := range c.byID {
delete(c.byID, id)
n++
if n >= c.maxSize/2 {
break
}
}
}
c.byID[p.GetAuthorId()] = cloneParticipant(p)
}
// PutAll stores many participants.
func (c *ParticipantCache) PutAll(ps []*pb.Participant) {
for _, p := range ps {
c.Put(p)
}
}
// MissingIDs returns author IDs not present in the cache.
func (c *ParticipantCache) MissingIDs(ids []string) []string {
c.mu.RLock()
defer c.mu.RUnlock()
var missing []string
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
if _, ok := c.byID[id]; !ok {
missing = append(missing, id)
}
}
return missing
}
func cloneParticipant(p *pb.Participant) *pb.Participant {
if p == nil {
return nil
}
out := &pb.Participant{
AuthorId: p.AuthorId,
AuthorType: p.AuthorType,
DisplayName: p.DisplayName,
Role: p.Role,
Bio: p.Bio,
StatusText: p.StatusText,
AvatarHash: p.AvatarHash,
Online: p.Online,
ProfileVersion: p.ProfileVersion,
PresenceVersion: p.PresenceVersion,
AgentId: p.AgentId,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
LastSeenAt: p.LastSeenAt,
}
if len(p.Aliases) > 0 {
out.Aliases = make(map[string]string, len(p.Aliases))
for k, v := range p.Aliases {
out.Aliases[k] = v
}
}
return out
}
// EnsureParticipants fetches missing IDs via BatchGetParticipants and caches them.
func (c *Client) EnsureParticipants(ctx context.Context, cache *ParticipantCache, authorIDs []string) error {
if cache == nil {
return nil
}
missing := cache.MissingIDs(authorIDs)
if len(missing) == 0 {
return nil
}
ps, err := c.BatchGetParticipants(ctx, missing)
if err != nil {
return err
}
cache.PutAll(ps)
return nil
}
// ResolveDisplayName picks a localized alias then falls back to display_name.
// locale examples: "ru-RU", "ru", "en-US".
func ResolveDisplayName(p *pb.Participant, locale string) string {
if p == nil {
return ""
}
if locale != "" && len(p.Aliases) > 0 {
if v, ok := p.Aliases[locale]; ok && v != "" {
return v
}
// primary language subtag
if i := indexByte(locale, '-'); i > 0 {
lang := locale[:i]
if v, ok := p.Aliases[lang]; ok && v != "" {
return v
}
}
}
if p.DisplayName != "" {
return p.DisplayName
}
return ""
}
func indexByte(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
// AvatarUploadNeeded reports whether localHash differs from the server hash
// (empty local means skip; empty server means upload if local present).
func AvatarUploadNeeded(serverHash, localHash string) bool {
if localHash == "" {
return false
}
return serverHash != localHash
}
// Idle helper kept for callers that want a soft TTL hint (cache itself is version-based).
func CacheSoftTTL() time.Duration { return 5 * time.Minute }