Commit c528009533

c52800953399a64c7a80d7970a8772dfe17873f8

parent: 7a781d1ccb

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-29 05:56 UTC

symbols: chunked builds, failure records, name and byte bounds

A new index is written in 5000-row transactions under a building id no
read sees, published in one short transaction, and the replaced one is
deleted in chunks; the next run removes an interrupted build. A failed
run is recorded beside the current index, which stays in use, and is
retried once after an hour or on the next push. Names over 256 bytes
are dropped and a run stops at 32 MiB of names and paths. The blob
view resolves its ref once and passes the commit to IndexedTree; repo
symbols and the results page take queries of two characters or more,
and the page's cursor names its index.

Closes #293

Layout: unified · split

.gitbay/wiki/Users.org +14 −8
@@ -252,7 +252,8 @@ gitbay repo unarchive you/project # writes refused, browsing
252A push to the default branch queues a symbol index of its tree, built in 252A push to the default branch queues a symbol index of its tree, built in
253the background after the push completes; a tree that is already indexed 253the background after the push completes; a tree that is already indexed
254is not indexed again. =repo symbols <owner/name> [--kind k] <query>= 254is not indexed again. =repo symbols <owner/name> [--kind k] <query>=
255lists definitions whose name starts with the query, exact matches 255lists definitions whose name starts with the query (two characters
256at least), exact matches
256first, case-sensitive before case-insensitive, as name, kind, path and 257first, case-sensitive before case-insensitive, as name, kind, path and
257line; =--limit= and =--cursor= page it, and without them it stops at 258line; =--limit= and =--cursor= page it, and without them it stops at
258200 rows. A Go method is listed as =Type.Method= and also matches on 259200 rows. A Go method is listed as =Type.Method= and also matches on
@@ -276,13 +277,18 @@ What is indexed:
276Languages other than Go are matched a line at a time, so unusual 277Languages other than Go are matched a line at a time, so unusual
277definition shapes are missed. Files over 1 MiB, anything under 278definition shapes are missed. Files over 1 MiB, anything under
278=vendor/= or =node_modules/=, =*_gen.go=, =*.pb.go=, Go files marked 279=vendor/= or =node_modules/=, =*_gen.go=, =*.pb.go=, Go files marked
279=Code generated ... DO NOT EDIT.=, and =*.min.js= are skipped. One run 280=Code generated ... DO NOT EDIT.=, and =*.min.js= are skipped, and a
280stops at 200,000 symbols or two minutes and records the index as 281name longer than 256 bytes is dropped. One run stops at 200,000
281partial; an index that cannot be built is recorded as failed. Either 282symbols, 32 MiB of names and paths, or two minutes, and publishes what
282record stands for its tree: a push that changes the tree builds again, 283it found as a partial index that says which bound it reached.
283one that does not is left alone. A repository that has not been pushed 284
284to since the index existed has none until its next push to the default 285A new index is written while the previous one stays in use, and
285branch. =admin symbols reindex <owner/name>= rebuilds one regardless. 286replaces it in one step. A run that cannot build an index records why
287and leaves the previous index current; the same tree is tried again
288once after an hour, or when a push changes the tree. A repository that
289has not been pushed to since the index existed has none until its next
290push to the default branch. =admin symbols reindex <owner/name>=
291rebuilds one regardless.
286 292
287On the web, a file viewed at the indexed tree (the default branch's 293On the web, a file viewed at the indexed tree (the default branch's
288head, or any commit with the same tree) links each name the index 294head, or any commit with the same tree) links each name the index
internal/control/symbols.go +31 −20
@@ -59,8 +59,8 @@ func runRepoSymbols(c *Ctx, args []string) int {
59 if path == "" || query == "" { 59 if path == "" || query == "" {
60 return c.usage() 60 return c.usage()
61 } 61 }
62 if len(query) > maxQueryLen { 62 if len(query) < MinSymbolQuery || len(query) > maxQueryLen {
63 return c.fail(protocol.ExitUsage, "query must be 1 to %d characters", maxQueryLen) 63 return c.fail(protocol.ExitUsage, "query must be %d to %d characters", MinSymbolQuery, maxQueryLen)
64 } 64 }
65 if kind != "" && !symbols.ValidKind(kind) { 65 if kind != "" && !symbols.ValidKind(kind) {
66 return c.fail(protocol.ExitUsage, "--kind must be one of %s", strings.Join(symbols.Kinds, ", ")) 66 return c.fail(protocol.ExitUsage, "--kind must be one of %s", strings.Join(symbols.Kinds, ", "))
@@ -71,27 +71,28 @@ func runRepoSymbols(c *Ctx, args []string) int {
71 } 71 }
72 idx, err := c.Store.SymbolIndexFor(repo.ID) 72 idx, err := c.Store.SymbolIndexFor(repo.ID)
73 if errors.Is(err, store.ErrNotFound) { 73 if errors.Is(err, store.ErrNotFound) {
74 if f, ferr := c.Store.SymbolFailureFor(repo.ID); ferr == nil {
75 return c.fail(protocol.ExitFailure, "the symbol index of %s could not be built: %s", repo.Path(), f.Note)
76 }
74 return c.fail(protocol.ExitNotFound, "%s has no symbol index yet; one is built after a push to %s", repo.Path(), repo.DefaultBranch) 77 return c.fail(protocol.ExitNotFound, "%s has no symbol index yet; one is built after a push to %s", repo.Path(), repo.DefaultBranch)
75 } else if err != nil { 78 } else if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err) 79 return c.fail(protocol.ExitFailure, "%v", err)
77 } 80 }
78 if ref != "" && ref != repo.DefaultBranch { 81 if ref != "" && ref != repo.DefaultBranch {
79 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) 82 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
80 if !IndexedTree(dir, ref, idx) { 83 sha, err := gitutil.ResolveRef(dir, ref)
84 if err != nil || !IndexedTree(dir, sha, idx) {
81 return c.fail(protocol.ExitNotFound, "only the default branch, %s, is indexed; %s is not at the indexed tree", repo.DefaultBranch, ref) 85 return c.fail(protocol.ExitNotFound, "only the default branch, %s, is indexed; %s is not at the indexed tree", repo.DefaultBranch, ref)
82 } 86 }
83 } 87 }
84 if idx.State == "failed" {
85 return c.fail(protocol.ExitFailure, "the symbol index of %s failed: %s", repo.Path(), idx.Note)
86 }
87 var after int64 88 var after int64
88 if p.key != "" { 89 if p.key != "" {
89 cursorIdx, id, ok := parseSymbolCursor(p.key) 90 cursorIdx, id, ok := ParseSymbolCursor(p.key)
90 if !ok { 91 if !ok {
91 return c.fail(protocol.ExitUsage, "bad cursor") 92 return c.fail(protocol.ExitUsage, "bad cursor")
92 } 93 }
93 if cursorIdx != idx.ID { 94 if cursorIdx != idx.ID {
94 return c.fail(protocol.ExitUsage, "the symbol index was rebuilt since that cursor; start again without --cursor") 95 return c.fail(protocol.ExitUsage, "%s", StaleSymbolCursor)
95 } 96 }
96 after = id 97 after = id
97 } 98 }
@@ -108,7 +109,7 @@ func runRepoSymbols(c *Ctx, args []string) int {
108 rows = rows[:symbolsUnpaged] 109 rows = rows[:symbolsUnpaged]
109 } 110 }
110 rows, next := trimPage(p, rows, "symbol", func(r store.SymbolRow) string { 111 rows, next := trimPage(p, rows, "symbol", func(r store.SymbolRow) string {
111 return strconv.FormatInt(idx.ID, 10) + "." + strconv.FormatInt(r.ID, 10) 112 return SymbolCursor(idx.ID, r.ID)
112 }) 113 })
113 var ds []symbolOut 114 var ds []symbolOut
114 for _, r := range rows { 115 for _, r := range rows {
@@ -129,10 +130,24 @@ func runRepoSymbols(c *Ctx, args []string) int {
129 }) 130 })
130} 131}
131 132
132// parseSymbolCursor reads "<index id>.<row id>". The index id makes a 133// MinSymbolQuery is the shortest query repo symbols and the results page
134// take: one character matches too much of an index to be worth a page.
135const MinSymbolQuery = 2
136
137// StaleSymbolCursor is the refusal for a cursor taken from an index that
138// has since been replaced.
139const StaleSymbolCursor = "the symbol index was rebuilt since that cursor; start again without it"
140
141// SymbolCursor is the paging key after row id of index indexID, the form
142// ParseSymbolCursor reads.
143func SymbolCursor(indexID, id int64) string {
144 return strconv.FormatInt(indexID, 10) + "." + strconv.FormatInt(id, 10)
145}
146
147// ParseSymbolCursor reads "<index id>.<row id>". The index id makes a
133// cursor from before a rebuild fail rather than page through the new 148// cursor from before a rebuild fail rather than page through the new
134// index from an unrelated row. 149// index from an unrelated row.
135func parseSymbolCursor(key string) (int64, int64, bool) { 150func ParseSymbolCursor(key string) (int64, int64, bool) {
136 a, b, ok := strings.Cut(key, ".") 151 a, b, ok := strings.Cut(key, ".")
137 if !ok { 152 if !ok {
138 return 0, 0, false 153 return 0, 0, false
@@ -142,15 +157,11 @@ func parseSymbolCursor(key string) (int64, int64, bool) {
142 return idx, id, err1 == nil && err2 == nil && id > 0 157 return idx, id, err1 == nil && err2 == nil && id > 0
143} 158}
144 159
145// IndexedTree reports whether ref names a commit whose tree is the one 160// IndexedTree reports whether commit sha has the tree idx was built from:
146// idx was built from: the default branch's head when the index is 161// the default branch's head when the index is current, or any other
147// current, or any other commit with the same content. The blob view uses 162// commit with the same content. The blob view uses it to decide whether
148// it to decide whether its names can link into the index. 163// its names can link into the index.
149func IndexedTree(dir, ref string, idx store.SymbolIndex) bool { 164func IndexedTree(dir, sha string, idx store.SymbolIndex) bool {
150 sha, err := gitutil.ResolveRef(dir, ref)
151 if err != nil {
152 return false
153 }
154 if sha == idx.Commit { 165 if sha == idx.Commit {
155 return true 166 return true
156 } 167 }
internal/control/symbols_test.go +1
@@ -114,6 +114,7 @@ func TestRepoSymbolsRefusals(t *testing.T) {
114 {"another ref", alice, []string{"repo", "symbols", repo.Path(), "--ref", "feature", "Parse"}, protocol.ExitNotFound, "only the default branch"}, 114 {"another ref", alice, []string{"repo", "symbols", repo.Path(), "--ref", "feature", "Parse"}, protocol.ExitNotFound, "only the default branch"},
115 {"unknown kind", alice, []string{"repo", "symbols", repo.Path(), "--kind", "widget", "Parse"}, protocol.ExitUsage, "--kind"}, 115 {"unknown kind", alice, []string{"repo", "symbols", repo.Path(), "--kind", "widget", "Parse"}, protocol.ExitUsage, "--kind"},
116 {"no query", alice, []string{"repo", "symbols", repo.Path()}, protocol.ExitUsage, ""}, 116 {"no query", alice, []string{"repo", "symbols", repo.Path()}, protocol.ExitUsage, ""},
117 {"one-character query", alice, []string{"repo", "symbols", repo.Path(), "P"}, protocol.ExitUsage, "2 to"},
117 {"reindex needs an admin", alice, []string{"admin", "symbols", "reindex", repo.Path()}, protocol.ExitDenied, "admin"}, 118 {"reindex needs an admin", alice, []string{"admin", "symbols", "reindex", repo.Path()}, protocol.ExitDenied, "admin"},
118 } { 119 } {
119 t.Run(tc.name, func(t *testing.T) { 120 t.Run(tc.name, func(t *testing.T) {
internal/httpd/symbols.go +25 −12
@@ -2,6 +2,7 @@ package httpd
2 2
3import ( 3import (
4 "errors" 4 "errors"
5 "fmt"
5 "html/template" 6 "html/template"
6 "net/http" 7 "net/http"
7 "net/url" 8 "net/url"
@@ -29,14 +30,14 @@ func escapePath(p string) string {
29} 30}
30 31
31// blobSymbols finds the viewed file's symbols and links the names in its 32// blobSymbols finds the viewed file's symbols and links the names in its
32// highlighted source to their definitions, when the ref being viewed has 33// highlighted source to their definitions, when commit, the ref being
33// the indexed tree. A name defined once links to that line at the same 34// viewed as the page resolved it, has the indexed tree. A name defined once links to that line at the same
34// ref; one defined more than once links to the results page. Names the 35// ref; one defined more than once links to the results page. Names the
35// index does not hold stay plain. The whole page costs one lookup of the 36// index does not hold stay plain. The whole page costs one lookup of the
36// file's distinct names, however many times each appears. 37// file's distinct names, however many times each appears.
37func (s *Server) blobSymbols(p repoPage, filePath string, code template.HTML) (template.HTML, []store.SymbolRow) { 38func (s *Server) blobSymbols(p repoPage, commit, filePath string, code template.HTML) (template.HTML, []store.SymbolRow) {
38 idx, err := s.st.SymbolIndexFor(p.Repo.ID) 39 idx, err := s.st.SymbolIndexFor(p.Repo.ID)
39 if err != nil || idx.State == "failed" || !control.IndexedTree(p.Dir, p.Ref, idx) { 40 if err != nil || !control.IndexedTree(p.Dir, commit, idx) {
40 return code, nil 41 return code, nil
41 } 42 }
42 list, _ := s.st.SymbolsInFile(idx.ID, filePath) 43 list, _ := s.st.SymbolsInFile(idx.ID, filePath)
@@ -101,20 +102,32 @@ func (s *Server) symbolsPage(w http.ResponseWriter, r *http.Request) {
101 switch { 102 switch {
102 case errors.Is(err, store.ErrNotFound): 103 case errors.Is(err, store.ErrNotFound):
103 problem = "no symbol index yet; one is built after a push to " + p.Repo.DefaultBranch 104 problem = "no symbol index yet; one is built after a push to " + p.Repo.DefaultBranch
105 if f, ferr := s.st.SymbolFailureFor(p.Repo.ID); ferr == nil {
106 problem = "the symbol index could not be built: " + f.Note
107 }
104 case err != nil: 108 case err != nil:
105 http.Error(w, "internal error", http.StatusInternalServerError) 109 http.Error(w, "internal error", http.StatusInternalServerError)
106 return 110 return
107 case idx.State == "failed": 111 case q == "":
108 problem = "the symbol index failed: " + idx.Note 112 case len(q) < control.MinSymbolQuery || len(q) > 200:
109 case len(q) > 200: 113 problem = fmt.Sprintf("query must be %d to 200 characters", control.MinSymbolQuery)
110 problem = "query must be 1 to 200 characters" 114 default:
111 case q != "":
112 if idx.State == "partial" { 115 if idx.State == "partial" {
113 note = "the index is partial: " + idx.Note 116 note = "the index is partial: " + idx.Note
114 } 117 }
115 var after int64 118 var after int64
116 if a, err := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64); err == nil && a > 0 { 119 if a := r.URL.Query().Get("after"); a != "" {
117 after = a 120 cursorIdx, id, ok := control.ParseSymbolCursor(a)
121 switch {
122 case !ok:
123 problem = "bad cursor"
124 case cursorIdx != idx.ID:
125 problem = control.StaleSymbolCursor
126 }
127 after = id
128 }
129 if problem != "" {
130 break
118 } 131 }
119 rows, err = s.st.SearchSymbols(idx.ID, q, kind, symbolsPageSize+1, after) 132 rows, err = s.st.SearchSymbols(idx.ID, q, kind, symbolsPageSize+1, after)
120 if err != nil { 133 if err != nil {
@@ -123,7 +136,7 @@ func (s *Server) symbolsPage(w http.ResponseWriter, r *http.Request) {
123 } 136 }
124 if len(rows) > symbolsPageSize { 137 if len(rows) > symbolsPageSize {
125 rows = rows[:symbolsPageSize] 138 rows = rows[:symbolsPageSize]
126 v := url.Values{"q": {q}, "after": {strconv.FormatInt(rows[len(rows)-1].ID, 10)}} 139 v := url.Values{"q": {q}, "after": {control.SymbolCursor(idx.ID, rows[len(rows)-1].ID)}}
127 if kind != "" { 140 if kind != "" {
128 v.Set("kind", kind) 141 v.Set("kind", kind)
129 } 142 }
internal/httpd/symbols_test.go +10 −3
@@ -62,8 +62,7 @@ func symbolServer(t *testing.T) *Server {
62 symbolGit(t, src, "add", ".") 62 symbolGit(t, src, "add", ".")
63 symbolGit(t, src, "commit", "-q", "-m", "side") 63 symbolGit(t, src, "commit", "-q", "-m", "side")
64 64
65 w := &symbols.Worker{St: st, RepoDir: func(owner, name string) string { return control.RepoDir(root, owner, name) }, 65 w := symbols.NewWith(st, func(owner, name string) string { return control.RepoDir(root, owner, name) }, 0)
66 MaxSymbols: symbols.DefaultMaxSymbols, MaxTime: symbols.DefaultMaxTime}
67 for _, r := range []struct{ name, vis string }{{"app", "public"}, {"secret", "private"}} { 66 for _, r := range []struct{ name, vis string }{{"app", "public"}, {"secret", "private"}} {
68 id, err := st.CreateRepo("user", uid, r.name, r.vis) 67 id, err := st.CreateRepo("user", uid, r.name, r.vis)
69 if err != nil { 68 if err != nil {
@@ -73,7 +72,7 @@ func symbolServer(t *testing.T) *Server {
73 os.MkdirAll(filepath.Dir(dir), 0o755) 72 os.MkdirAll(filepath.Dir(dir), 0o755)
74 symbolGit(t, root, "init", "-q", "--bare", dir) 73 symbolGit(t, root, "init", "-q", "--bare", dir)
75 symbolGit(t, src, "push", "-q", dir, "main", "side") 74 symbolGit(t, src, "push", "-q", dir, "main", "side")
76 if err := w.Index(context.Background(), id, false); err != nil { 75 if _, err := w.Index(context.Background(), id, false); err != nil {
77 t.Fatal(err) 76 t.Fatal(err)
78 } 77 }
79 } 78 }
@@ -124,6 +123,14 @@ func TestSymbolsPage(t *testing.T) {
124 if strings.Contains(w.Body.String(), "a/dup.go:3") { 123 if strings.Contains(w.Body.String(), "a/dup.go:3") {
125 t.Error("kind filter ignored") 124 t.Error("kind filter ignored")
126 } 125 }
126 if w := get(t, h, "/alice/app/symbols?q=d", nil); !strings.Contains(w.Body.String(), "query must be 2 to 200 characters") {
127 t.Error("a one-character query was searched")
128 }
129 // A cursor from an index that has been replaced says so rather than
130 // showing an empty page.
131 if w := get(t, h, "/alice/app/symbols?q=dup&after=999999.1", nil); !strings.Contains(w.Body.String(), "rebuilt since that cursor") {
132 t.Errorf("stale cursor:\n%s", w.Body.String())
133 }
127 for _, p := range []string{"/alice/secret/symbols?q=dup", "/alice/secret/blob/main/main.go"} { 134 for _, p := range []string{"/alice/secret/symbols?q=dup", "/alice/secret/blob/main/main.go"} {
128 if w := get(t, h, p, nil); w.Code != 404 { 135 if w := get(t, h, p, nil); w.Code != 404 {
129 t.Errorf("%s anonymously: %d, want 404", p, w.Code) 136 t.Errorf("%s anonymously: %d, want 404", p, w.Code)
internal/httpd/web.go +9 −2
@@ -694,7 +694,14 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
694 } 694 }
695 p.Tab = "files" 695 p.Tab = "files"
696 filePath := strings.Trim(r.PathValue("path"), "/") 696 filePath := strings.Trim(r.PathValue("path"), "/")
697 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1) 697 // The file and its symbol links are read from one commit, even if the
698 // ref moves while the page renders.
699 commit, err := gitutil.ResolveRef(p.Dir, p.Ref)
700 if err != nil {
701 s.notFound(w, r)
702 return
703 }
704 data, err := gitutil.ReadBlob(p.Dir, commit, filePath, maxRenderBytes+1)
698 if err != nil { 705 if err != nil {
699 s.notFound(w, r) 706 s.notFound(w, r)
700 return 707 return
@@ -708,7 +715,7 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
708 } 715 }
709 var fileSymbols []store.SymbolRow 716 var fileSymbols []store.SymbolRow
710 if !binary && !image { 717 if !binary && !image {
711 codeHTML, fileSymbols = s.blobSymbols(p, filePath, codeHTML) 718 codeHTML, fileSymbols = s.blobSymbols(p, commit, filePath, codeHTML)
712 } 719 }
713 // Markdown and org render like a README, with the source one click 720 // Markdown and org render like a README, with the source one click
714 // away; ?view=source shows the text instead. 721 // away; ?view=source shows the text instead.
internal/store/migrations/0072_symbols.down.sql +1
@@ -1,3 +1,4 @@
1DROP TABLE symbol_requests; 1DROP TABLE symbol_requests;
2DROP TABLE symbol_failures;
2DROP TABLE symbols; 3DROP TABLE symbols;
3DROP TABLE symbol_indexes; 4DROP TABLE symbol_indexes;
internal/store/migrations/0072_symbols.up.sql +27 −2
@@ -1,21 +1,29 @@
1-- Symbol indexes (#293): the definitions found in the tree of a
2-- repository's default branch head. tree is the key: a head whose tree
3-- is the current index's needs no new one.
4--
5-- An index is written in chunks while its state is building, which no
6-- read sees; publishing it makes it ok or partial (a bound was reached,
7-- note says which) and retires the previous one in one short
8-- transaction. Building and retired rows are deleted, symbols first and
9-- in chunks, by the worker. At most one index per repository is current.
10--
1-- AUTOINCREMENT because a replaced index must not hand its id to the 11-- AUTOINCREMENT because a replaced index must not hand its id to the
2-- next: a paging cursor names the index it was taken from. 12-- next: a paging cursor names the index it was taken from.
3CREATE TABLE symbol_indexes ( 13CREATE TABLE symbol_indexes (
4 id INTEGER PRIMARY KEY AUTOINCREMENT, 14 id INTEGER PRIMARY KEY AUTOINCREMENT,
5 repo_id INTEGER NOT NULL UNIQUE REFERENCES repos(id) ON DELETE CASCADE, 15 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6 commit_sha TEXT NOT NULL, 16 commit_sha TEXT NOT NULL,
7 tree TEXT NOT NULL, 17 tree TEXT NOT NULL,
8 state TEXT NOT NULL CHECK (state IN ('ok', 'partial', 'failed')), 18 state TEXT NOT NULL CHECK (state IN ('building', 'ok', 'partial', 'retired')),
9 note TEXT NOT NULL DEFAULT '', 19 note TEXT NOT NULL DEFAULT '',
10 files INTEGER NOT NULL DEFAULT 0, 20 files INTEGER NOT NULL DEFAULT 0,
11 symbols INTEGER NOT NULL DEFAULT 0, 21 symbols INTEGER NOT NULL DEFAULT 0,
12 built_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) 22 built_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
13); 23);
24CREATE INDEX symbol_indexes_repo ON symbol_indexes(repo_id, state);
25CREATE UNIQUE INDEX symbol_indexes_current ON symbol_indexes(repo_id)
26 WHERE state IN ('ok', 'partial');
14-- name is what is listed, key the name as written at a use site (they 27-- name is what is listed, key the name as written at a use site (they
15-- differ for Go methods: Type.Method and Method). lname and lkey are 28-- differ for Go methods: Type.Method and Method). lname and lkey are
16-- their lower-case forms, for case-insensitive prefix ranges. 29-- their lower-case forms, for case-insensitive prefix ranges.
@@ -34,12 +42,23 @@ CREATE INDEX symbols_lname ON symbols(index_id, lname);
34CREATE INDEX symbols_lkey ON symbols(index_id, lkey); 42CREATE INDEX symbols_lkey ON symbols(index_id, lkey);
35CREATE INDEX symbols_key ON symbols(index_id, key); 43CREATE INDEX symbols_key ON symbols(index_id, key);
36CREATE INDEX symbols_path ON symbols(index_id, path, line); 44CREATE INDEX symbols_path ON symbols(index_id, path, line);
45-- The last run that could not build an index, kept apart so the current
46-- index stays in place. Cleared by the next index published.
47CREATE TABLE symbol_failures (
48 repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
49 tree TEXT NOT NULL,
50 note TEXT NOT NULL,
51 failed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
52);
37-- Repositories waiting for the index worker. seq counts requests, so a 53-- Repositories waiting for the index worker. seq counts requests, so a
38-- push that lands while a build runs leaves its request in place; force 54-- push that lands while a build runs leaves its request in place; force
55-- rebuilds even when the tree is the indexed one. not_before defers a
56-- retry after a failure; attempts counts those retries.
39CREATE TABLE symbol_requests ( 57CREATE TABLE symbol_requests (
40 repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE, 58 repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
41 seq INTEGER NOT NULL DEFAULT 1, 59 seq INTEGER NOT NULL DEFAULT 1,
42 force INTEGER NOT NULL DEFAULT 0, 60 force INTEGER NOT NULL DEFAULT 0,
61 attempts INTEGER NOT NULL DEFAULT 0,
62 not_before TEXT NOT NULL DEFAULT '',
43 requested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) 63 requested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
44); 64);
internal/store/symbols.go +199 −38
@@ -3,24 +3,32 @@ package store
3import ( 3import (
4 "database/sql" 4 "database/sql"
5 "errors" 5 "errors"
6 "fmt"
6 "strings" 7 "strings"
7 "unicode/utf8" 8 "unicode/utf8"
8) 9)
9 10
10// SymbolIndex is a repository's symbol index: what was indexed and how it 11// SymbolIndex is one symbol index of a repository. The states are
11// went. The states are described with the table's schema. 12// described with the table's schema; reads only ever see ok or partial.
12type SymbolIndex struct { 13type SymbolIndex struct {
13 ID int64 14 ID int64
14 RepoID int64 15 RepoID int64
15 Commit string 16 Commit string
16 Tree string 17 Tree string
17 State string // ok | partial | failed 18 State string // building | ok | partial | retired
18 Note string 19 Note string
19 Files int 20 Files int
20 Symbols int 21 Symbols int
21 BuiltAt string 22 BuiltAt string
22} 23}
23 24
25// SymbolFailure is the last run that could not build an index.
26type SymbolFailure struct {
27 Tree string
28 Note string
29 FailedAt string
30}
31
24// SymbolRow is one definition. Key is the name as written where it is 32// SymbolRow is one definition. Key is the name as written where it is
25// used; see internal/symbols. 33// used; see internal/symbols.
26type SymbolRow struct { 34type SymbolRow struct {
@@ -42,26 +50,31 @@ type SymbolTarget struct {
42 50
43// SymbolRequest is a repository waiting for the index worker. 51// SymbolRequest is a repository waiting for the index worker.
44type SymbolRequest struct { 52type SymbolRequest struct {
45 RepoID int64 53 RepoID int64
46 Seq int64 54 Seq int64
47 Force bool 55 Force bool
56 Attempts int
48} 57}
49 58
50// RequestSymbolIndex queues a repository for the index worker. A request 59// RequestSymbolIndex queues a repository for the index worker. A request
51// already waiting is bumped, so one taken by a running build is not 60// already waiting is bumped, so one taken by a running build is not
52// cleared when that build ends; force is kept once set. 61// cleared when that build ends, and a deferred retry becomes due now;
62// force is kept once set.
53func (s *Store) RequestSymbolIndex(repoID int64, force bool) error { 63func (s *Store) RequestSymbolIndex(repoID int64, force bool) error {
54 _, err := s.DB.Exec(` 64 _, err := s.DB.Exec(`
55 INSERT INTO symbol_requests (repo_id, force) VALUES (?, ?) 65 INSERT INTO symbol_requests (repo_id, force) VALUES (?, ?)
56 ON CONFLICT (repo_id) DO UPDATE SET seq = seq + 1, 66 ON CONFLICT (repo_id) DO UPDATE SET seq = seq + 1,
57 force = MAX(force, excluded.force), 67 force = MAX(force, excluded.force), attempts = 0, not_before = '',
58 requested_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, repoID, force) 68 requested_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, repoID, force)
59 return err 69 return err
60} 70}
61 71
62// SymbolRequests lists the waiting repositories, oldest request first. 72// SymbolRequests lists the requests that are due, oldest first.
63func (s *Store) SymbolRequests() ([]SymbolRequest, error) { 73func (s *Store) SymbolRequests() ([]SymbolRequest, error) {
64 rows, err := s.DB.Query(`SELECT repo_id, seq, force FROM symbol_requests ORDER BY requested_at, repo_id`) 74 rows, err := s.DB.Query(`
75 SELECT repo_id, seq, force, attempts FROM symbol_requests
76 WHERE not_before = '' OR not_before <= strftime('%Y-%m-%dT%H:%M:%fZ','now')
77 ORDER BY requested_at, repo_id`)
65 if err != nil { 78 if err != nil {
66 return nil, err 79 return nil, err
67 } 80 }
@@ -69,7 +82,7 @@ func (s *Store) SymbolRequests() ([]SymbolRequest, error) {
69 var out []SymbolRequest 82 var out []SymbolRequest
70 for rows.Next() { 83 for rows.Next() {
71 var r SymbolRequest 84 var r SymbolRequest
72 if err := rows.Scan(&r.RepoID, &r.Seq, &r.Force); err != nil { 85 if err := rows.Scan(&r.RepoID, &r.Seq, &r.Force, &r.Attempts); err != nil {
73 return nil, err 86 return nil, err
74 } 87 }
75 out = append(out, r) 88 out = append(out, r)
@@ -84,46 +97,65 @@ func (s *Store) DoneSymbolRequest(r SymbolRequest) error {
84 return err 97 return err
85} 98}
86 99
87// SymbolIndexFor returns a repository's index, or ErrNotFound when none 100// DeferSymbolRequest keeps a request for another attempt after seconds,
88// has been built. 101// unless it was requested again since it was read.
89func (s *Store) SymbolIndexFor(repoID int64) (SymbolIndex, error) { 102func (s *Store) DeferSymbolRequest(r SymbolRequest, seconds int) error {
103 _, err := s.DB.Exec(`
104 UPDATE symbol_requests SET force = 0, attempts = attempts + 1,
105 not_before = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
106 WHERE repo_id = ? AND seq = ?`, fmt.Sprintf("+%d seconds", seconds), r.RepoID, r.Seq)
107 return err
108}
109
110const symbolIndexCols = `id, repo_id, commit_sha, tree, state, note, files, symbols, built_at`
111
112func scanSymbolIndex(row *sql.Row) (SymbolIndex, error) {
90 var x SymbolIndex 113 var x SymbolIndex
91 err := s.DB.QueryRow(` 114 err := row.Scan(&x.ID, &x.RepoID, &x.Commit, &x.Tree, &x.State, &x.Note, &x.Files, &x.Symbols, &x.BuiltAt)
92 SELECT id, repo_id, commit_sha, tree, state, note, files, symbols, built_at
93 FROM symbol_indexes WHERE repo_id = ?`, repoID).
94 Scan(&x.ID, &x.RepoID, &x.Commit, &x.Tree, &x.State, &x.Note, &x.Files, &x.Symbols, &x.BuiltAt)
95 if errors.Is(err, sql.ErrNoRows) { 115 if errors.Is(err, sql.ErrNoRows) {
96 return x, ErrNotFound 116 return x, ErrNotFound
97 } 117 }
98 return x, err 118 return x, err
99} 119}
100 120
121// SymbolIndexFor returns a repository's current index, or ErrNotFound
122// when none has been published.
123func (s *Store) SymbolIndexFor(repoID int64) (SymbolIndex, error) {
124 return scanSymbolIndex(s.DB.QueryRow(`SELECT `+symbolIndexCols+`
125 FROM symbol_indexes WHERE repo_id = ? AND state IN ('ok', 'partial')`, repoID))
126}
127
128// SymbolIndexByID returns an index in any state.
129func (s *Store) SymbolIndexByID(id int64) (SymbolIndex, error) {
130 return scanSymbolIndex(s.DB.QueryRow(`SELECT `+symbolIndexCols+`
131 FROM symbol_indexes WHERE id = ?`, id))
132}
133
134// BeginSymbolIndex creates an index in the building state, which no read
135// sees until PublishSymbolIndex.
136func (s *Store) BeginSymbolIndex(repoID int64, commit, tree string) (int64, error) {
137 res, err := s.DB.Exec(`
138 INSERT INTO symbol_indexes (repo_id, commit_sha, tree, state)
139 VALUES (?, ?, ?, 'building')`, repoID, commit, tree)
140 if err != nil {
141 return 0, err
142 }
143 return res.LastInsertId()
144}
145
101// symbolInsertRows is how many rows one INSERT carries: eight columns 146// symbolInsertRows is how many rows one INSERT carries: eight columns
102// each, well under SQLite's variable limit. 147// each, well under SQLite's variable limit.
103const symbolInsertRows = 200 148const symbolInsertRows = 200
104 149
105// ReplaceSymbolIndex swaps a repository's index for a new one in one 150// AddSymbols writes rows into an index being built, in one transaction.
106// transaction: readers see the old index or the new, never a mix. The 151// The caller keeps each call to a few thousand rows so the write lock is
107// symbols count is taken from syms. 152// held briefly.
108func (s *Store) ReplaceSymbolIndex(x SymbolIndex, syms []SymbolRow) (int64, error) { 153func (s *Store) AddSymbols(indexID int64, syms []SymbolRow) error {
109 tx, err := s.DB.Begin() 154 tx, err := s.DB.Begin()
110 if err != nil { 155 if err != nil {
111 return 0, err 156 return err
112 } 157 }
113 defer tx.Rollback() 158 defer tx.Rollback()
114 if _, err := tx.Exec("DELETE FROM symbol_indexes WHERE repo_id = ?", x.RepoID); err != nil {
115 return 0, err
116 }
117 res, err := tx.Exec(`
118 INSERT INTO symbol_indexes (repo_id, commit_sha, tree, state, note, files, symbols)
119 VALUES (?, ?, ?, ?, ?, ?, ?)`, x.RepoID, x.Commit, x.Tree, x.State, x.Note, x.Files, len(syms))
120 if err != nil {
121 return 0, err
122 }
123 id, err := res.LastInsertId()
124 if err != nil {
125 return 0, err
126 }
127 for len(syms) > 0 { 159 for len(syms) > 0 {
128 n := min(len(syms), symbolInsertRows) 160 n := min(len(syms), symbolInsertRows)
129 var q strings.Builder 161 var q strings.Builder
@@ -134,14 +166,143 @@ func (s *Store) ReplaceSymbolIndex(x SymbolIndex, syms []SymbolRow) (int64, erro
134 q.WriteString(",") 166 q.WriteString(",")
135 } 167 }
136 q.WriteString("(?,?,?,?,?,?,?,?)") 168 q.WriteString("(?,?,?,?,?,?,?,?)")
137 args = append(args, id, r.Name, strings.ToLower(r.Name), r.Key, strings.ToLower(r.Key), r.Kind, r.Path, r.Line) 169 args = append(args, indexID, r.Name, strings.ToLower(r.Name), r.Key, strings.ToLower(r.Key), r.Kind, r.Path, r.Line)
138 } 170 }
139 if _, err := tx.Exec(q.String(), args...); err != nil { 171 if _, err := tx.Exec(q.String(), args...); err != nil {
172 return err
173 }
174 syms = syms[n:]
175 }
176 return tx.Commit()
177}
178
179// PublishSymbolIndex makes a built index the repository's current one, as
180// ok or partial, and retires the one it replaces, in one short
181// transaction. A recorded failure is cleared.
182func (s *Store) PublishSymbolIndex(x SymbolIndex) error {
183 tx, err := s.DB.Begin()
184 if err != nil {
185 return err
186 }
187 defer tx.Rollback()
188 if _, err := tx.Exec(`
189 UPDATE symbol_indexes SET state = 'retired'
190 WHERE repo_id = ? AND state IN ('ok', 'partial')`, x.RepoID); err != nil {
191 return err
192 }
193 res, err := tx.Exec(`
194 UPDATE symbol_indexes SET state = ?, note = ?, files = ?,
195 symbols = (SELECT COUNT(*) FROM symbols WHERE index_id = ?),
196 built_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
197 WHERE id = ? AND repo_id = ? AND state = 'building'`,
198 x.State, x.Note, x.Files, x.ID, x.ID, x.RepoID)
199 if err != nil {
200 return err
201 }
202 if n, _ := res.RowsAffected(); n != 1 {
203 return fmt.Errorf("symbol index %d is not being built", x.ID)
204 }
205 if _, err := tx.Exec("DELETE FROM symbol_failures WHERE repo_id = ?", x.RepoID); err != nil {
206 return err
207 }
208 return tx.Commit()
209}
210
211// symbolDeleteRows is how many symbols one purge transaction deletes.
212const symbolDeleteRows = 5000
213
214// PurgeSymbolIndexes deletes a repository's indexes that are not current:
215// retired ones, and building ones left by a run that did not finish. The
216// symbols go a chunk per transaction, then the index row.
217func (s *Store) PurgeSymbolIndexes(repoID int64) error {
218 rows, err := s.DB.Query(`SELECT id FROM symbol_indexes
219 WHERE repo_id = ? AND state IN ('building', 'retired')`, repoID)
220 if err != nil {
221 return err
222 }
223 var ids []int64
224 for rows.Next() {
225 var id int64
226 if err := rows.Scan(&id); err != nil {
227 rows.Close()
228 return err
229 }
230 ids = append(ids, id)
231 }
232 rows.Close()
233 if err := rows.Err(); err != nil {
234 return err
235 }
236 for _, id := range ids {
237 for {
238 res, err := s.DB.Exec(`DELETE FROM symbols WHERE id IN
239 (SELECT id FROM symbols WHERE index_id = ? LIMIT ?)`, id, symbolDeleteRows)
240 if err != nil {
241 return err
242 }
243 if n, _ := res.RowsAffected(); n == 0 {
244 break
245 }
246 }
247 if _, err := s.DB.Exec(`DELETE FROM symbol_indexes
248 WHERE id = ? AND state IN ('building', 'retired')`, id); err != nil {
249 return err
250 }
251 }
252 return nil
253}
254
255// ReplaceSymbolIndex builds, publishes and purges in one call: the
256// worker's sequence, for a caller holding every row already.
257func (s *Store) ReplaceSymbolIndex(x SymbolIndex, syms []SymbolRow) (int64, error) {
258 id, err := s.BeginSymbolIndex(x.RepoID, x.Commit, x.Tree)
259 if err != nil {
260 return 0, err
261 }
262 for len(syms) > 0 {
263 n := min(len(syms), 5000)
264 if err := s.AddSymbols(id, syms[:n]); err != nil {
140 return 0, err 265 return 0, err
141 } 266 }
142 syms = syms[n:] 267 syms = syms[n:]
143 } 268 }
144 return id, tx.Commit() 269 x.ID = id
270 if err := s.PublishSymbolIndex(x); err != nil {
271 return 0, err
272 }
273 return id, s.PurgeSymbolIndexes(x.RepoID)
274}
275
276// RecordSymbolFailure records a run that could not build an index. The
277// current index, if any, stays current.
278func (s *Store) RecordSymbolFailure(repoID int64, tree, note string) error {
279 _, err := s.DB.Exec(`
280 INSERT INTO symbol_failures (repo_id, tree, note) VALUES (?, ?, ?)
281 ON CONFLICT (repo_id) DO UPDATE SET tree = excluded.tree, note = excluded.note,
282 failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, repoID, tree, note)
283 return err
284}
285
286// SymbolFailureFor returns the last failure, or ErrNotFound.
287func (s *Store) SymbolFailureFor(repoID int64) (SymbolFailure, error) {
288 var f SymbolFailure
289 err := s.DB.QueryRow(`SELECT tree, note, failed_at FROM symbol_failures WHERE repo_id = ?`, repoID).
290 Scan(&f.Tree, &f.Note, &f.FailedAt)
291 if errors.Is(err, sql.ErrNoRows) {
292 return f, ErrNotFound
293 }
294 return f, err
295}
296
297// SymbolFailureRecent reports whether tree failed within the last
298// seconds.
299func (s *Store) SymbolFailureRecent(repoID int64, tree string, seconds int) (bool, error) {
300 var n int
301 err := s.DB.QueryRow(`SELECT COUNT(*) FROM symbol_failures
302 WHERE repo_id = ? AND tree = ?
303 AND failed_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)`,
304 repoID, tree, fmt.Sprintf("-%d seconds", seconds)).Scan(&n)
305 return n > 0, err
145} 306}
146 307
147// SearchSymbols finds the symbols whose name or key starts with q, ignoring 308// SearchSymbols finds the symbols whose name or key starts with q, ignoring
internal/symbols/extract.go +16 −3
@@ -113,18 +113,31 @@ func Language(p string) string {
113// convention for generated files. 113// convention for generated files.
114var generatedGo = regexp.MustCompile(`(?m)^// Code generated .* DO NOT EDIT\.$`) 114var generatedGo = regexp.MustCompile(`(?m)^// Code generated .* DO NOT EDIT\.$`)
115 115
116// MaxNameBytes is the longest name the index keeps. A longer one is
117// dropped: no real definition is named that way, and a file of them is a
118// way to fill the index.
119const MaxNameBytes = 256
120
116// Extract returns the definitions in one file, in line order. 121// Extract returns the definitions in one file, in line order.
117func Extract(p string, data []byte) []Symbol { 122func Extract(p string, data []byte) []Symbol {
123 var syms []Symbol
118 switch lang := Language(p); lang { 124 switch lang := Language(p); lang {
119 case "": 125 case "":
120 return nil 126 return nil
121 case "go": 127 case "go":
122 return extractGo(data) 128 syms = extractGo(data)
123 case "org", "markdown": 129 case "org", "markdown":
124 return extractHeadings(lang, data) 130 syms = extractHeadings(lang, data)
125 default: 131 default:
126 return extractLines(rules[lang], data) 132 syms = extractLines(rules[lang], data)
133 }
134 kept := syms[:0]
135 for _, s := range syms {
136 if len(s.Name) <= MaxNameBytes && len(s.Key) <= MaxNameBytes {
137 kept = append(kept, s)
138 }
127 } 139 }
140 return kept
128} 141}
129 142
130func extractGo(data []byte) []Symbol { 143func extractGo(data []byte) []Symbol {
internal/symbols/worker.go +101 −29
@@ -12,12 +12,20 @@ import (
12 "gitbay.org/gitbay/internal/store" 12 "gitbay.org/gitbay/internal/store"
13) 13)
14 14
15// Bounds on one index run. 15// Bounds on one index run, and how it writes.
16const ( 16const (
17 // DefaultMaxSymbols is where a repository's index stops growing. 17 // DefaultMaxSymbols is where a repository's index stops growing.
18 DefaultMaxSymbols = 200_000 18 DefaultMaxSymbols = 200_000
19 // DefaultMaxBytes bounds the names, keys and paths one index holds, so
20 // a tree of long names cannot fill the database within the count.
21 DefaultMaxBytes = 32 << 20
19 // DefaultMaxTime is how long one run may take. 22 // DefaultMaxTime is how long one run may take.
20 DefaultMaxTime = 2 * time.Minute 23 DefaultMaxTime = 2 * time.Minute
24 // DefaultChunkRows is how many symbols one write transaction carries.
25 DefaultChunkRows = 5000
26 // DefaultBackoff is how long a failed tree waits before it is tried
27 // again.
28 DefaultBackoff = time.Hour
21) 29)
22 30
23// Worker builds the index for each repository that has asked for one: 31// Worker builds the index for each repository that has asked for one:
@@ -28,7 +36,13 @@ type Worker struct {
28 RepoDir func(owner, name string) string 36 RepoDir func(owner, name string) string
29 Tick time.Duration 37 Tick time.Duration
30 MaxSymbols int 38 MaxSymbols int
39 MaxBytes int
31 MaxTime time.Duration 40 MaxTime time.Duration
41 ChunkRows int
42 Backoff time.Duration
43 // chunkHook runs after each chunk is written; tests read the index
44 // mid-build through it.
45 chunkHook func(indexID int64)
32} 46}
33 47
34func New(st *store.Store, repoDir func(owner, name string) string) *Worker { 48func New(st *store.Store, repoDir func(owner, name string) string) *Worker {
@@ -38,8 +52,14 @@ func New(st *store.Store, repoDir func(owner, name string) string) *Worker {
38 tick = d 52 tick = d
39 } 53 }
40 } 54 }
55 return NewWith(st, repoDir, tick)
56}
57
58// NewWith is New with the tick given, and every bound at its default.
59func NewWith(st *store.Store, repoDir func(owner, name string) string, tick time.Duration) *Worker {
41 return &Worker{St: st, RepoDir: repoDir, Tick: tick, 60 return &Worker{St: st, RepoDir: repoDir, Tick: tick,
42 MaxSymbols: DefaultMaxSymbols, MaxTime: DefaultMaxTime} 61 MaxSymbols: DefaultMaxSymbols, MaxBytes: DefaultMaxBytes, MaxTime: DefaultMaxTime,
62 ChunkRows: DefaultChunkRows, Backoff: DefaultBackoff}
43} 63}
44 64
45// Run sweeps until ctx ends. 65// Run sweeps until ctx ends.
@@ -56,9 +76,10 @@ func (w *Worker) Run(ctx context.Context) {
56 } 76 }
57} 77}
58 78
59// Sweep handles every waiting request once. A request is cleared whatever 79// Sweep handles every due request once. A request is cleared whatever
60// the outcome, so a repository whose index fails is not retried until 80// the outcome, except that a first failure is kept for one retry after
61// it is asked for again. 81// Backoff: a failure is retried once, not in a loop, and again only when
82// something asks.
62func (w *Worker) Sweep(ctx context.Context) { 83func (w *Worker) Sweep(ctx context.Context) {
63 reqs, err := w.St.SymbolRequests() 84 reqs, err := w.St.SymbolRequests()
64 if err != nil { 85 if err != nil {
@@ -69,67 +90,111 @@ func (w *Worker) Sweep(ctx context.Context) {
69 if ctx.Err() != nil { 90 if ctx.Err() != nil {
70 return 91 return
71 } 92 }
72 err := w.Index(ctx, req.RepoID, req.Force) 93 failed, err := w.Index(ctx, req.RepoID, req.Force)
73 if ctx.Err() != nil { 94 if ctx.Err() != nil {
74 return // shutting down: the request stays for the next start 95 return // shutting down: the request stays for the next start
75 } 96 }
76 if err != nil { 97 if err != nil {
77 slog.Warn("symbols: indexing", "repo", req.RepoID, "err", err) 98 slog.Warn("symbols: indexing", "repo", req.RepoID, "err", err)
78 } 99 }
100 if failed && req.Attempts == 0 {
101 w.St.DeferSymbolRequest(req, int(w.Backoff.Seconds()))
102 continue
103 }
79 w.St.DoneSymbolRequest(req) 104 w.St.DoneSymbolRequest(req)
80 } 105 }
81} 106}
82 107
83// Index brings one repository's index up to its default branch's head. 108// Index brings one repository's index up to its default branch's head.
84// A head whose tree is already indexed is left alone unless force is set. 109// A head whose tree is already indexed is left alone unless force is set,
85// The error is for the log: an index that could not be built is recorded 110// and so is one whose tree failed within Backoff. The new index is
86// as failed, and one cut short by a bound as partial. 111// written in chunks while no read can see it, then published in one
87func (w *Worker) Index(ctx context.Context, repoID int64, force bool) error { 112// short transaction. A run cut short by a bound publishes what it found
113// as partial; one that cannot build records the failure and leaves the
114// current index in place, reporting failed. The error is for the log.
115func (w *Worker) Index(ctx context.Context, repoID int64, force bool) (failed bool, err error) {
88 repo, err := w.St.RepoByID(repoID) 116 repo, err := w.St.RepoByID(repoID)
89 if errors.Is(err, store.ErrNotFound) { 117 if errors.Is(err, store.ErrNotFound) {
90 return nil 118 return false, nil
91 } else if err != nil { 119 } else if err != nil {
92 return err 120 return false, err
121 }
122 // A run that stopped part way left its building index behind.
123 if err := w.St.PurgeSymbolIndexes(repo.ID); err != nil {
124 return false, err
93 } 125 }
94 dir := w.RepoDir(repo.OwnerName, repo.Name) 126 dir := w.RepoDir(repo.OwnerName, repo.Name)
95 commit, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch) 127 commit, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
96 if err != nil { 128 if err != nil {
97 return nil // no default branch yet: nothing to index 129 return false, nil // no default branch yet: nothing to index
98 } 130 }
99 tree, err := gitutil.ResolveTree(dir, commit) 131 tree, err := gitutil.ResolveTree(dir, commit)
100 if err != nil { 132 if err != nil {
101 return err 133 return false, err
102 } 134 }
103 if cur, err := w.St.SymbolIndexFor(repo.ID); err == nil && cur.Tree == tree && !force { 135 if !force {
104 return nil 136 if cur, err := w.St.SymbolIndexFor(repo.ID); err == nil && cur.Tree == tree {
137 return false, nil
138 }
139 if recent, err := w.St.SymbolFailureRecent(repo.ID, tree, int(w.Backoff.Seconds())); err != nil || recent {
140 return false, err
141 }
105 } 142 }
106 x := store.SymbolIndex{RepoID: repo.ID, Commit: commit, Tree: tree, State: "ok"} 143 x := store.SymbolIndex{RepoID: repo.ID, Commit: commit, Tree: tree, State: "ok"}
107 syms, files, runErr := w.collect(ctx, dir, tree) 144 syms, files, runErr := w.collect(ctx, dir, tree)
108 if ctx.Err() != nil { 145 if ctx.Err() != nil {
109 return ctx.Err() 146 return false, ctx.Err()
110 } 147 }
111 x.Files = files 148 x.Files = files
112 switch { 149 switch {
113 case errors.Is(runErr, errSymbolCap): 150 case errors.Is(runErr, errSymbolCap):
114 x.State, x.Note = "partial", fmt.Sprintf("stopped at %d symbols", w.MaxSymbols) 151 x.State, x.Note = "partial", fmt.Sprintf("stopped at %d symbols", w.MaxSymbols)
152 case errors.Is(runErr, errByteBudget):
153 x.State, x.Note = "partial", fmt.Sprintf("stopped at %d bytes of names and paths", w.MaxBytes)
115 case errors.Is(runErr, context.DeadlineExceeded): 154 case errors.Is(runErr, context.DeadlineExceeded):
116 x.State, x.Note = "partial", fmt.Sprintf("stopped after %s", w.MaxTime) 155 x.State, x.Note = "partial", fmt.Sprintf("stopped after %s", w.MaxTime)
117 case runErr != nil: 156 case runErr != nil:
118 x.State, x.Note, syms = "failed", runErr.Error(), nil 157 if err := w.St.RecordSymbolFailure(repo.ID, tree, runErr.Error()); err != nil {
158 return true, err
159 }
160 return true, fmt.Errorf("%s failed: %v", repo.Path(), runErr)
161 }
162 if x.ID, err = w.St.BeginSymbolIndex(repo.ID, commit, tree); err != nil {
163 return false, err
119 } 164 }
120 if _, err := w.St.ReplaceSymbolIndex(x, syms); err != nil { 165 for len(syms) > 0 {
121 return err 166 if ctx.Err() != nil {
167 return false, ctx.Err() // the next run purges what was written
168 }
169 n := min(len(syms), w.ChunkRows)
170 if err := w.St.AddSymbols(x.ID, syms[:n]); err != nil {
171 return false, err
172 }
173 syms = syms[n:]
174 if w.chunkHook != nil {
175 w.chunkHook(x.ID)
176 }
177 }
178 if err := w.St.PublishSymbolIndex(x); err != nil {
179 return false, err
180 }
181 if err := w.St.PurgeSymbolIndexes(repo.ID); err != nil {
182 return false, err
122 } 183 }
123 if x.State != "ok" { 184 if x.State != "ok" {
124 return fmt.Errorf("%s %s: %s", repo.Path(), x.State, x.Note) 185 return false, fmt.Errorf("%s %s: %s", repo.Path(), x.State, x.Note)
125 } 186 }
126 return nil 187 return false, nil
127} 188}
128 189
129var errSymbolCap = errors.New("symbol cap reached") 190var (
191 errSymbolCap = errors.New("symbol cap reached")
192 errByteBudget = errors.New("byte budget reached")
193)
130 194
131// collect reads every indexable blob in tree and extracts its symbols, 195// collect reads every indexable blob in tree and extracts its symbols,
132// stopping at the symbol cap or the time bound with what it has. 196// stopping at the symbol cap, the byte budget or the time bound with what
197// it has.
133func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolRow, int, error) { 198func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolRow, int, error) {
134 ctx, cancel := context.WithTimeout(ctx, w.MaxTime) 199 ctx, cancel := context.WithTimeout(ctx, w.MaxTime)
135 defer cancel() 200 defer cancel()
@@ -149,8 +214,8 @@ func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolR
149 shas = append(shas, b.SHA) 214 shas = append(shas, b.SHA)
150 } 215 }
151 var out []store.SymbolRow 216 var out []store.SymbolRow
152 files := 0 217 files, used := 0, 0
153 capped := false 218 var stop error
154 err = gitutil.CatBlobs(ctx, dir, shas, func(i int, data []byte) bool { 219 err = gitutil.CatBlobs(ctx, dir, shas, func(i int, data []byte) bool {
155 if gitutil.IsBinary(data) { 220 if gitutil.IsBinary(data) {
156 return true 221 return true
@@ -158,15 +223,22 @@ func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolR
158 files++ 223 files++
159 for _, s := range Extract(paths[i], data) { 224 for _, s := range Extract(paths[i], data) {
160 if len(out) >= w.MaxSymbols { 225 if len(out) >= w.MaxSymbols {
161 capped = true 226 stop = errSymbolCap
227 return false
228 }
229 // The key is counted too: it is stored beside the name.
230 n := len(s.Name) + len(s.Key) + len(paths[i])
231 if used+n > w.MaxBytes {
232 stop = errByteBudget
162 return false 233 return false
163 } 234 }
235 used += n
164 out = append(out, store.SymbolRow{Name: s.Name, Key: s.Key, Kind: s.Kind, Path: paths[i], Line: s.Line}) 236 out = append(out, store.SymbolRow{Name: s.Name, Key: s.Key, Kind: s.Kind, Path: paths[i], Line: s.Line})
165 } 237 }
166 return true 238 return true
167 }) 239 })
168 if capped { 240 if stop != nil {
169 return out, files, errSymbolCap 241 return out, files, stop
170 } 242 }
171 if err != nil && ctx.Err() == nil { 243 if err != nil && ctx.Err() == nil {
172 return nil, files, err 244 return nil, files, err
internal/symbols/worker_test.go +168 −12
@@ -2,6 +2,7 @@ package symbols
2 2
3import ( 3import (
4 "context" 4 "context"
5 "fmt"
5 "os" 6 "os"
6 "os/exec" 7 "os/exec"
7 "path/filepath" 8 "path/filepath"
@@ -45,8 +46,7 @@ func newFixture(t *testing.T) *fixture {
45 } 46 }
46 root := t.TempDir() 47 root := t.TempDir()
47 f := &fixture{t: t, st: st, repo: repo, src: filepath.Join(root, "src"), bare: filepath.Join(root, "app.git")} 48 f := &fixture{t: t, st: st, repo: repo, src: filepath.Join(root, "src"), bare: filepath.Join(root, "app.git")}
48 f.w = &Worker{St: st, RepoDir: func(owner, name string) string { return f.bare }, 49 f.w = NewWith(st, func(owner, name string) string { return f.bare }, 0)
49 MaxSymbols: DefaultMaxSymbols, MaxTime: DefaultMaxTime}
50 f.git(root, "init", "-q", "-b", repo.DefaultBranch, "src") 50 f.git(root, "init", "-q", "-b", repo.DefaultBranch, "src")
51 f.git(root, "init", "-q", "--bare", f.bare) 51 f.git(root, "init", "-q", "--bare", f.bare)
52 return f 52 return f
@@ -202,22 +202,178 @@ func TestWorkerTimeBound(t *testing.T) {
202 } 202 }
203} 203}
204 204
205// A tree that cannot be read is recorded as failed, and the record stands 205// breakBlob deletes the loose object of path at the default branch, so
206// for that tree: asking again does not retry it. 206// the tree cannot be read.
207func TestWorkerFailureIsRecordedNotRetried(t *testing.T) { 207func (f *fixture) breakBlob(path string) {
208 f.t.Helper()
209 blob := f.git(f.bare, "rev-parse", "refs/heads/"+f.repo.DefaultBranch+":"+path)
210 if err := os.Remove(filepath.Join(f.bare, "objects", blob[:2], blob[2:])); err != nil {
211 f.t.Fatal(err)
212 }
213}
214
215// A tree that cannot be read is recorded as a failure beside the current
216// index, which stays current. The request is kept for one retry after the
217// backoff rather than tried again at once; a new request for the same
218// tree inside the backoff is not retried either.
219func TestWorkerFailureKeepsCurrentIndex(t *testing.T) {
208 f := newFixture(t) 220 f := newFixture(t)
209 f.commit(map[string]string{"a.go": "package a\n\nfunc A() {}\n"}) 221 f.commit(map[string]string{"a.go": "package a\n\nfunc A() {}\n"})
210 blob := f.git(f.bare, "rev-parse", "refs/heads/"+f.repo.DefaultBranch+":a.go") 222 good := f.sweep(false)
211 if err := os.Remove(filepath.Join(f.bare, "objects", blob[:2], blob[2:])); err != nil { 223
224 f.commit(map[string]string{"b.go": "package a\n\nfunc B() {}\n"})
225 f.breakBlob("b.go")
226 f.st.RequestSymbolIndex(f.repo.ID, false)
227 f.w.Sweep(context.Background())
228 cur, err := f.st.SymbolIndexFor(f.repo.ID)
229 if err != nil || cur.ID != good.ID {
230 t.Fatalf("current index after a failure = %+v, %v; want %d", cur, err, good.ID)
231 }
232 fail, err := f.st.SymbolFailureFor(f.repo.ID)
233 if err != nil || !strings.Contains(fail.Note, "b.go") {
234 t.Fatalf("failure = %+v, %v", fail, err)
235 }
236 if reqs, _ := f.st.SymbolRequests(); len(reqs) != 0 {
237 t.Fatalf("a failed request is due again at once: %+v", reqs)
238 }
239 var attempts int
240 f.st.DB.QueryRow("SELECT attempts FROM symbol_requests WHERE repo_id = ?", f.repo.ID).Scan(&attempts)
241 if attempts != 1 {
242 t.Fatalf("attempts = %d, want the request kept for one retry", attempts)
243 }
244
245 // Asked again inside the backoff: the failed tree is left alone.
246 f.st.RequestSymbolIndex(f.repo.ID, false)
247 failed, err := f.w.Index(context.Background(), f.repo.ID, false)
248 if failed || err != nil {
249 t.Fatalf("a recently failed tree was retried: %v, %v", failed, err)
250 }
251
252 // After the backoff it is tried again, and still fails.
253 f.w.Backoff = 0
254 if failed, _ := f.w.Index(context.Background(), f.repo.ID, false); !failed {
255 t.Fatal("a failed tree was not retried after the backoff")
256 }
257
258 // A second failure of a retry is the end of it.
259 f.st.DB.Exec("DELETE FROM symbol_requests")
260 f.st.DB.Exec("INSERT INTO symbol_requests (repo_id, attempts) VALUES (?, 1)", f.repo.ID)
261 f.w.Sweep(context.Background())
262 var n int
263 f.st.DB.QueryRow("SELECT COUNT(*) FROM symbol_requests").Scan(&n)
264 if n != 0 {
265 t.Fatal("a failed retry was kept for another")
266 }
267
268 // A push that changes the tree builds, and clears the failure.
269 f.git(f.src, "rm", "-q", "b.go")
270 f.commit(map[string]string{"c.go": "package a\n\nfunc C() {}\n"})
271 next := f.sweep(false)
272 if next.ID == good.ID {
273 t.Fatal("a new tree after a failure was not indexed")
274 }
275 if _, err := f.st.SymbolFailureFor(f.repo.ID); err != store.ErrNotFound {
276 t.Fatalf("failure kept after a good index: %v", err)
277 }
278}
279
280// A new index is written in chunks no read sees: throughout the build the
281// old index is current, and the flip replaces it whole.
282func TestWorkerBuildIsInvisibleUntilPublished(t *testing.T) {
283 f := newFixture(t)
284 f.commit(map[string]string{"a.go": "package a\n\nfunc Old() {}\n"})
285 old := f.sweep(false)
286
287 f.commit(map[string]string{"a.go": "package a\n\nfunc New1() {}\nfunc New2() {}\nfunc New3() {}\n"})
288 f.w.ChunkRows = 1
289 chunks := 0
290 f.w.chunkHook = func(building int64) {
291 chunks++
292 cur, err := f.st.SymbolIndexFor(f.repo.ID)
293 if err != nil || cur.ID != old.ID {
294 t.Errorf("mid-build current index = %+v, %v; want the old one", cur, err)
295 }
296 if got := f.names(cur); len(got) != 1 || got[0] != "a.go:Old" {
297 t.Errorf("mid-build reads %v", got)
298 }
299 if b, _ := f.st.SymbolIndexByID(building); b.State != "building" {
300 t.Errorf("index being built is %q", b.State)
301 }
302 }
303 next := f.sweep(false)
304 if chunks != 3 {
305 t.Fatalf("%d chunks, want one per row", chunks)
306 }
307 if got := f.names(next); len(got) != 3 {
308 t.Fatalf("after the flip: %v", got)
309 }
310 var indexes, rows int
311 f.st.DB.QueryRow("SELECT COUNT(*) FROM symbol_indexes").Scan(&indexes)
312 f.st.DB.QueryRow("SELECT COUNT(*) FROM symbols").Scan(&rows)
313 if indexes != 1 || rows != 3 {
314 t.Fatalf("%d indexes and %d rows left, want the new index alone", indexes, rows)
315 }
316}
317
318// A run that stopped mid-build leaves the old index current, and the next
319// run deletes what it wrote.
320func TestWorkerCleansUpInterruptedBuild(t *testing.T) {
321 f := newFixture(t)
322 f.commit(map[string]string{"a.go": "package a\n\nfunc Old() {}\n"})
323 old := f.sweep(false)
324
325 // What a crash between chunks leaves.
326 orphan, err := f.st.BeginSymbolIndex(f.repo.ID, "c", "t")
327 if err != nil {
328 t.Fatal(err)
329 }
330 if err := f.st.AddSymbols(orphan, []store.SymbolRow{{Name: "Half", Key: "Half", Kind: "function", Path: "x.go", Line: 1}}); err != nil {
212 t.Fatal(err) 331 t.Fatal(err)
213 } 332 }
333 if cur, _ := f.st.SymbolIndexFor(f.repo.ID); cur.ID != old.ID {
334 t.Fatalf("an unfinished build became current: %+v", cur)
335 }
336
337 f.commit(nil) // same tree: nothing to build, but the leftovers go
338 f.sweep(false)
339 if _, err := f.st.SymbolIndexByID(orphan); err != store.ErrNotFound {
340 t.Fatalf("interrupted build still there: %v", err)
341 }
342 var rows int
343 f.st.DB.QueryRow("SELECT COUNT(*) FROM symbols WHERE index_id = ?", orphan).Scan(&rows)
344 if rows != 0 {
345 t.Fatalf("%d rows of the interrupted build left", rows)
346 }
347}
348
349// A hostile tree: names past the length cap are dropped, and long
350// headings stop at the byte budget with a partial index saying so.
351func TestWorkerHostileNames(t *testing.T) {
352 f := newFixture(t)
353 files := map[string]string{}
354 huge := strings.Repeat("x", MaxNameBytes+1)
355 for i := 0; i < 20; i++ {
356 files[fmt.Sprintf("huge%02d.go", i)] = "package a\n\nfunc " + huge + "() {}\nvar " + huge + " int\n"
357 }
358 var md strings.Builder
359 for i := 0; i < 500; i++ {
360 fmt.Fprintf(&md, "# %03d %s\n", i, strings.Repeat("h", 200))
361 }
362 files["notes.md"] = md.String()
363 f.commit(files)
364 f.w.MaxBytes = 20_000
214 x := f.sweep(false) 365 x := f.sweep(false)
215 if x.State != "failed" || x.Note == "" || x.Symbols != 0 { 366 if x.State != "partial" || !strings.Contains(x.Note, "20000 bytes") {
216 t.Fatalf("index = %+v, want failed with a note", x) 367 t.Fatalf("index = %+v, want partial at the byte budget", x)
368 }
369 var longest, total int
370 f.st.DB.QueryRow("SELECT COALESCE(MAX(length(name)), 0), COALESCE(SUM(length(name) + length(key) + length(path)), 0) FROM symbols").
371 Scan(&longest, &total)
372 if longest > MaxNameBytes {
373 t.Errorf("a %d-byte name was kept", longest)
217 } 374 }
218 again := f.sweep(false) 375 if total > 20_000 || x.Symbols == 0 {
219 if again.ID != x.ID { 376 t.Errorf("%d symbols holding %d bytes, want some within the budget", x.Symbols, total)
220 t.Fatalf("a failed tree was retried: %+v then %+v", x, again)
221 } 377 }
222} 378}
223 379