events.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"strconv"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/bnema/zerowrap"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// DefaultEventBuffer is the number of events the Events use case remembers.
21const DefaultEventBuffer = 1024
22
23// Limits of the watchers.
24const (
25	// MaxWatchTopics is the most topics one watch may follow.
26	MaxWatchTopics = 64
27	// MaxWatchers is the most watches in progress at once.
28	MaxWatchers = 256
29	// MaxWatchersPerClient is the most watches in progress for one client
30	// address (see ports.WatchParams.Client).
31	MaxWatchersPerClient = 16
32	// DefaultRevalidateEvery is how long a watcher with a stored token goes
33	// without asking the token store whether the token still exists.
34	DefaultRevalidateEvery = 5 * time.Second
35)
36
37// Topics a watcher may follow.
38const (
39	// TopicHome follows every repository the caller may see.
40	TopicHome = "home"
41	// topicRepoPrefix starts the topic of one repository: "repo:<name>".
42	topicRepoPrefix = "repo:"
43)
44
45// bufferedEvent is an event with its sequence number in the buffer, and what
46// was true of its repository when it was buffered. Access to an event is
47// decided from that, and from the repository as it is when the event is
48// delivered: a repository made public later does not reveal what happened
49// while it was private, and a repository deleted and created again under the
50// same name does not inherit the events of the old one.
51type bufferedEvent struct {
52	seq uint64
53	ev  domain.Event
54
55	// known is false when the repository did not exist any more when the
56	// event was buffered: nobody is told about it.
57	known bool
58	// vis is the visibility of the repository at that time.
59	vis domain.Visibility
60	// created is the creation time of that repository.
61	created time.Time
62}
63
64// Events keeps the last events of the bus in a ring buffer, numbers them, and
65// lets callers watch them: live, or replayed from the id of the last event
66// they saw. Events of a private repository only reach callers that may read
67// it, checked again for every event.
68//
69// Event ids are opaque to clients: "<boot>-<seq>", where boot identifies this
70// run of the server, so that an id from before a restart is unknown rather
71// than taken for a newer event.
72//
73// The goroutine that feeds the buffer uses the logger of the context given to
74// NewEvents and lives until Close.
75type Events struct {
76	repos *Repos
77	auth  ports.Revalidator
78	clock ports.Clock
79	boot  string
80	size  int
81	every time.Duration // see EventsConfig.RevalidateEvery
82
83	stop context.CancelFunc
84	done chan struct{}
85
86	mu      sync.Mutex
87	ring    []bufferedEvent // capacity size, oldest first
88	nextSeq uint64          // sequence number of the next event, from 1
89	changed chan struct{}   // closed and replaced on every append and on close
90	closed  bool
91	watches int
92	clients map[string]int // watches in progress by client
93}
94
95var _ ports.EventService = (*Events)(nil)
96
97// EventsConfig configures Events.
98type EventsConfig struct {
99	// Size is the number of events remembered; DefaultEventBuffer when not
100	// positive.
101	Size int
102	// Auth confirms that the token of a watcher still exists. Required.
103	Auth ports.Revalidator
104	// RevalidateEvery is how long the answer of Auth is kept for a watcher;
105	// DefaultRevalidateEvery when zero, and negative to ask for every event.
106	RevalidateEvery time.Duration
107}
108
109// NewEvents subscribes to the bus and starts remembering its events. ctx
110// carries the logger; its cancellation is not followed, call Close.
111func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, clock ports.Clock, cfg EventsConfig) *Events {
112	size := cfg.Size
113	if size <= 0 {
114		size = DefaultEventBuffer
115	}
116	every := cfg.RevalidateEvery
117	if every == 0 {
118		every = DefaultRevalidateEvery
119	}
120	run, stop := context.WithCancel(context.WithoutCancel(ctx))
121	e := &Events{
122		repos:   repos,
123		auth:    cfg.Auth,
124		clock:   clock,
125		every:   every,
126		clients: make(map[string]int),
127		boot:    strconv.FormatInt(clock.Now().UnixMilli(), 36),
128		size:    size,
129		stop:    stop,
130		done:    make(chan struct{}),
131		nextSeq: 1,
132		changed: make(chan struct{}),
133	}
134	sub := bus.Subscribe(run) // before returning: nothing published after is missed
135	go e.feed(run, sub)
136	return e
137}
138
139// feed appends the events of the bus until it stops.
140func (e *Events) feed(ctx context.Context, sub <-chan domain.Event) {
141	defer close(e.done)
142	defer e.shutdown()
143	for {
144		select {
145		case ev, ok := <-sub:
146			if !ok {
147				return
148			}
149			e.append(ctx, ev)
150		case <-ctx.Done():
151			return
152		}
153	}
154}
155
156// snapshot records the visibility and the generation of the repository an
157// event is about. Every event of the repository carries them.
158func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
159	switch ev := ev.(type) {
160	case domain.RepoCreated:
161		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.At}
162	case domain.RepoUpdated:
163		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
164	case domain.RepoDeleted:
165		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
166	case domain.PushEvent:
167		if ev.CreatedAt.IsZero() {
168			return bufferedEvent{} // repository unknown at publish time: nobody is told
169		}
170		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
171	case domain.MergeRequestOpened:
172		return repoSnapshot(ev.Visibility, ev.CreatedAt)
173	case domain.MergeRequestMerged:
174		return repoSnapshot(ev.Visibility, ev.CreatedAt)
175	case domain.MergeRequestClosed:
176		return repoSnapshot(ev.Visibility, ev.CreatedAt)
177	case domain.MergeRequestReady:
178		return repoSnapshot(ev.Visibility, ev.CreatedAt)
179	case domain.CommentAdded:
180		return repoSnapshot(ev.Visibility, ev.CreatedAt)
181	case domain.RunUpdated:
182		return repoSnapshot(ev.Visibility, ev.CreatedAt)
183	case domain.JobUpdated:
184		return repoSnapshot(ev.Visibility, ev.CreatedAt)
185	}
186	return bufferedEvent{known: true}
187}
188
189// repoSnapshot is the snapshot of an event that carries the visibility and the
190// creation time of its repository; a zero creation time means the repository
191// was unknown when it was published, and nobody is told.
192func repoSnapshot(vis domain.Visibility, created time.Time) bufferedEvent {
193	if created.IsZero() {
194		return bufferedEvent{}
195	}
196	return bufferedEvent{known: true, vis: vis, created: created}
197}
198
199func (e *Events) append(ctx context.Context, ev domain.Event) {
200	be := e.snapshot(ctx, ev) // outside the lock: it reads the store
201	e.mu.Lock()
202	defer e.mu.Unlock()
203	if len(e.ring) == e.size {
204		copy(e.ring, e.ring[1:])
205		e.ring = e.ring[:e.size-1]
206	}
207	be.seq, be.ev = e.nextSeq, ev
208	e.ring = append(e.ring, be)
209	e.nextSeq++
210	e.wake()
211}
212
213// wake releases the watchers waiting for news. The caller holds mu.
214func (e *Events) wake() {
215	close(e.changed)
216	e.changed = make(chan struct{})
217}
218
219func (e *Events) shutdown() {
220	e.mu.Lock()
221	defer e.mu.Unlock()
222	if !e.closed {
223		e.closed = true
224		e.wake()
225	}
226}
227
228// Close ends every watch cleanly, stops feeding and waits for the feeder,
229// until ctx ends. It is safe to call more than once.
230func (e *Events) Close(ctx context.Context) {
231	e.stop()
232	select {
233	case <-e.done:
234	case <-ctx.Done():
235	}
236}
237
238// Watchers returns the number of watches in progress.
239func (e *Events) Watchers() int {
240	e.mu.Lock()
241	defer e.mu.Unlock()
242	return e.watches
243}
244
245func (e *Events) id(seq uint64) string { return e.boot + "-" + strconv.FormatUint(seq, 10) }
246
247// parseID returns the sequence number of an id of this run.
248func (e *Events) parseID(id string) (uint64, bool) {
249	boot, seq, ok := strings.Cut(id, "-")
250	if !ok || boot != e.boot {
251		return 0, false
252	}
253	n, err := strconv.ParseUint(seq, 10, 64)
254	return n, err == nil && n > 0
255}
256
257// watch is the state of one Watch call.
258type watch struct {
259	p     domain.Principal
260	home  bool
261	repos map[domain.RepoName]bool
262
263	checked time.Time // when the token store last confirmed the principal
264}
265
266func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
267
268// parseTopics validates the topics of a watch. A repository topic needs a
269// repository the principal may read (else domain.ErrNotFound); an unknown
270// topic, or more than MaxWatchTopics, is domain.ErrInvalid.
271func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {
272	if len(topics) > MaxWatchTopics {
273		return nil, fmt.Errorf("%w: %d topics, at most %d", domain.ErrInvalid, len(topics), MaxWatchTopics)
274	}
275	w := &watch{p: p, repos: make(map[domain.RepoName]bool), checked: time.Now()}
276	if len(topics) == 0 {
277		w.home = true
278	}
279	for _, t := range topics {
280		if t == TopicHome {
281			w.home = true
282			continue
283		}
284		raw, ok := strings.CutPrefix(t, topicRepoPrefix)
285		if !ok {
286			return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
287		}
288		name, err := domain.ParseRepoName(raw)
289		if err != nil {
290			return nil, err
291		}
292		if _, err := e.repos.Get(ctx, p, name); err != nil {
293			return nil, err
294		}
295		w.repos[name] = true
296	}
297	return w, nil
298}
299
300// Watch calls send for the events the principal may see until ctx ends, the
301// buffer closes (both return nil) or send fails (its error is returned).
302//
303// A principal with a stored token is checked again for every event, and every
304// few seconds when idle: when the token expired or was revoked the watch ends
305// with domain.ErrUnauthenticated and nothing more is sent. More watches than
306// MaxWatchers, or than MaxWatchersPerClient for one Client, give
307// domain.ErrResourceExhausted.
308//
309// Without ResumeAfter only new events are sent. With it, the buffered events
310// after that id are sent first, then new ones. When the id is no longer
311// buffered, or was not issued by this run, a gap comes first and the oldest
312// buffered event follows. A watcher that falls more than the buffer behind
313// gets a gap too, and carries on with the oldest buffered event.
314func (e *Events) Watch(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error {
315	w, err := e.parseTopics(ctx, p, params.Topics)
316	if err != nil {
317		return err
318	}
319
320	e.mu.Lock()
321	next := e.nextSeq // only new events
322	gap := false
323	if params.ResumeAfter != "" {
324		seq, ok := e.parseID(params.ResumeAfter)
325		oldest := e.nextSeq - uint64(len(e.ring)) // first buffered sequence, nextSeq when empty
326		switch {
327		case ok && seq >= oldest-1 && seq < e.nextSeq:
328			next = seq + 1
329		default:
330			next, gap = oldest, true
331		}
332	}
333	if e.watches >= MaxWatchers || (params.Client != "" && e.clients[params.Client] >= MaxWatchersPerClient) {
334		e.mu.Unlock()
335		return fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted)
336	}
337	e.watches++
338	if params.Client != "" {
339		e.clients[params.Client]++
340	}
341	e.mu.Unlock()
342	defer func() {
343		e.mu.Lock()
344		e.watches--
345		if params.Client != "" {
346			if e.clients[params.Client]--; e.clients[params.Client] == 0 {
347				delete(e.clients, params.Client)
348			}
349		}
350		e.mu.Unlock()
351	}()
352
353	if params.Ready != nil {
354		params.Ready()
355	}
356	log := zerowrap.FromCtx(ctx)
357	log.Debug().Strs("topics", params.Topics).Bool("resume", params.ResumeAfter != "").Bool("gap", gap).Msg("watch started")
358	if gap {
359		if err := send(ports.WatchEvent{Gap: true}); err != nil {
360			return err
361		}
362	}
363	for {
364		if err := e.stillAllowed(ctx, w); err != nil {
365			return err
366		}
367		batch, changed, oldest, lost, closed := e.since(next)
368		if lost {
369			if err := send(ports.WatchEvent{Gap: true}); err != nil {
370				return err
371			}
372			next = oldest
373			continue
374		}
375		for _, be := range batch {
376			if err := e.deliver(ctx, w, be, send); err != nil {
377				return err
378			}
379			next = be.seq + 1
380		}
381		if len(batch) > 0 {
382			continue
383		}
384		if closed {
385			return nil
386		}
387		e.wait(ctx, w, changed)
388		if ctx.Err() != nil {
389			return nil
390		}
391	}
392}
393
394// wait blocks until more events arrive or ctx ends. A watcher with a stored
395// token also wakes up every few seconds, so that a revocation ends the watch
396// of a quiet forge too (the caller checks the token on the next turn).
397func (e *Events) wait(ctx context.Context, w *watch, changed <-chan struct{}) {
398	var idle <-chan time.Time
399	if w.p.TokenID() != "" {
400		t := time.NewTimer(e.idleCheck())
401		defer t.Stop()
402		idle = t.C
403	}
404	select {
405	case <-changed:
406	case <-idle:
407	case <-ctx.Done():
408	}
409}
410
411// idleCheck is how long a watcher with a stored token waits for news before
412// checking its token anyway.
413func (e *Events) idleCheck() time.Duration {
414	if e.every <= 0 {
415		return DefaultRevalidateEvery
416	}
417	return min(e.every, DefaultRevalidateEvery)
418}
419
420// stillAllowed checks that the holder of a stored token may still watch: the
421// token has not expired and, at most every few seconds, still exists. Root and
422// anonymous callers have nothing to lose.
423func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
424	if w.p.TokenID() == "" {
425		return nil
426	}
427	if exp := w.p.ExpiresAt(); !exp.IsZero() && !e.clock.Now().Before(exp) {
428		return domain.ErrUnauthenticated
429	}
430	if e.every > 0 && time.Since(w.checked) < e.every {
431		return nil
432	}
433	if err := e.auth.Revalidate(ctx, w.p); err != nil {
434		if ctx.Err() != nil {
435			return nil
436		}
437		return err
438	}
439	w.checked = time.Now()
440	return nil
441}
442
443// since returns the buffered events from sequence next on, and the channel
444// that closes when more arrive. lost is true when next fell out of the buffer;
445// the caller then restarts from oldest, the first buffered sequence. The
446// returned slice is a copy.
447func (e *Events) since(next uint64) (batch []bufferedEvent, changed <-chan struct{}, oldest uint64, lost, closed bool) {
448	e.mu.Lock()
449	defer e.mu.Unlock()
450	oldest = e.nextSeq - uint64(len(e.ring))
451	if next < oldest {
452		return nil, e.changed, oldest, true, e.closed
453	}
454	batch = append(batch, e.ring[next-oldest:]...)
455	return batch, e.changed, oldest, false, e.closed
456}
457
458// deliver sends the event if the watcher follows its topic and may see it.
459func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(ports.WatchEvent) error) error {
460	if err := e.stillAllowed(ctx, w); err != nil {
461		return err
462	}
463	out := ports.WatchEvent{ID: e.id(be.seq), Event: be.ev}
464	switch ev := be.ev.(type) {
465	case domain.EventGap:
466		// The feeder lost events: tell every watcher.
467		return send(ports.WatchEvent{Gap: true})
468	case domain.RepoDeleted:
469		// The repository is gone: use the visibility it had.
470		if !w.wants(ev.Repo) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
471			return nil
472		}
473		return send(out)
474	case domain.RepoCreated:
475		return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
476	case domain.RepoUpdated:
477		return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
478	case domain.PushEvent:
479		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
480	case domain.MergeRequestOpened:
481		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
482	case domain.MergeRequestMerged:
483		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
484	case domain.MergeRequestClosed:
485		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
486	case domain.MergeRequestReady:
487		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488	case domain.CommentAdded:
489		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490	case domain.RunUpdated:
491		// CI events go on the topic of their repository only, never on home.
492		if !w.repos[ev.Run.Repo] {
493			return nil
494		}
495		return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
496	case domain.JobUpdated:
497		if !w.repos[ev.Job.Repo] {
498			return nil
499		}
500		return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
501	}
502	return nil // an event this version does not stream
503}
504
505// deliverRepo sends an event about a repository if the watcher could read the
506// repository when the event happened and can read it now, and the repository
507// is still the one the event is about. A repository that no longer exists is
508// skipped: its RepoDeleted follows or came before.
509//
510// The exception is a repository that became private: the watchers who could
511// read it before are told once, with its name and its new visibility only,
512// so that what they show can be removed.
513func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
514	if !w.wants(name) || !be.known {
515		return nil
516	}
517	repo, err := lookupRepo(ctx, e.repos.meta, name)
518	switch {
519	case errors.Is(err, domain.ErrNotFound):
520		return nil
521	case ctx.Err() != nil:
522		return nil
523	case err != nil:
524		return fmt.Errorf("check access to %q: %w", name, err)
525	}
526	if repo.CreatedAt.After(be.created) {
527		return nil // the event is about a repository of this name that was deleted
528	}
529	if !canReadAs(w.p, name, be.vis) {
530		if up, ok := be.ev.(domain.RepoUpdated); ok && canReadAs(w.p, name, up.Previous) {
531			out.Repo = &ports.RepoView{Repo: domain.Repo{Name: name, Visibility: up.Visibility}}
532			return send(out)
533		}
534		return nil
535	}
536	if !canRead(w.p, repo) {
537		return nil
538	}
539	if withRepo {
540		view, err := e.repos.View(ctx, repo)
541		if err != nil {
542			return err
543		}
544		out.Repo = &view
545	}
546	return send(out)
547}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close