commit 635ade7fa06283937355616d5bc0d2d38e879043 from: Oliver Lowe date: Wed Apr 10 08:23:44 2024 UTC lemmy: implement client-side community, post caching Servers now provide a Cache-Control header in the HTTP response, so we can use that for some dead simple caching. commit - ed1a61d7093471344579fb8d0e3632134dc1dc26 commit + 635ade7fa06283937355616d5bc0d2d38e879043 blob - 6d2879c6eeac9346f577d13ae7e63840f80280d8 blob + 5760a25e191636c36032a100e12bdaf0560cf6c5 --- client.go +++ client.go @@ -11,6 +11,7 @@ import ( "os" "path" "strconv" + "sync" "time" ) @@ -21,6 +22,7 @@ type Client struct { Debug bool authToken string instance *url.URL + cache *cache ready bool } @@ -48,6 +50,13 @@ func (c *Client) init() error { if c.Client == nil { c.Client = http.DefaultClient } + if c.cache == nil { + c.cache = &cache{ + post: make(map[int]entry), + community: make(map[string]entry), + mu: &sync.Mutex{}, + } + } c.ready = true return nil } @@ -100,6 +109,12 @@ func (c *Client) LookupCommunity(name string) (Communi return Community{}, Counts{}, err } } + if ent, ok := c.cache.community[name]; ok { + if time.Now().Before(ent.expiry) { + return ent.community, Counts{}, nil + } + c.cache.delete(ent.post, ent.community) + } params := map[string]string{"name": name} resp, err := c.get("community", params) @@ -123,7 +138,16 @@ func (c *Client) LookupCommunity(name string) (Communi if err := json.NewDecoder(resp.Body).Decode(&cres); err != nil { return Community{}, Counts{}, fmt.Errorf("decode community response: %w", err) } - return cres.View.Community, cres.View.Counts, nil + community := cres.View.Community + age := extractMaxAge(resp.Header) + if age != "" { + dur, err := parseMaxAge(age) + if err != nil { + return community, Counts{}, fmt.Errorf("parse cache max age from response header: %w", err) + } + c.cache.store(Post{}, community, dur) + } + return community, cres.View.Counts, nil } func (c *Client) Posts(community string, mode ListMode) ([]Post, error) { @@ -171,6 +195,12 @@ func (c *Client) LookupPost(id int) (Post, error) { return Post{}, err } } + if ent, ok := c.cache.post[id]; ok { + if time.Now().Before(ent.expiry) { + return ent.post, nil + } + c.cache.delete(ent.post, Community{}) + } params := map[string]string{"id": strconv.Itoa(id)} resp, err := c.get("post", params) @@ -184,6 +214,14 @@ func (c *Client) LookupPost(id int) (Post, error) { return Post{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body)) } post, _, _, err := decodePostResponse(resp.Body) + age := extractMaxAge(resp.Header) + if age != "" { + dur, err := parseMaxAge(age) + if err != nil { + return post, fmt.Errorf("parse cache max age from response header: %w", err) + } + c.cache.store(post, Community{}, dur) + } return post, err } blob - /dev/null blob + c131343175181319f4127c2ac07bd312a89a7621 (mode 644) --- /dev/null +++ cache.go @@ -0,0 +1,74 @@ +package lemmy + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +type cache struct { + post map[int]entry + community map[string]entry + mu *sync.Mutex +} + +type entry struct { + post Post + community Community + expiry time.Time +} + +func (c *cache) store(p Post, com Community, dur time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + t := time.Now().Add(dur) + entry := entry{expiry: t} + if p.Name() != "" { + entry.post = p + c.post[p.ID] = entry + } + if com.Name() != "" { + entry.community = com + c.community[com.Name()] = entry + } +} + +func (c *cache) delete(p Post, com Community) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.post, p.ID) + delete(c.community, com.Name()) +} + +// max-age=50 +func parseMaxAge(s string) (time.Duration, error) { + var want string + elems := strings.Split(s, ",") + for i := range elems { + elems[i] = strings.TrimSpace(elems[i]) + if strings.HasPrefix(elems[i], "max-age") { + want = elems[i] + } + } + _, num, found := strings.Cut(want, "=") + if !found { + return 0, fmt.Errorf("missing = separator") + } + n, err := strconv.Atoi(num) + if err != nil { + return 0, fmt.Errorf("parse seconds: %w", err) + } + return time.Duration(n) * time.Second, nil +} + +// Cache-Control: public, max-age=50 +func extractMaxAge(header http.Header) string { + cc := header.Get("Cache-Control") + if !strings.Contains(cc, "max-age=") { + return "" + } + return cc +} blob - 9b645de3870778f579e1f3eab608be62c1b56010 blob + e1617c0cfee440834afd7b0c7d53a237622357c0 --- cmd/Lemmy/comment.go +++ cmd/Lemmy/comment.go @@ -34,7 +34,7 @@ func parseReply(r io.Reader) (*lemmy.Comment, error) { return &comment, nil } -func printThread(w io.Writer, prefix string, parent int, comments []lemmy.Comment) { +func printThread(w io.Writer, prefix string, parent int, comments []lemmy.Comment) { for _, child := range children(parent, comments) { fprintComment(w, prefix, child) if len(children(child.ID, comments)) > 0 { @@ -47,7 +47,6 @@ func fprintComment(w io.Writer, prefix string, c lemmy fmt.Fprintln(w, prefix, "From:", c.Creator) fmt.Fprintln(w, prefix, "Archived-At:", c.ActivityURL) fmt.Fprintln(w, prefix, c.Content) - println() } func children(parent int, pool []lemmy.Comment) []lemmy.Comment { blob - c22a508e7f453da8e6d42bfc052b08f902576ece blob + ca21a0e9e64a8765914273f3a210540437c7754d --- fs/fs.go +++ fs/fs.go @@ -14,35 +14,14 @@ import ( ) type FS struct { - Client *lemmy.Client - // Communities holds a cache of communities. - Communities map[string]lemmy.Community - Posts map[int]lemmy.Post - started bool + Client *lemmy.Client + started bool } func (fsys *FS) start() error { if fsys.Client == nil { fsys.Client = &lemmy.Client{} } - - if fsys.Communities == nil { - fsys.Communities = make(map[string]lemmy.Community) - if fsys.Client.Authenticated() { - subscribed, err := fsys.Client.Communities(lemmy.ListSubscribed) - if err != nil { - return fmt.Errorf("load subscriptions: %w", err) - } - for _, c := range subscribed { - fsys.Communities[c.Name()] = c - } - } - } - - if fsys.Posts == nil { - fsys.Posts = make(map[int]lemmy.Post) - } - fsys.started = true return nil } @@ -71,16 +50,11 @@ func (fsys *FS) Open(name string) (fs.File, error) { return nil, &fs.PathError{"open", name, fs.ErrNotExist} } - community, ok := fsys.Communities[elems[0]] - if !ok { - var err error - community, _, err = fsys.Client.LookupCommunity(elems[0]) - if errors.Is(err, lemmy.ErrNotFound) { - return nil, &fs.PathError{"open", name, fs.ErrNotExist} - } else if err != nil { - return nil, &fs.PathError{"open", name, err} - } - fsys.Communities[elems[0]] = community + community, _, err := fsys.Client.LookupCommunity(elems[0]) + if errors.Is(err, lemmy.ErrNotFound) { + return nil, &fs.PathError{"open", name, fs.ErrNotExist} + } else if err != nil { + return nil, &fs.PathError{"open", name, err} } if len(elems) == 1 { return &lFile{ @@ -138,10 +112,14 @@ func (fsys *FS) Open(name string) (fs.File, error) { func (fsys *FS) openRoot() (fs.File, error) { dirinfo := new(dirInfo) - for _, c := range fsys.Communities { + communities, err := fsys.Client.Communities(lemmy.ListAll) + if err != nil { + return nil, err + } + for _, c := range communities { c := c - de := fs.FileInfoToDirEntry(&c) - dirinfo.entries = append(dirinfo.entries, de) + dent := fs.FileInfoToDirEntry(&c) + dirinfo.entries = append(dirinfo.entries, dent) } return &dummy{ name: ".",