diff --git a/db.go b/db.go index 165b204..b187d3d 100644 --- a/db.go +++ b/db.go @@ -692,7 +692,8 @@ func EnsureIndexes(ctx context.Context, db *sql.DB) error { } // AllAuthors returns the distinct issue authors for the filter datalist: seeded -// users' logins plus any github/guest creator_name. Small set, fetched once. +// users' logins plus any github/guest creator_name. Small set, fetched once at +// startup; app.addAuthor keeps it current as new creators appear. func AllAuthors(ctx context.Context, db *sql.DB) ([]string, error) { rows, err := db.QueryContext(ctx, ` SELECT login FROM users diff --git a/helpers_test.go b/helpers_test.go index 58ec0f1..e3f94c2 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -1,7 +1,9 @@ package main import ( + "slices" "strings" + "sync" "testing" "time" "unicode/utf8" @@ -129,6 +131,34 @@ func TestCacheKeysLabelOrderIndependent(t *testing.T) { } } +func TestAddAuthor(t *testing.T) { + a := &app{} + a.authors.Store(&[]string{"alice", "carol"}) + + a.addAuthor("bob") // new author lands in sorted position + if got, want := *a.authors.Load(), []string{"alice", "bob", "carol"}; !slices.Equal(got, want) { + t.Fatalf("after addAuthor(bob): %v, want %v", got, want) + } + a.addAuthor("bob") // existing author is not duplicated + if got := *a.authors.Load(); len(got) != 3 { + t.Fatalf("addAuthor(bob) twice: %v, want no duplicate", got) + } + // Concurrent first-time creators all survive the CAS race (run with -race). + var wg sync.WaitGroup + names := []string{"dave", "erin", "frank", "grace"} + for _, n := range names { + wg.Add(1) + go func() { + defer wg.Done() + a.addAuthor(n) + }() + } + wg.Wait() + if got := *a.authors.Load(); len(got) != 7 || !slices.IsSorted(got) { + t.Fatalf("after concurrent adds: %v, want 7 sorted authors", got) + } +} + func TestBoolToInt(t *testing.T) { if boolToInt(true) != 1 { t.Error("boolToInt(true) != 1") diff --git a/main.go b/main.go index 236a5a3..913b5a7 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "os" "os/signal" "runtime" + "slices" "strconv" "strings" "sync/atomic" @@ -35,7 +36,7 @@ type app struct { lists *ListCache // shared, version-invalidated rendered-list cache hub *Hub catalog []Label - authors []string // distinct issue authors, for the filter datalist + authors atomic.Pointer[[]string] // distinct issue authors for the filter datalist; copy-on-write (see addAuthor) stats *appStats writeLimit bool // write rate-limit toggle (disable for load tests) writes *writeLimiter // per-identity write budget (keyed by login, not sid) @@ -197,7 +198,6 @@ func main() { lists: newListCache(reads, &version), hub: hub, catalog: catalog, - authors: authors, stats: &appStats{}, writeLimit: *writeLimit, writes: newWriteLimiter(), @@ -209,6 +209,7 @@ func main() { devUser: *devUser, baseURL: strings.TrimRight(base, "/"), } + a.authors.Store(&authors) go a.sample() // ---- public listener: the app, stream, and commands ---- @@ -1016,6 +1017,24 @@ func (a *app) handleComment(w http.ResponseWriter, r *http.Request) { _ = sse.MarshalAndPatchSignals(map[string]any{"draft": "", "_notice": "", "_busy": false}) } +// addAuthor inserts name into the author-facet datalist if it's new. The list +// is a startup snapshot (AllAuthors) refreshed only here, on issue create — +// the one path that can mint an author. Copy-on-write keeps the sorted slice +// immutable, so concurrent renders read it without locking. +func (a *app) addAuthor(name string) { + for { + cur := a.authors.Load() + i, found := slices.BinarySearch(*cur, name) + if found { + return + } + next := slices.Insert(slices.Clone(*cur), i, name) + if a.authors.CompareAndSwap(cur, &next) { + return + } + } +} + // handleCreate inserts a new issue. Unlike comments this changes the issue set, // so the write op sets affectsCounts=true → bumps dataVersion → the count cache // is invalidated and every session's list re-projects with the new row on top. @@ -1087,6 +1106,7 @@ func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) { return } + a.addAuthor(creator) // a first-time creator should appear in the author facet now, not after restart // Close the composer and focus the new issue for its creator. sess.withState(func(st *SessionState) { st.Composing = false diff --git a/render.go b/render.go index 28c5b42..e48f86e 100644 --- a/render.go +++ b/render.go @@ -515,7 +515,7 @@ func (a *app) renderRegions(ctx context.Context, state SessionState, vw viewer) log.Printf("count: %v", cr.err) // non-fatal: fall back to the shown count cr.n, cr.capped = shown, false } - facets, err := renderToString("facets", buildFacets(a.catalog, a.authors, state, shown, cr.n, cr.capped, vw)) + facets, err := renderToString("facets", buildFacets(a.catalog, *a.authors.Load(), state, shown, cr.n, cr.capped, vw)) if err != nil { return regionSet{}, err } diff --git a/tasks/prelaunch-checklist.md b/tasks/prelaunch-checklist.md index c1e21b1..6add213 100644 --- a/tasks/prelaunch-checklist.md +++ b/tasks/prelaunch-checklist.md @@ -105,7 +105,7 @@ Verified clean at review time: `go vet ./...`, `gofmt -l .`, `go test -race ./.. - [x] **P3.5 Document the `sid` trust boundary** — `main.go:364-372`, `main.go:403-424`. Sessions are keyed by a client-chosen sid: knowing another tab's UUID lets you steer its *view* state (not auth — writes use the signed cookie). Acceptable by design; add a code comment stating the boundary so readers don't copy it into an app where it isn't. (Partially addressed by P0.3.) -- [ ] **P3.6 New authors never appear in facets until restart** — `main.go:153` (`a.authors` is a startup snapshot) used at `render.go:514`. Pure staleness, not a race. **Fix or document:** refresh on issue create by a new author, or comment that the datalist is a startup snapshot by design. +- [x] **P3.6 New authors never appear in facets until restart** — `main.go:153` (`a.authors` is a startup snapshot) used at `render.go:514`. Pure staleness, not a race. **Fix or document:** refresh on issue create by a new author, or comment that the datalist is a startup snapshot by design. ## P4 — Nice to have