1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "sync"
8 "time"
9
10 "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"
12)
13
14const (
15 // maxStatsEntries bounds the cache of figures; when full it is emptied,
16 // and the entries of the current heads come back on the next read.
17 maxStatsEntries = 1024
18 // statsTTL is how long figures are served: the 30-day window moves
19 // with time even when the repository does not.
20 statsTTL = 10 * time.Minute
21)
22
23// statsKey names the figures of one state of a repository: its life (the
24// creation time), its last push or merge (which every ref change, tags
25// included, moves) and the head counted from.
26type statsKey struct {
27 repo domain.RepoName
28 created int64
29 pushed int64
30 head domain.ObjectID
31}
32
33type statsEntry struct {
34 st ports.RepoStats
35 at time.Time
36 err error
37 // done is closed when st and err are set; until then the entry is the
38 // read in flight that the other callers wait for.
39 done chan struct{}
40}
41
42// statsCache keeps the figures of the home page, so that a request reads Git
43// once per repository state and per statsTTL, and concurrent requests share
44// one read.
45type statsCache struct {
46 mu sync.Mutex
47 m map[statsKey]*statsEntry
48}
49
50// get returns the figures of key, computing them with read when they are
51// missing, failed or older than statsTTL.
52func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) {
53 c.mu.Lock()
54 e, ok := c.m[key]
55 if ok {
56 select {
57 case <-e.done:
58 if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) {
59 ok = false
60 }
61 default: // in flight
62 }
63 }
64 if !ok {
65 if c.m == nil || len(c.m) >= maxStatsEntries {
66 c.m = make(map[statsKey]*statsEntry)
67 }
68 e = &statsEntry{at: now, done: make(chan struct{})}
69 c.m[key] = e
70 c.mu.Unlock()
71 e.st, e.err = read()
72 close(e.done)
73 return cloneStats(e.st), e.err
74 }
75 c.mu.Unlock()
76 select {
77 case <-e.done:
78 return cloneStats(e.st), e.err
79 case <-ctx.Done():
80 return ports.RepoStats{}, ctx.Err()
81 }
82}
83
84func cloneStats(st ports.RepoStats) ports.RepoStats {
85 if st.LatestTag != nil {
86 t := *st.LatestTag
87 st.LatestTag = &t
88 }
89 return st
90}
91
92// RepoStats counts the commits reachable from head, all of them and those of
93// the last ports.RecentDays days before now, and names the latest tag. The
94// answer is kept for statsTTL, or until the repository is pushed to.
95func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
96 r, err := b.authorize(ctx, p, repo)
97 if err != nil {
98 return ports.RepoStats{}, err
99 }
100 key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head}
101 return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) {
102 // The read outlives a caller that gives up: the others wait for it.
103 rctx := context.WithoutCancel(ctx)
104 var st ports.RepoStats
105 var err error
106 if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {
107 return ports.RepoStats{}, err
108 }
109 since := now.AddDate(0, 0, -ports.RecentDays)
110 if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {
111 return ports.RepoStats{}, err
112 }
113 tag, ok, err := b.reader.LatestTag(rctx, repo)
114 if err != nil {
115 return ports.RepoStats{}, err
116 }
117 if ok {
118 st.LatestTag = &tag
119 }
120 return st, nil
121 })
122}