Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Materialise the activity feed in an events table

bca9ec24cbbb938f2d349d5f06e2c1f4fca06fcb · Keenan Nemetz · 9d ago · parent 73a732b1

Materialise the activity feed in an events table

The previous commit read the feed as a UNION over the source tables. It
stopped materialising events in Go, but SQLite still scanned and sorted
the union to find a page, so a page cost the history.

This replaces it with the design the feed actually wants: an `events`
table with one row per event — issue and change opened, comment,
approval, state change, release — carrying the time, actor, title and
assignees the feed renders. Rows are indexed by (repository, time, id),
so a repository page is an index range read; the instance page walks the
global time index and filters by repository, and neither sorts history.
An unfiltered page total is a stored per-repository count.

Maintenance is part of the refresh that already exists:

- an entity's events are replaced when the entity is reloaded, which is
  how a title or assignee edit reaches old events;
- state changes are rebuilt into events for the entities the commits
  name, which also catches a close-and-reopen that leaves the tree where
  it started and an endpoint diff cannot see;
- releases are refreshed from the tags after every push (post-receive)
  and after a release written in the browser, because a tag is a ref and
  no metadata commit announces it;
- the stored count moves with the rows a refresh adds or removes.

Measured with 100,000 events across 50 repositories: a repository page
takes 0.3-0.5 ms (page 1 or page 20), an instance page 0.5-0.9 ms, and an
unfiltered total 0.1 ms. A filtered total is the one query that scans,
at 12 ms for 100,000 events.

On the load harness, instance activity rises from 222 to 482 req/s and
p50 from 18 ms to 8 ms; activity from 577 to 762 req/s. The repository
feed no longer reads tags per request, and nothing reads the metadata
branch to render a page.

server/activity_release_test.go +54

@@ -0,0 +1,54 @@
1 package server_test
2
3 // A release is a tag, so its feed event is not announced by a metadata commit.
4 // Both paths that create a tag must refresh the feed: a push (post-receive) and
5 // the browser's release form (which writes the tag on the server directly).
6
7 import (
8 "net/url"
9 "strings"
10 "testing"
11 )
12
13 func TestActivityShowsAReleasePushedAsATag(t *testing.T) {
14 t.Parallel()
15 f := newFixture(t)
16 f.publish(f.repo)
17 work := workRepo(t, map[string]string{"README.md": "# release\n"})
18 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
19 t.Fatalf("push main: %s", errs)
20 }
21 runGit(t, work, "tag", "-a", "v1.0.0", "-m", "first release\n")
22 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "v1.0.0"); err != nil {
23 t.Fatalf("push tag: %s", errs)
24 }
25
26 _, body := f.get("/r/" + f.repo + "/activity")
27 if !strings.Contains(body, "released") || !strings.Contains(body, "v1.0.0") {
28 t.Fatalf("the pushed tag is not in the activity feed:\n%s", body)
29 }
30 }
31
32 func TestActivityShowsAReleaseCreatedInTheBrowser(t *testing.T) {
33 t.Parallel()
34 f := newFixture(t)
35 f.publish(f.repo)
36 work := workRepo(t, map[string]string{"README.md": "# release\n"})
37 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
38 t.Fatalf("push main: %s", errs)
39 }
40 sess, cookie := f.browserSession(t, f.ownerID)
41 resp, body := f.browserPost(t, "/r/"+f.repo+"/releases", cookie, url.Values{
42 "csrf": {sess.CSRF}, "tag": {"v2.0.0"}, "target": {"main"},
43 "notes": {"second release\n"},
44 })
45 if resp.StatusCode != 303 {
46 t.Fatalf("create release: %d\n%s", resp.StatusCode, body)
47 }
48 // The write refreshes the release events before it answers, so the feed is
49 // current on the next read.
50 _, body = f.get("/r/" + f.repo + "/activity")
51 if !strings.Contains(body, "released") || !strings.Contains(body, "v2.0.0") {
52 t.Fatalf("the browser release is not in the activity feed:\n%s", body)
53 }
54 }