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
252252A push to the default branch queues a symbol index of its tree, built in
253253the background after the push completes; a tree that is already indexed
254254is 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
256257first, case-sensitive before case-insensitive, as name, kind, path and
257258line; =--limit= and =--cursor= page it, and without them it stops at
258259200 rows. A Go method is listed as =Type.Method= and also matches on
@@ -276,13 +277,18 @@ What is indexed:
276277Languages other than Go are matched a line at a time, so unusual
277278definition shapes are missed. Files over 1 MiB, anything under
278279=vendor/= or =node_modules/=, =*_gen.go=, =*.pb.go=, Go files marked
279=Code generated ... DO NOT EDIT.=, and =*.min.js= are skipped. One run
280stops at 200,000 symbols or two minutes and records the index as
281partial; an index that cannot be built is recorded as failed. Either
282record stands for its tree: a push that changes the tree builds again,
283one that does not is left alone. A repository that has not been pushed
284to since the index existed has none until its next push to the default
285branch. =admin symbols reindex <owner/name>= rebuilds one regardless.
280=Code generated ... DO NOT EDIT.=, and =*.min.js= are skipped, and a
281name longer than 256 bytes is dropped. One run stops at 200,000
282symbols, 32 MiB of names and paths, or two minutes, and publishes what
283it found as a partial index that says which bound it reached.
284
285A new index is written while the previous one stays in use, and
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.
286292
287293On the web, a file viewed at the indexed tree (the default branch's
288294head, 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 {
5959 if path == "" || query == "" {
6060 return c.usage()
6161 }
62 if len(query) > maxQueryLen {
63 return c.fail(protocol.ExitUsage, "query must be 1 to %d characters", maxQueryLen)
62 if len(query) < MinSymbolQuery || len(query) > maxQueryLen {
63 return c.fail(protocol.ExitUsage, "query must be %d to %d characters", MinSymbolQuery, maxQueryLen)
6464 }
6565 if kind != "" && !symbols.ValidKind(kind) {
6666 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 {
7171 }
7272 idx, err := c.Store.SymbolIndexFor(repo.ID)
7373 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 }
7477 return c.fail(protocol.ExitNotFound, "%s has no symbol index yet; one is built after a push to %s", repo.Path(), repo.DefaultBranch)
7578 } else if err != nil {
7679 return c.fail(protocol.ExitFailure, "%v", err)
7780 }
7881 if ref != "" && ref != repo.DefaultBranch {
7982 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) {
8185 return c.fail(protocol.ExitNotFound, "only the default branch, %s, is indexed; %s is not at the indexed tree", repo.DefaultBranch, ref)
8286 }
8387 }
84 if idx.State == "failed" {
85 return c.fail(protocol.ExitFailure, "the symbol index of %s failed: %s", repo.Path(), idx.Note)
86 }
8788 var after int64
8889 if p.key != "" {
89 cursorIdx, id, ok := parseSymbolCursor(p.key)
90 cursorIdx, id, ok := ParseSymbolCursor(p.key)
9091 if !ok {
9192 return c.fail(protocol.ExitUsage, "bad cursor")
9293 }
9394 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)
9596 }
9697 after = id
9798 }
@@ -108,7 +109,7 @@ func runRepoSymbols(c *Ctx, args []string) int {
108109 rows = rows[:symbolsUnpaged]
109110 }
110111 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)
112113 })
113114 var ds []symbolOut
114115 for _, r := range rows {
@@ -129,10 +130,24 @@ func runRepoSymbols(c *Ctx, args []string) int {
129130 })
130131}
131132
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
133148// cursor from before a rebuild fail rather than page through the new
134149// index from an unrelated row.
135func parseSymbolCursor(key string) (int64, int64, bool) {
150func ParseSymbolCursor(key string) (int64, int64, bool) {
136151 a, b, ok := strings.Cut(key, ".")
137152 if !ok {
138153 return 0, 0, false
@@ -142,15 +157,11 @@ func parseSymbolCursor(key string) (int64, int64, bool) {
142157 return idx, id, err1 == nil && err2 == nil && id > 0
143158}
144159
145// IndexedTree reports whether ref names a commit whose tree is the one
146// idx was built from: the default branch's head when the index is
147// current, or any other commit with the same content. The blob view uses
148// it to decide whether its names can link into the index.
149func IndexedTree(dir, ref string, idx store.SymbolIndex) bool {
150 sha, err := gitutil.ResolveRef(dir, ref)
151 if err != nil {
152 return false
153 }
160// IndexedTree reports whether commit sha has the tree idx was built from:
161// the default branch's head when the index is current, or any other
162// commit with the same content. The blob view uses it to decide whether
163// its names can link into the index.
164func IndexedTree(dir, sha string, idx store.SymbolIndex) bool {
154165 if sha == idx.Commit {
155166 return true
156167 }
internal/control/symbols_test.go +1
@@ -114,6 +114,7 @@ func TestRepoSymbolsRefusals(t *testing.T) {
114114 {"another ref", alice, []string{"repo", "symbols", repo.Path(), "--ref", "feature", "Parse"}, protocol.ExitNotFound, "only the default branch"},
115115 {"unknown kind", alice, []string{"repo", "symbols", repo.Path(), "--kind", "widget", "Parse"}, protocol.ExitUsage, "--kind"},
116116 {"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"},
117118 {"reindex needs an admin", alice, []string{"admin", "symbols", "reindex", repo.Path()}, protocol.ExitDenied, "admin"},
118119 } {
119120 t.Run(tc.name, func(t *testing.T) {
internal/httpd/symbols.go +25 −12
@@ -2,6 +2,7 @@ package httpd
22
33import (
44 "errors"
5 "fmt"
56 "html/template"
67 "net/http"
78 "net/url"
@@ -29,14 +30,14 @@ func escapePath(p string) string {
2930}
3031
3132// 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// the indexed tree. A name defined once links to that line at the same
33// highlighted source to their definitions, when commit, the ref being
34// viewed as the page resolved it, has the indexed tree. A name defined once links to that line at the same
3435// ref; one defined more than once links to the results page. Names the
3536// index does not hold stay plain. The whole page costs one lookup of the
3637// 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) {
3839 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) {
4041 return code, nil
4142 }
4243 list, _ := s.st.SymbolsInFile(idx.ID, filePath)
@@ -101,20 +102,32 @@ func (s *Server) symbolsPage(w http.ResponseWriter, r *http.Request) {
101102 switch {
102103 case errors.Is(err, store.ErrNotFound):
103104 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 }
104108 case err != nil:
105109 http.Error(w, "internal error", http.StatusInternalServerError)
106110 return
107 case idx.State == "failed":
108 problem = "the symbol index failed: " + idx.Note
109 case len(q) > 200:
110 problem = "query must be 1 to 200 characters"
111 case q != "":
111 case q == "":
112 case len(q) < control.MinSymbolQuery || len(q) > 200:
113 problem = fmt.Sprintf("query must be %d to 200 characters", control.MinSymbolQuery)
114 default:
112115 if idx.State == "partial" {
113116 note = "the index is partial: " + idx.Note
114117 }
115118 var after int64
116 if a, err := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64); err == nil && a > 0 {
117 after = a
119 if a := r.URL.Query().Get("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
118131 }
119132 rows, err = s.st.SearchSymbols(idx.ID, q, kind, symbolsPageSize+1, after)
120133 if err != nil {
@@ -123,7 +136,7 @@ func (s *Server) symbolsPage(w http.ResponseWriter, r *http.Request) {
123136 }
124137 if len(rows) > symbolsPageSize {
125138 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)}}
127140 if kind != "" {
128141 v.Set("kind", kind)
129142 }
internal/httpd/symbols_test.go +10 −3
@@ -62,8 +62,7 @@ func symbolServer(t *testing.T) *Server {
6262 symbolGit(t, src, "add", ".")
6363 symbolGit(t, src, "commit", "-q", "-m", "side")
6464
65 w := &symbols.Worker{St: st, RepoDir: func(owner, name string) string { return control.RepoDir(root, owner, name) },
66 MaxSymbols: symbols.DefaultMaxSymbols, MaxTime: symbols.DefaultMaxTime}
65 w := symbols.NewWith(st, func(owner, name string) string { return control.RepoDir(root, owner, name) }, 0)
6766 for _, r := range []struct{ name, vis string }{{"app", "public"}, {"secret", "private"}} {
6867 id, err := st.CreateRepo("user", uid, r.name, r.vis)
6968 if err != nil {
@@ -73,7 +72,7 @@ func symbolServer(t *testing.T) *Server {
7372 os.MkdirAll(filepath.Dir(dir), 0o755)
7473 symbolGit(t, root, "init", "-q", "--bare", dir)
7574 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 {
7776 t.Fatal(err)
7877 }
7978 }
@@ -124,6 +123,14 @@ func TestSymbolsPage(t *testing.T) {
124123 if strings.Contains(w.Body.String(), "a/dup.go:3") {
125124 t.Error("kind filter ignored")
126125 }
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 }
127134 for _, p := range []string{"/alice/secret/symbols?q=dup", "/alice/secret/blob/main/main.go"} {
128135 if w := get(t, h, p, nil); w.Code != 404 {
129136 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) {
694694 }
695695 p.Tab = "files"
696696 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)
698705 if err != nil {
699706 s.notFound(w, r)
700707 return
@@ -708,7 +715,7 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
708715 }
709716 var fileSymbols []store.SymbolRow
710717 if !binary && !image {
711 codeHTML, fileSymbols = s.blobSymbols(p, filePath, codeHTML)
718 codeHTML, fileSymbols = s.blobSymbols(p, commit, filePath, codeHTML)
712719 }
713720 // Markdown and org render like a README, with the source one click
714721 // away; ?view=source shows the text instead.
internal/store/migrations/0072_symbols.down.sql +1
@@ -1,3 +1,4 @@
11DROP TABLE symbol_requests;
2DROP TABLE symbol_failures;
23DROP TABLE symbols;
34DROP 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--
111-- AUTOINCREMENT because a replaced index must not hand its id to the
212-- next: a paging cursor names the index it was taken from.
313CREATE TABLE symbol_indexes (
414 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,
616 commit_sha TEXT NOT NULL,
717 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')),
919 note TEXT NOT NULL DEFAULT '',
1020 files INTEGER NOT NULL DEFAULT 0,
1121 symbols INTEGER NOT NULL DEFAULT 0,
1222 built_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
1323);
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');
1427-- name is what is listed, key the name as written at a use site (they
1528-- differ for Go methods: Type.Method and Method). lname and lkey are
1629-- their lower-case forms, for case-insensitive prefix ranges.
@@ -34,12 +42,23 @@ CREATE INDEX symbols_lname ON symbols(index_id, lname);
3442CREATE INDEX symbols_lkey ON symbols(index_id, lkey);
3543CREATE INDEX symbols_key ON symbols(index_id, key);
3644CREATE 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);
3753-- Repositories waiting for the index worker. seq counts requests, so a
3854-- 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.
3957CREATE TABLE symbol_requests (
4058 repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
4159 seq INTEGER NOT NULL DEFAULT 1,
4260 force INTEGER NOT NULL DEFAULT 0,
61 attempts INTEGER NOT NULL DEFAULT 0,
62 not_before TEXT NOT NULL DEFAULT '',
4363 requested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
4464);
internal/store/symbols.go +199 −38
@@ -3,24 +3,32 @@ package store
33import (
44 "database/sql"
55 "errors"
6 "fmt"
67 "strings"
78 "unicode/utf8"
89)
910
10// SymbolIndex is a repository's symbol index: what was indexed and how it
11// went. The states are described with the table's schema.
11// SymbolIndex is one symbol index of a repository. The states are
12// described with the table's schema; reads only ever see ok or partial.
1213type SymbolIndex struct {
1314 ID int64
1415 RepoID int64
1516 Commit string
1617 Tree string
17 State string // ok | partial | failed
18 State string // building | ok | partial | retired
1819 Note string
1920 Files int
2021 Symbols int
2122 BuiltAt string
2223}
2324
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
2432// SymbolRow is one definition. Key is the name as written where it is
2533// used; see internal/symbols.
2634type SymbolRow struct {
@@ -42,26 +50,31 @@ type SymbolTarget struct {
4250
4351// SymbolRequest is a repository waiting for the index worker.
4452type SymbolRequest struct {
45 RepoID int64
46 Seq int64
47 Force bool
53 RepoID int64
54 Seq int64
55 Force bool
56 Attempts int
4857}
4958
5059// RequestSymbolIndex queues a repository for the index worker. A request
5160// 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.
5363func (s *Store) RequestSymbolIndex(repoID int64, force bool) error {
5464 _, err := s.DB.Exec(`
5565 INSERT INTO symbol_requests (repo_id, force) VALUES (?, ?)
5666 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 = '',
5868 requested_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, repoID, force)
5969 return err
6070}
6171
62// SymbolRequests lists the waiting repositories, oldest request first.
72// SymbolRequests lists the requests that are due, oldest first.
6373func (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`)
6578 if err != nil {
6679 return nil, err
6780 }
@@ -69,7 +82,7 @@ func (s *Store) SymbolRequests() ([]SymbolRequest, error) {
6982 var out []SymbolRequest
7083 for rows.Next() {
7184 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 {
7386 return nil, err
7487 }
7588 out = append(out, r)
@@ -84,46 +97,65 @@ func (s *Store) DoneSymbolRequest(r SymbolRequest) error {
8497 return err
8598}
8699
87// SymbolIndexFor returns a repository's index, or ErrNotFound when none
88// has been built.
89func (s *Store) SymbolIndexFor(repoID int64) (SymbolIndex, error) {
100// DeferSymbolRequest keeps a request for another attempt after seconds,
101// unless it was requested again since it was read.
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) {
90113 var x SymbolIndex
91 err := s.DB.QueryRow(`
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)
114 err := row.Scan(&x.ID, &x.RepoID, &x.Commit, &x.Tree, &x.State, &x.Note, &x.Files, &x.Symbols, &x.BuiltAt)
95115 if errors.Is(err, sql.ErrNoRows) {
96116 return x, ErrNotFound
97117 }
98118 return x, err
99119}
100120
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
101146// symbolInsertRows is how many rows one INSERT carries: eight columns
102147// each, well under SQLite's variable limit.
103148const symbolInsertRows = 200
104149
105// ReplaceSymbolIndex swaps a repository's index for a new one in one
106// transaction: readers see the old index or the new, never a mix. The
107// symbols count is taken from syms.
108func (s *Store) ReplaceSymbolIndex(x SymbolIndex, syms []SymbolRow) (int64, error) {
150// AddSymbols writes rows into an index being built, in one transaction.
151// The caller keeps each call to a few thousand rows so the write lock is
152// held briefly.
153func (s *Store) AddSymbols(indexID int64, syms []SymbolRow) error {
109154 tx, err := s.DB.Begin()
110155 if err != nil {
111 return 0, err
156 return err
112157 }
113158 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 }
127159 for len(syms) > 0 {
128160 n := min(len(syms), symbolInsertRows)
129161 var q strings.Builder
@@ -134,14 +166,143 @@ func (s *Store) ReplaceSymbolIndex(x SymbolIndex, syms []SymbolRow) (int64, erro
134166 q.WriteString(",")
135167 }
136168 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)
138170 }
139171 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 {
140265 return 0, err
141266 }
142267 syms = syms[n:]
143268 }
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
145306}
146307
147308// 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 {
113113// convention for generated files.
114114var generatedGo = regexp.MustCompile(`(?m)^// Code generated .* DO NOT EDIT\.$`)
115115
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
116121// Extract returns the definitions in one file, in line order.
117122func Extract(p string, data []byte) []Symbol {
123 var syms []Symbol
118124 switch lang := Language(p); lang {
119125 case "":
120126 return nil
121127 case "go":
122 return extractGo(data)
128 syms = extractGo(data)
123129 case "org", "markdown":
124 return extractHeadings(lang, data)
130 syms = extractHeadings(lang, data)
125131 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 }
127139 }
140 return kept
128141}
129142
130143func extractGo(data []byte) []Symbol {
internal/symbols/worker.go +101 −29
@@ -12,12 +12,20 @@ import (
1212 "gitbay.org/gitbay/internal/store"
1313)
1414
15// Bounds on one index run.
15// Bounds on one index run, and how it writes.
1616const (
1717 // DefaultMaxSymbols is where a repository's index stops growing.
1818 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
1922 // DefaultMaxTime is how long one run may take.
2023 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
2129)
2230
2331// Worker builds the index for each repository that has asked for one:
@@ -28,7 +36,13 @@ type Worker struct {
2836 RepoDir func(owner, name string) string
2937 Tick time.Duration
3038 MaxSymbols int
39 MaxBytes int
3140 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)
3246}
3347
3448func 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 {
3852 tick = d
3953 }
4054 }
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 {
4160 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}
4363}
4464
4565// Run sweeps until ctx ends.
@@ -56,9 +76,10 @@ func (w *Worker) Run(ctx context.Context) {
5676 }
5777}
5878
59// Sweep handles every waiting request once. A request is cleared whatever
60// the outcome, so a repository whose index fails is not retried until
61// it is asked for again.
79// Sweep handles every due request once. A request is cleared whatever
80// the outcome, except that a first failure is kept for one retry after
81// Backoff: a failure is retried once, not in a loop, and again only when
82// something asks.
6283func (w *Worker) Sweep(ctx context.Context) {
6384 reqs, err := w.St.SymbolRequests()
6485 if err != nil {
@@ -69,67 +90,111 @@ func (w *Worker) Sweep(ctx context.Context) {
6990 if ctx.Err() != nil {
7091 return
7192 }
72 err := w.Index(ctx, req.RepoID, req.Force)
93 failed, err := w.Index(ctx, req.RepoID, req.Force)
7394 if ctx.Err() != nil {
7495 return // shutting down: the request stays for the next start
7596 }
7697 if err != nil {
7798 slog.Warn("symbols: indexing", "repo", req.RepoID, "err", err)
7899 }
100 if failed && req.Attempts == 0 {
101 w.St.DeferSymbolRequest(req, int(w.Backoff.Seconds()))
102 continue
103 }
79104 w.St.DoneSymbolRequest(req)
80105 }
81106}
82107
83108// 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.
85// The error is for the log: an index that could not be built is recorded
86// as failed, and one cut short by a bound as partial.
87func (w *Worker) Index(ctx context.Context, repoID int64, force bool) error {
109// A head whose tree is already indexed is left alone unless force is set,
110// and so is one whose tree failed within Backoff. The new index is
111// written in chunks while no read can see it, then published in one
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) {
88116 repo, err := w.St.RepoByID(repoID)
89117 if errors.Is(err, store.ErrNotFound) {
90 return nil
118 return false, nil
91119 } 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
93125 }
94126 dir := w.RepoDir(repo.OwnerName, repo.Name)
95127 commit, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
96128 if err != nil {
97 return nil // no default branch yet: nothing to index
129 return false, nil // no default branch yet: nothing to index
98130 }
99131 tree, err := gitutil.ResolveTree(dir, commit)
100132 if err != nil {
101 return err
133 return false, err
102134 }
103 if cur, err := w.St.SymbolIndexFor(repo.ID); err == nil && cur.Tree == tree && !force {
104 return nil
135 if !force {
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 }
105142 }
106143 x := store.SymbolIndex{RepoID: repo.ID, Commit: commit, Tree: tree, State: "ok"}
107144 syms, files, runErr := w.collect(ctx, dir, tree)
108145 if ctx.Err() != nil {
109 return ctx.Err()
146 return false, ctx.Err()
110147 }
111148 x.Files = files
112149 switch {
113150 case errors.Is(runErr, errSymbolCap):
114151 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)
115154 case errors.Is(runErr, context.DeadlineExceeded):
116155 x.State, x.Note = "partial", fmt.Sprintf("stopped after %s", w.MaxTime)
117156 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
119164 }
120 if _, err := w.St.ReplaceSymbolIndex(x, syms); err != nil {
121 return err
165 for len(syms) > 0 {
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
122183 }
123184 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)
125186 }
126 return nil
187 return false, nil
127188}
128189
129var errSymbolCap = errors.New("symbol cap reached")
190var (
191 errSymbolCap = errors.New("symbol cap reached")
192 errByteBudget = errors.New("byte budget reached")
193)
130194
131195// 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.
133198func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolRow, int, error) {
134199 ctx, cancel := context.WithTimeout(ctx, w.MaxTime)
135200 defer cancel()
@@ -149,8 +214,8 @@ func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolR
149214 shas = append(shas, b.SHA)
150215 }
151216 var out []store.SymbolRow
152 files := 0
153 capped := false
217 files, used := 0, 0
218 var stop error
154219 err = gitutil.CatBlobs(ctx, dir, shas, func(i int, data []byte) bool {
155220 if gitutil.IsBinary(data) {
156221 return true
@@ -158,15 +223,22 @@ func (w *Worker) collect(ctx context.Context, dir, tree string) ([]store.SymbolR
158223 files++
159224 for _, s := range Extract(paths[i], data) {
160225 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
162233 return false
163234 }
235 used += n
164236 out = append(out, store.SymbolRow{Name: s.Name, Key: s.Key, Kind: s.Kind, Path: paths[i], Line: s.Line})
165237 }
166238 return true
167239 })
168 if capped {
169 return out, files, errSymbolCap
240 if stop != nil {
241 return out, files, stop
170242 }
171243 if err != nil && ctx.Err() == nil {
172244 return nil, files, err
internal/symbols/worker_test.go +168 −12
@@ -2,6 +2,7 @@ package symbols
22
33import (
44 "context"
5 "fmt"
56 "os"
67 "os/exec"
78 "path/filepath"
@@ -45,8 +46,7 @@ func newFixture(t *testing.T) *fixture {
4546 }
4647 root := t.TempDir()
4748 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 MaxSymbols: DefaultMaxSymbols, MaxTime: DefaultMaxTime}
49 f.w = NewWith(st, func(owner, name string) string { return f.bare }, 0)
5050 f.git(root, "init", "-q", "-b", repo.DefaultBranch, "src")
5151 f.git(root, "init", "-q", "--bare", f.bare)
5252 return f
@@ -202,22 +202,178 @@ func TestWorkerTimeBound(t *testing.T) {
202202 }
203203}
204204
205// A tree that cannot be read is recorded as failed, and the record stands
206// for that tree: asking again does not retry it.
207func TestWorkerFailureIsRecordedNotRetried(t *testing.T) {
205// breakBlob deletes the loose object of path at the default branch, so
206// the tree cannot be read.
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) {
208220 f := newFixture(t)
209221 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")
211 if err := os.Remove(filepath.Join(f.bare, "objects", blob[:2], blob[2:])); err != nil {
222 good := f.sweep(false)
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 {
212331 t.Fatal(err)
213332 }
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
214365 x := f.sweep(false)
215 if x.State != "failed" || x.Note == "" || x.Symbols != 0 {
216 t.Fatalf("index = %+v, want failed with a note", x)
366 if x.State != "partial" || !strings.Contains(x.Note, "20000 bytes") {
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)
217374 }
218 again := f.sweep(false)
219 if again.ID != x.ID {
220 t.Fatalf("a failed tree was retried: %+v then %+v", x, again)
375 if total > 20_000 || x.Symbols == 0 {
376 t.Errorf("%d symbols holding %d bytes, want some within the budget", x.Symbols, total)
221377 }
222378}
223379