1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "sync"
8 "time"
9
10 "github.com/bnema/zerowrap"
11
12 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"
14)
15
16// MaintenanceDelay is how long maintenance waits after the first push of a
17// burst, so that pushes in quick succession share one run.
18const MaintenanceDelay = 5 * time.Second
19
20// Maintainer runs repository maintenance after pushes, debounced per
21// repository: the first Schedule starts a timer, further ones before it fires
22// join the same run, and a repository is maintained by one run at a time. A
23// Schedule during a run queues exactly one more run.
24//
25// Its goroutines use the logger of the context given to NewMaintainer and
26// live until Close. A run is cancelled by Close, and Git bounds it otherwise
27// (gitcli applies its command timeout to a context without deadline).
28type Maintainer struct {
29 git ports.GitRepos
30 delay time.Duration
31 ctx context.Context
32 stop context.CancelFunc
33
34 mu sync.Mutex
35 closed bool
36 entries map[domain.RepoName]*maintEntry
37 wg sync.WaitGroup
38}
39
40type maintEntry struct {
41 timer *time.Timer // pending run, nil while running or idle
42 running bool
43 again bool // scheduled during a run
44}
45
46// NewMaintainer builds a maintainer. ctx carries the logger; its cancellation
47// is not followed, call Close. A zero delay means MaintenanceDelay.
48func NewMaintainer(ctx context.Context, git ports.GitRepos, delay time.Duration) *Maintainer {
49 if delay <= 0 {
50 delay = MaintenanceDelay
51 }
52 run, stop := context.WithCancel(context.WithoutCancel(ctx))
53 return &Maintainer{git: git, delay: delay, ctx: run, stop: stop, entries: make(map[domain.RepoName]*maintEntry)}
54}
55
56// Schedule queues maintenance of the repository.
57func (m *Maintainer) Schedule(ctx context.Context, name domain.RepoName) {
58 m.mu.Lock()
59 defer m.mu.Unlock()
60 if m.closed {
61 return
62 }
63 e := m.entries[name]
64 switch {
65 case e == nil:
66 e = &maintEntry{}
67 m.entries[name] = e
68 m.arm(name, e)
69 log := zerowrap.FromCtx(ctx)
70 log.Debug().Str("repo", name.String()).Dur("delay", m.delay).Msg("repository maintenance scheduled")
71 case e.running:
72 e.again = true
73 }
74 // A timer is already pending: this push joins it.
75}
76
77// arm starts the timer of e. The caller holds mu.
78func (m *Maintainer) arm(name domain.RepoName, e *maintEntry) {
79 m.wg.Add(1) // released when the timer fires or is stopped by Close
80 e.timer = time.AfterFunc(m.delay, func() { m.run(name, e) })
81}
82
83func (m *Maintainer) run(name domain.RepoName, e *maintEntry) {
84 defer m.wg.Done()
85 m.mu.Lock()
86 if m.closed {
87 delete(m.entries, name)
88 m.mu.Unlock()
89 return
90 }
91 e.timer, e.running = nil, true
92 m.mu.Unlock()
93
94 log := zerowrap.FromCtx(m.ctx)
95 start := time.Now()
96 if err := m.git.Maintain(m.ctx, name); err != nil {
97 log.Warn().Err(err).Str("repo", name.String()).Msg("repository maintenance failed")
98 } else {
99 log.Debug().Str("repo", name.String()).Dur("duration", time.Since(start)).Msg("repository maintained")
100 }
101
102 m.mu.Lock()
103 defer m.mu.Unlock()
104 e.running = false
105 if e.again && !m.closed {
106 e.again = false
107 m.arm(name, e)
108 return
109 }
110 delete(m.entries, name)
111}
112
113// Close stops pending runs, cancels the one in progress and waits for it.
114func (m *Maintainer) Close(_ context.Context) {
115 m.mu.Lock()
116 m.closed = true
117 for _, e := range m.entries {
118 if e.timer != nil && e.timer.Stop() {
119 m.wg.Done() // the timer will never fire
120 e.timer = nil
121 }
122 }
123 m.mu.Unlock()
124 m.stop()
125 m.wg.Wait()
126}