package fetchers import ( "context" "encoding/json" "fmt" "llostre/notes" "sort" "time" "github.com/redis/go-redis/v9" ) type Cache struct { client *redis.Client ctx context.Context postExpiry time.Duration profileExpiry time.Duration } func NewCache(addr, password string, postExpiryMinutes, profileExpiryMinutes int) (*Cache, error) { client := redis.NewClient(&redis.Options{ Addr: addr, Password: password, DB: 0, }) ctx := context.Background() if err := client.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("valkey connection failed: %w", err) } return &Cache{ client: client, ctx: ctx, postExpiry: time.Duration(postExpiryMinutes) * time.Minute, profileExpiry: time.Duration(profileExpiryMinutes) * time.Minute, }, nil } func (c *Cache) Get(uri string) (*notes.Note, bool) { key := fmt.Sprintf("post:%s", uri) data, err := c.client.Get(c.ctx, key).Bytes() if err != nil { return nil, false } var note notes.Note if err := json.Unmarshal(data, ¬e); err != nil { return nil, false } c.client.Expire(c.ctx, key, c.postExpiry) return ¬e, true } func (c *Cache) Set(uri string, note *notes.Note) { key := fmt.Sprintf("post:%s", uri) data, err := json.Marshal(note) if err != nil { return } c.client.Set(c.ctx, key, data, c.postExpiry) } func (c *Cache) Delete(uri string) { key := fmt.Sprintf("post:%s", uri) c.client.Del(c.ctx, key) } func (c *Cache) DeleteAuthor(did string) { key := fmt.Sprintf("profile:%s", did) c.client.Del(c.ctx, key) } // GetAuthor returns a cached author and refreshes its TTL. func (c *Cache) GetAuthor(did string) (*notes.Author, bool) { return c.getAuthor(did, true) } // readAuthor is the same as GetAuthor but doesn't refresh the TTL. func (c *Cache) readAuthor(did string) (*notes.Author, bool) { return c.getAuthor(did, false) } func (c *Cache) getAuthor(did string, refresh bool) (*notes.Author, bool) { key := fmt.Sprintf("profile:%s", did) data, err := c.client.Get(c.ctx, key).Bytes() if err != nil { return nil, false } var author notes.Author if err := json.Unmarshal(data, &author); err != nil { return nil, false } if refresh { c.client.Expire(c.ctx, key, c.profileExpiry) } return &author, true } func (c *Cache) SetAuthor(did string, author *notes.Author) { key := fmt.Sprintf("profile:%s", did) data, err := json.Marshal(author) if err != nil { return } c.client.Set(c.ctx, key, data, c.profileExpiry) } func (c *Cache) GetCursor(did string) (string, bool) { key := fmt.Sprintf("cursor:%s", did) cursor, err := c.client.Get(c.ctx, key).Result() if err != nil { return "", false } return cursor, true } func (c *Cache) SetCursor(did, cursor string) { key := fmt.Sprintf("cursor:%s", did) if cursor == "" { c.client.Del(c.ctx, key) return } c.client.Set(c.ctx, key, cursor, 24*time.Hour) } func (c *Cache) GetPostsByDID(did string) ([]notes.Note, error) { pattern := fmt.Sprintf("post:at://%s/*", did) var posts []notes.Note iter := c.client.Scan(c.ctx, 0, pattern, 0).Iterator() for iter.Next(c.ctx) { key := iter.Val() data, err := c.client.Get(c.ctx, key).Bytes() if err != nil { continue } var note notes.Note if err := json.Unmarshal(data, ¬e); err != nil { continue } c.client.Expire(c.ctx, key, c.postExpiry) posts = append(posts, note) } if err := iter.Err(); err != nil { return nil, err } sort.Slice(posts, func(i, j int) bool { return posts[i].CreatedAt.After(posts[j].CreatedAt) }) return posts, nil } // returns how many posts we expect to have cached for an author // for detecting gaps caused by uneven TTL expiry func (c *Cache) GetPostCount(did string) (int, bool) { key := fmt.Sprintf("post_count:%s", did) count, err := c.client.Get(c.ctx, key).Int() if err != nil { return 0, false } return count, true } // resets the expected count. Used after a fresh fetch func (c *Cache) SetPostCount(did string, count int) { key := fmt.Sprintf("post_count:%s", did) c.client.Set(c.ctx, key, count, c.postExpiry) } // increments the expected post count. Used for continuation fetches // (fetching from a previous cursor) and jetstream events func (c *Cache) AddPostCount(did string, incr int) { key := fmt.Sprintf("post_count:%s", did) c.client.IncrBy(c.ctx, key, int64(incr)) c.client.Expire(c.ctx, key, c.postExpiry) } // track oldest sent post per profile func (c *Cache) GetOldestSent(did string) (time.Time, bool) { key := fmt.Sprintf("oldest_sent:%s", did) ts, err := c.client.Get(c.ctx, key).Int64() if err != nil { return time.Time{}, false } return time.Unix(ts, 0), true } func (c *Cache) SetOldestSent(did string, timestamp time.Time) { key := fmt.Sprintf("oldest_sent:%s", did) c.client.Set(c.ctx, key, timestamp.Unix(), 24*time.Hour) } // count posts older than timestamp for a profile func (c *Cache) CountPostsOlderThan(did string, timestamp time.Time) (int, error) { posts, err := c.GetPostsByDID(did) if err != nil { return 0, err } for i, p := range posts { if p.CreatedAt.Before(timestamp) { return len(posts) - i, nil } } return 0, nil } func (c *Cache) Close() error { return c.client.Close() }