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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Index state changes for the activity feed

4d938fe894d56ff29b87fa0f2ebec50a744f2993 · Keenan Nemetz · 10d ago · parent e2a58f3a

Index state changes for the activity feed

The activity feed found closed, reopened and merged events by reading the
last 200 metadata commit subjects on every render: one git process per
repository, so the instance home with N repositories ran N of them. It was
the last derived read still going to git.

- A `state_changes` table is fed by the same refresh: a delta reads the
  commits in its range, a rebuild reads the whole metadata branch. Rows are
  keyed by the commit, so a retried or overlapping refresh cannot duplicate
  an event, and an ordinal keeps two changes in one second in branch order
  (commit times are seconds; a close and a reopen often share one).
- `Store.StateChanges` returns the events newest first; the feed reads it
  and keeps the metadata log as the fallback for a server without an index
  or a failed read.
- `git.Repo.Log` and `CommitsBetween` treat a limit of zero as no limit,
  which the rebuild needs, and `metadata.ParseStateSubject` is exported so
  the index derives the events without a second copy of the format.

The activity page now starts no metadata log; the instance home and the
instance activity page lose one process per visible repository. On the
load harness with three repositories: activity 496 req/s (p50 7 ms),
instance activity 230 req/s (p50 17 ms), home 202 req/s (p50 20 ms).

Schema version 7 rebuilds each repository on its first read after upgrade.

forge/index/activity_test.go +125

@@ -0,0 +1,125 @@
1 package index
2
3 // State changes are the activity feed's closed/reopened/merged events. They come
4 // from metadata commit subjects, so the index derives them from the commits it
5 // already reads, and a rebuild and a delta must agree.
6
7 import (
8 "testing"
9 "time"
10
11 "github.com/rodent-software/vole/metadata"
12 )
13
14 func TestStateChangesAreIndexedFromCommitSubjects(t *testing.T) {
15 t.Parallel()
16 s, m, g := incrementalRepo(t)
17 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
18 iss := createIndexedIssue(t, g, "Closes and reopens", now, nil, nil)
19 if err := s.Ensure(m); err != nil {
20 t.Fatal(err)
21 }
22 if list, err := s.StateChanges(m.ID, 0); err != nil || len(list) != 0 {
23 t.Fatalf("a new issue produced state changes: %+v, %v", list, err)
24 }
25
26 if _, err := metadata.SetIssueState(g, iss.ID, "closed", "keenan"); err != nil {
27 t.Fatal(err)
28 }
29 if err := s.Ensure(m); err != nil {
30 t.Fatal(err)
31 }
32 list, err := s.StateChanges(m.ID, 0)
33 if err != nil {
34 t.Fatal(err)
35 }
36 if len(list) != 1 {
37 t.Fatalf("state changes = %+v, want one close", list)
38 }
39 if list[0].Kind != "issue" || list[0].ID != iss.ID || list[0].State != "closed" || list[0].Actor != "keenan" {
40 t.Fatalf("close event = %+v", list[0])
41 }
42 if list[0].Time.IsZero() {
43 t.Fatal("close event has no time")
44 }
45
46 if _, err := metadata.SetIssueState(g, iss.ID, "open", "wilbur"); err != nil {
47 t.Fatal(err)
48 }
49 if err := s.Ensure(m); err != nil {
50 t.Fatal(err)
51 }
52 list, err = s.StateChanges(m.ID, 0)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if len(list) != 2 {
57 t.Fatalf("state changes = %+v, want a close and a reopen", list)
58 }
59 // Newest first: the reopen is the first row.
60 if list[0].State != "open" || list[0].Actor != "wilbur" {
61 t.Fatalf("newest event = %+v, want the reopen", list[0])
62 }
63 if list[1].State != "closed" {
64 t.Fatalf("older event = %+v, want the close", list[1])
65 }
66 }
67
68 func TestStateChangesDoNotDuplicateOnARefresh(t *testing.T) {
69 t.Parallel()
70 s, m, g := incrementalRepo(t)
71 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
72 iss := createIndexedIssue(t, g, "One close", now, nil, nil)
73 if _, err := metadata.SetIssueState(g, iss.ID, "closed", "keenan"); err != nil {
74 t.Fatal(err)
75 }
76 for i := 0; i < 3; i++ {
77 if err := s.Ensure(m); err != nil {
78 t.Fatal(err)
79 }
80 }
81 list, err := s.StateChanges(m.ID, 0)
82 if err != nil {
83 t.Fatal(err)
84 }
85 if len(list) != 1 {
86 t.Fatalf("state changes = %+v after three refreshes, want one", list)
87 }
88 }
89
90 func TestStateChangesSurviveARebuild(t *testing.T) {
91 t.Parallel()
92 s, m, g := incrementalRepo(t)
93 now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
94 iss := createIndexedIssue(t, g, "An issue", now, nil, nil)
95 req := createIndexedRequest(t, g, "A change", now.Add(time.Minute), nil)
96 if _, err := metadata.SetIssueState(g, iss.ID, "closed", "keenan"); err != nil {
97 t.Fatal(err)
98 }
99 if _, err := metadata.SetRequestState(g, req.ID, "closed", "keenan"); err != nil {
100 t.Fatal(err)
101 }
102 if err := s.Ensure(m); err != nil {
103 t.Fatal(err)
104 }
105 before, err := s.StateChanges(m.ID, 0)
106 if err != nil || len(before) != 2 {
107 t.Fatalf("incremental state changes = %+v, %v", before, err)
108 }
109
110 if err := s.IndexRepo(m); err != nil {
111 t.Fatal(err)
112 }
113 after, err := s.StateChanges(m.ID, 0)
114 if err != nil {
115 t.Fatal(err)
116 }
117 if len(after) != len(before) {
118 t.Fatalf("rebuilt state changes = %+v, want %d", after, len(before))
119 }
120 for i := range before {
121 if before[i] != after[i] {
122 t.Fatalf("event %d differs: incremental %+v, rebuilt %+v", i, before[i], after[i])
123 }
124 }
125 }