initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
498
backend/scripts/docgen/main.go
Normal file
498
backend/scripts/docgen/main.go
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
// Command docgen analyses every go-* project under a workspace root and imports
|
||||
// audience-segmented documentation for each one into go-doc.
|
||||
//
|
||||
// One-shot sync:
|
||||
//
|
||||
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data
|
||||
//
|
||||
// Continuous sync, re-importing whenever a project's docs or source change:
|
||||
//
|
||||
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data -watch
|
||||
//
|
||||
// Imported pages are wrapped in generated markers. Re-running replaces only the
|
||||
// machine-written region, so prose written outside the markers survives, and a
|
||||
// page that was never generated is left untouched unless -force is passed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-doc/docgen"
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type options struct {
|
||||
root string
|
||||
dataDir string
|
||||
apps string
|
||||
skip string
|
||||
watch bool
|
||||
interval time.Duration
|
||||
dryRun bool
|
||||
force bool
|
||||
importSubdoc bool
|
||||
verbose bool
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var opts options
|
||||
|
||||
flag.StringVar(&opts.root, "root", defaultRoot(), "directory containing the go-* projects")
|
||||
flag.StringVar(&opts.dataDir, "data", storage.ResolveRoot(), "go-doc data directory to import into")
|
||||
flag.StringVar(&opts.apps, "apps", "", "comma-separated project names to limit the run (default: all)")
|
||||
flag.StringVar(&opts.skip, "skip", "go-doc", "comma-separated project names to ignore")
|
||||
flag.BoolVar(&opts.watch, "watch", false, "keep running and re-import when a project changes")
|
||||
flag.DurationVar(&opts.interval, "interval", 5*time.Second, "poll interval for -watch")
|
||||
flag.BoolVar(&opts.dryRun, "dry-run", false, "analyse and render without writing anything")
|
||||
flag.BoolVar(&opts.force, "force", false, "overwrite pages that carry no generated markers")
|
||||
flag.BoolVar(&opts.importSubdoc, "import-subdocs", false, "also import each project's own Markdown as separate go-doc pages")
|
||||
flag.BoolVar(&opts.verbose, "verbose", false, "log at debug level")
|
||||
flag.Parse()
|
||||
|
||||
level := slog.LevelInfo
|
||||
if opts.verbose {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
engine, err := storage.New(opts.dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open the go-doc data directory: %w", err)
|
||||
}
|
||||
|
||||
im := newImporter(opts, engine, logger)
|
||||
|
||||
if !opts.watch {
|
||||
result, err := im.SyncOnce()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.report(logger)
|
||||
if result.Failed > 0 {
|
||||
return fmt.Errorf("%d project(s) failed to import", result.Failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("watching for documentation changes",
|
||||
"root", opts.root,
|
||||
"interval", opts.interval.String(),
|
||||
"data", engine.Root(),
|
||||
"hint", "Ctrl-C to stop",
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(opts.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
changed, err := im.Scan()
|
||||
switch {
|
||||
case err != nil:
|
||||
logger.Error("scan failed", "error", err)
|
||||
case len(changed) == 0:
|
||||
logger.Debug("no changes")
|
||||
default:
|
||||
logger.Info("change detected", "projects", strings.Join(sortedKeys(changed), ", "))
|
||||
result, syncErr := im.Import(sortedKeys(changed))
|
||||
if syncErr != nil {
|
||||
logger.Error("sync failed", "error", syncErr)
|
||||
}
|
||||
result.report(logger)
|
||||
}
|
||||
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRoot() string {
|
||||
if v := strings.TrimSpace(os.Getenv("GO_DOC_WORKSPACE")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "/home/dnoulet/go"
|
||||
}
|
||||
|
||||
// Result summarises one sync pass.
|
||||
type Result struct {
|
||||
Created int
|
||||
Updated int
|
||||
Unchanged int
|
||||
Skipped int
|
||||
Failed int
|
||||
Subdocs int
|
||||
Pages []pageResult
|
||||
}
|
||||
|
||||
func (r Result) report(logger *slog.Logger) {
|
||||
logger.Info("sync complete",
|
||||
"created", r.Created,
|
||||
"updated", r.Updated,
|
||||
"unchanged", r.Unchanged,
|
||||
"skipped", r.Skipped,
|
||||
"subdocs", r.Subdocs,
|
||||
"failed", r.Failed,
|
||||
)
|
||||
for _, page := range r.Pages {
|
||||
attrs := []any{"project", page.Project, "slug", page.Slug, "status", string(page.Status)}
|
||||
if len(page.Notes) > 0 {
|
||||
attrs = append(attrs, "notes", strings.Join(page.Notes, ", "))
|
||||
}
|
||||
if page.Sub {
|
||||
attrs = append(attrs, "subdocument", true)
|
||||
}
|
||||
if page.Status == docgen.StatusSkipped {
|
||||
logger.Warn("skipped", attrs...)
|
||||
continue
|
||||
}
|
||||
logger.Info("imported", attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// pageResult is the outcome for one written page.
|
||||
type pageResult struct {
|
||||
Project string
|
||||
Slug string
|
||||
Status docgen.Status
|
||||
Notes []string
|
||||
Sub bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// importer holds the sync state shared by the one-shot and watch paths.
|
||||
type importer struct {
|
||||
opts options
|
||||
engine *storage.Engine
|
||||
logger *slog.Logger
|
||||
|
||||
only map[string]struct{}
|
||||
skipSet map[string]struct{}
|
||||
|
||||
// seen maps a project name to the signature observed on the previous poll,
|
||||
// which is what makes "did this change?" a cheap map lookup.
|
||||
seen map[string]string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newImporter(opts options, engine *storage.Engine, logger *slog.Logger) *importer {
|
||||
return &importer{
|
||||
opts: opts,
|
||||
engine: engine,
|
||||
logger: logger,
|
||||
only: splitSet(opts.apps),
|
||||
skipSet: splitSet(opts.skip),
|
||||
seen: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func splitSet(raw string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
out[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Discover returns the project names selected by -apps and -skip.
|
||||
func (im *importer) Discover() ([]string, error) {
|
||||
entries, err := os.ReadDir(im.opts.root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read workspace root %s: %w", im.opts.root, err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !entry.IsDir() || !strings.HasPrefix(name, "go-") {
|
||||
continue
|
||||
}
|
||||
if _, skip := im.skipSet[name]; skip {
|
||||
continue
|
||||
}
|
||||
if len(im.only) > 0 {
|
||||
if _, wanted := im.only[name]; !wanted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// Signatures returns the current source signature per project.
|
||||
func (im *importer) Signatures() (map[string]string, error) {
|
||||
names, err := im.Discover()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(names))
|
||||
for _, name := range names {
|
||||
project, err := docgen.Analyze(name, filepath.Join(im.opts.root, name))
|
||||
if err != nil {
|
||||
im.logger.Debug("cannot analyse project", "project", name, "error", err)
|
||||
continue
|
||||
}
|
||||
out[name] = project.Signature
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SyncOnce analyses and imports every discovered project.
|
||||
func (im *importer) SyncOnce() (Result, error) {
|
||||
names, err := im.Discover()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
im.logger.Warn("no go-* projects discovered", "root", im.opts.root)
|
||||
}
|
||||
return im.Import(names)
|
||||
}
|
||||
|
||||
// Scan returns the projects whose signature changed since the previous poll,
|
||||
// including any project seen for the first time. Remembering every signature —
|
||||
// not just the changed ones — keeps a project from re-importing forever once it
|
||||
// has been synced. A project that has left the workspace is forgotten and
|
||||
// reported: its page stays on disk untouched, because a sync that silently
|
||||
// deletes documentation is worse than one that leaves it stale.
|
||||
func (im *importer) Scan() (map[string]string, error) {
|
||||
sigs, err := im.Signatures()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
im.mu.Lock()
|
||||
defer im.mu.Unlock()
|
||||
|
||||
changed := map[string]string{}
|
||||
for name, sig := range sigs {
|
||||
if previous, seen := im.seen[name]; !seen || previous != sig {
|
||||
changed[name] = sig
|
||||
}
|
||||
}
|
||||
for name, sig := range sigs {
|
||||
im.seen[name] = sig
|
||||
}
|
||||
|
||||
var departed []string
|
||||
for name := range im.seen {
|
||||
if _, present := sigs[name]; !present {
|
||||
departed = append(departed, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(departed)
|
||||
for _, name := range departed {
|
||||
delete(im.seen, name)
|
||||
im.logger.Info("project left the workspace; its page was left as is",
|
||||
"project", name,
|
||||
"hint", "delete the page by hand if the project is gone for good",
|
||||
)
|
||||
}
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// Import analyses each named project and writes its page through the storage
|
||||
// engine, so the result is slugified, atomically written and immediately
|
||||
// visible to the API and the frontend.
|
||||
func (im *importer) Import(names []string) (Result, error) {
|
||||
var (
|
||||
result Result
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
for _, name := range names {
|
||||
wg.Add(1)
|
||||
go func(name string) {
|
||||
defer wg.Done()
|
||||
|
||||
pages, subdocs, err := im.importOne(name)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
result.Failed++
|
||||
im.logger.Error("import failed", "project", name, "error", err)
|
||||
return
|
||||
}
|
||||
result.Subdocs += subdocs
|
||||
result.Pages = append(result.Pages, pages...)
|
||||
for _, page := range pages {
|
||||
if page.Sub {
|
||||
continue
|
||||
}
|
||||
switch page.Status {
|
||||
case docgen.StatusCreated:
|
||||
result.Created++
|
||||
case docgen.StatusUpdated:
|
||||
result.Updated++
|
||||
case docgen.StatusUnchanged:
|
||||
result.Unchanged++
|
||||
case docgen.StatusSkipped:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
}(name)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(result.Pages, func(i, j int) bool { return result.Pages[i].Slug < result.Pages[j].Slug })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// importOne builds and writes the page for a single project, plus any sub-pages
|
||||
// when -import-subdocs is set.
|
||||
func (im *importer) importOne(name string) ([]pageResult, int, error) {
|
||||
root := filepath.Join(im.opts.root, name)
|
||||
|
||||
project, err := docgen.Analyze(name, root)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, err := docgen.Render(project)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
slug := storage.Slugify(name)
|
||||
|
||||
status, err := im.write(slug, page)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
out := []pageResult{{
|
||||
Project: name,
|
||||
Slug: slug,
|
||||
Status: status,
|
||||
Notes: notes(project),
|
||||
}}
|
||||
|
||||
if !im.opts.importSubdoc {
|
||||
return out, 0, nil
|
||||
}
|
||||
|
||||
subpages := im.subdocPages(project)
|
||||
for _, sub := range subpages {
|
||||
subStatus, err := im.write(sub.slug, sub.body)
|
||||
if err != nil {
|
||||
im.logger.Warn("subdocument import failed",
|
||||
"project", name, "path", sub.source, "error", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, pageResult{
|
||||
Project: name,
|
||||
Slug: sub.slug,
|
||||
Status: subStatus,
|
||||
Sub: true,
|
||||
})
|
||||
}
|
||||
return out, len(subpages), nil
|
||||
}
|
||||
|
||||
type subdocPage struct {
|
||||
source string
|
||||
slug string
|
||||
body string
|
||||
}
|
||||
|
||||
// subdocPages turns each Markdown file the project already owns into its own
|
||||
// go-doc page, wrapped so a later sync refreshes it instead of orphaning it.
|
||||
func (im *importer) subdocPages(project *docgen.Project) []subdocPage {
|
||||
base := storage.Slugify(project.Name)
|
||||
var out []subdocPage
|
||||
|
||||
for _, doc := range project.Docs {
|
||||
if doc.Path == project.Readme {
|
||||
continue // already the summary of the main page
|
||||
}
|
||||
stem := strings.TrimSuffix(filepath.Base(doc.Path), filepath.Ext(doc.Path))
|
||||
slug := storage.Slugify(project.Name + "-" + stem)
|
||||
if slug == "" || slug == base {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(project.Path, filepath.FromSlash(doc.Path)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"Imported from %s in the %s repository.\n\n---\n\n%s",
|
||||
doc.Path, project.Name, strings.TrimRight(string(data), "\n"),
|
||||
)
|
||||
out = append(out, subdocPage{
|
||||
source: doc.Path,
|
||||
slug: slug,
|
||||
body: docgen.Wrap(project.Name+":"+doc.Path, project.Signature, body),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// write merges a generated page with whatever is already on disk. Existing prose
|
||||
// outside the markers survives; a page with no markers is skipped unless force.
|
||||
func (im *importer) write(slug, page string) (docgen.Status, error) {
|
||||
var existing string
|
||||
if data, err := os.ReadFile(filepath.Join(im.engine.DocsDir(), slug+".md")); err == nil {
|
||||
existing = string(data)
|
||||
}
|
||||
|
||||
merged, status, err := docgen.Merge(existing, page, im.opts.force)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if im.opts.dryRun {
|
||||
im.logger.Debug("dry run", "slug", slug, "status", string(status), "bytes", len(merged))
|
||||
return status, nil
|
||||
}
|
||||
if _, err := im.engine.SaveDoc(slug, merged); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// notes surfaces anything a reader should know about a generated page.
|
||||
func notes(project *docgen.Project) []string {
|
||||
var out []string
|
||||
if project.Readme == "" {
|
||||
out = append(out, "no README found")
|
||||
}
|
||||
if len(project.Routes) == 0 {
|
||||
out = append(out, "no HTTP routes detected")
|
||||
}
|
||||
if len(project.Modules) == 0 {
|
||||
out = append(out, "no go.mod found")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
172
backend/scripts/docgen/main_test.go
Normal file
172
backend/scripts/docgen/main_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func TestScanDetectsNewProjectsAndEditsButSettlesWhenIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
writeProject(t, filepath.Join(root, "go-alpha"))
|
||||
|
||||
im := newImporter(options{root: root}, newEngine(t), discardLogger())
|
||||
|
||||
// A project nobody has seen counts as changed.
|
||||
changed := scan(t, im)
|
||||
if want := []string{"go-alpha"}; !equal(changed, want) {
|
||||
t.Fatalf("first scan = %v, want %v", changed, want)
|
||||
}
|
||||
|
||||
// An untouched project must not re-import on every poll.
|
||||
if changed := scan(t, im); len(changed) != 0 {
|
||||
t.Errorf("idle scan reported %v, want no changes", changed)
|
||||
}
|
||||
|
||||
// A project that appears mid-run is picked up.
|
||||
writeProject(t, filepath.Join(root, "go-beta"))
|
||||
if changed := scan(t, im); !equal(changed, []string{"go-beta"}) {
|
||||
t.Errorf("scan after a new project = %v, want [go-beta]", changed)
|
||||
}
|
||||
|
||||
// An edit to an existing project is picked up, once.
|
||||
writeProject(t, filepath.Join(root, "go-alpha"))
|
||||
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
|
||||
t.Errorf("scan after an edit = %v, want [go-alpha]", changed)
|
||||
}
|
||||
if changed := scan(t, im); len(changed) != 0 {
|
||||
t.Errorf("the edit was reported twice: %v", changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForgetsProjectsThatLeaveTheWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
project := filepath.Join(root, "go-alpha")
|
||||
writeProject(t, project)
|
||||
|
||||
var logs strings.Builder
|
||||
im := newImporter(options{root: root}, newEngine(t),
|
||||
slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
|
||||
if changed := scan(t, im); len(changed) != 1 {
|
||||
t.Fatalf("first scan = %v, want one project", changed)
|
||||
}
|
||||
logs.Reset()
|
||||
|
||||
if err := os.RemoveAll(project); err != nil {
|
||||
t.Fatalf("remove project: %v", err)
|
||||
}
|
||||
|
||||
if changed := scan(t, im); len(changed) != 0 {
|
||||
t.Errorf("a departed project was treated as changed: %v", changed)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "project left the workspace") {
|
||||
t.Errorf("a departed project was not reported:\n%s", logs.String())
|
||||
}
|
||||
|
||||
// Coming back is a change again rather than a silent no-op.
|
||||
writeProject(t, project)
|
||||
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
|
||||
t.Errorf("scan after the project returned = %v, want [go-alpha]", changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHonoursAppsAndSkip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"go-alpha", "go-beta", "go-doc", "not-a-go-app"} {
|
||||
writeProject(t, filepath.Join(root, name))
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts options
|
||||
want []string
|
||||
}{
|
||||
{name: "default skips go-doc", opts: options{root: root, skip: "go-doc"}, want: []string{"go-alpha", "go-beta"}},
|
||||
{name: "-apps narrows the run", opts: options{root: root, skip: "", apps: "go-beta"}, want: []string{"go-beta"}},
|
||||
{name: "no skip at all", opts: options{root: root}, want: []string{"go-alpha", "go-beta", "go-doc"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
im := newImporter(tc.opts, newEngine(t), discardLogger())
|
||||
got, err := im.Discover()
|
||||
if err != nil {
|
||||
t.Fatalf("discover: %v", err)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("discover = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverReportsAMissingWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
im := newImporter(options{root: filepath.Join(t.TempDir(), "absent")}, newEngine(t), discardLogger())
|
||||
if _, err := im.Discover(); err == nil {
|
||||
t.Fatal("a missing workspace should be reported, not silently ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func scan(t *testing.T, im *importer) []string {
|
||||
t.Helper()
|
||||
changed, err := im.Scan()
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(changed))
|
||||
for name := range changed {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func equal(got, want []string) bool {
|
||||
return strings.Join(got, ",") == strings.Join(want, ",")
|
||||
}
|
||||
|
||||
func newEngine(t *testing.T) *storage.Engine {
|
||||
t.Helper()
|
||||
engine, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("provision engine: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
}
|
||||
|
||||
func writeProject(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "internal"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", root, err)
|
||||
}
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.com/" + filepath.Base(root) + "\n\ngo 1.27.1\n",
|
||||
"README.md": "# " + filepath.Base(root) + "\n\nIt serves an HTTP API.\n",
|
||||
"internal/app.go": "package internal\n\n// Run starts the app.\nfunc Run() error { return nil }\n",
|
||||
"internal/app_t.go": "placeholder\n",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
300
backend/scripts/seed_perf_data.go
Normal file
300
backend/scripts/seed_perf_data.go
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// Command seed_perf_data floods the go-doc flat-file store with generated
|
||||
// documentation and feedback so the platform can be exercised under realistic
|
||||
// load.
|
||||
//
|
||||
// go run ./scripts/seed_perf_data.go -data ../data -docs 120 -comments 25 -workers 8
|
||||
//
|
||||
// Every document is a syntactically valid Markdown page carrying all three
|
||||
// audience sections, and every comment is a real JSON record with a real badge.
|
||||
// The generator is deterministic for a given -seed so runs are reproducible.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
dataDir = flag.String("data", storage.ResolveRoot(), "flat-file data directory")
|
||||
docCount = flag.Int("docs", 120, "number of Markdown documents to generate (spec floor: 100)")
|
||||
perDoc = flag.Int("comments", 25, "comments to inject per document")
|
||||
workers = flag.Int("workers", 8, "parallel seeding workers")
|
||||
seed = flag.Int64("seed", 20260101, "PRNG seed for reproducible output")
|
||||
force = flag.Bool("force", false, "overwrite documents that already exist")
|
||||
minBodyKB = flag.Int("min-body-kb", 2, "minimum document body size in KiB")
|
||||
skipTemplate = flag.Bool("skip-go-doc", true, "leave data/docs/go-doc.md untouched")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
if *docCount < 100 && !*force {
|
||||
logger.Warn("doc count is below the 100 document floor from the specification", "docs", *docCount)
|
||||
}
|
||||
|
||||
engine, err := storage.New(*dataDir)
|
||||
if err != nil {
|
||||
logger.Error("cannot open the data directory", "error", err, "data", *dataDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("seeding flat-file store",
|
||||
"data_root", engine.Root(),
|
||||
"docs", *docCount,
|
||||
"comments_per_doc", *perDoc,
|
||||
"total_comments", *docCount**perDoc,
|
||||
"workers", *workers,
|
||||
)
|
||||
|
||||
jobs := make(chan int, *docCount)
|
||||
for i := 0; i < *docCount; i++ {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
var (
|
||||
docsWritten atomic.Int64
|
||||
docsSkipped atomic.Int64
|
||||
commentsWritten atomic.Int64
|
||||
bytesWritten atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
failures atomic.Int64
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for w := 0; w < *workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
// Each worker owns its own PRNG stream, which keeps output
|
||||
// reproducible regardless of scheduling order.
|
||||
rng := rand.New(rand.NewSource(*seed + int64(worker)*7919))
|
||||
|
||||
for index := range jobs {
|
||||
title := documentTitle(index)
|
||||
slug := storage.Slugify(title)
|
||||
|
||||
if *skipTemplate && slug == "go-doc" {
|
||||
docsSkipped.Add(1)
|
||||
continue
|
||||
}
|
||||
if !*force {
|
||||
exists, err := engine.DocExists(slug)
|
||||
if err != nil {
|
||||
failures.Add(1)
|
||||
logger.Error("existence check failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
docsSkipped.Add(1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
body := documentBody(title, slug, *minBodyKB, rng)
|
||||
if _, err := engine.SaveDoc(slug, body); err != nil {
|
||||
failures.Add(1)
|
||||
logger.Error("document write failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
docsWritten.Add(1)
|
||||
bytesWritten.Add(int64(len(body)))
|
||||
|
||||
for n := 0; n < *perDoc; n++ {
|
||||
role := storage.Roles()[rng.Intn(len(storage.Roles()))]
|
||||
comment := storage.CommentInput{
|
||||
Author: authorName(rng, role),
|
||||
Role: string(role),
|
||||
Content: commentBody(role, slug, n, rng),
|
||||
}
|
||||
if _, err := engine.AppendComment(slug, comment); err != nil {
|
||||
failures.Add(1)
|
||||
logger.Error("comment write failed", "slug", slug, "error", err)
|
||||
break
|
||||
}
|
||||
commentsWritten.Add(1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
metas, err := engine.ListDocs()
|
||||
if err != nil {
|
||||
logger.Error("index read failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totalComments, byRole := 0, map[string]int{}
|
||||
for _, meta := range metas {
|
||||
stats, err := engine.Stats(meta.Slug)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totalComments += stats.Total
|
||||
for role, n := range stats.ByRole {
|
||||
byRole[role] += n
|
||||
}
|
||||
}
|
||||
|
||||
rate := 0.0
|
||||
if elapsed > 0 {
|
||||
rate = float64(commentsWritten.Load()) / elapsed.Seconds()
|
||||
}
|
||||
|
||||
logger.Info("seed complete",
|
||||
"elapsed", elapsed.Round(time.Millisecond),
|
||||
"documents_written", docsWritten.Load(),
|
||||
"documents_skipped", docsSkipped.Load(),
|
||||
"documents_indexed", len(metas),
|
||||
"comments_written", commentsWritten.Load(),
|
||||
"comments_indexed", totalComments,
|
||||
"markdown_bytes", bytesWritten.Load(),
|
||||
"comments_per_second", int(rate),
|
||||
"by_role", byRole,
|
||||
)
|
||||
|
||||
if failures.Load() > 0 {
|
||||
logger.Error("seed completed with failures", "failures", failures.Load())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("\nSeeded %d documents and %d comments into %s\n",
|
||||
docsWritten.Load(), commentsWritten.Load(), filepath.Clean(engine.Root()))
|
||||
}
|
||||
|
||||
var serviceNouns = []string{
|
||||
"Gateway", "Pipeline", "Registry", "Collector", "Scheduler", "Router",
|
||||
"Indexer", "Notifier", "Bridge", "Vault", "Ledger", "Beacon",
|
||||
"Annex", "Relay", "Forge", "Harbor", "Signal", "Archive",
|
||||
"Compass", "Foundry", "Sentinel", "Orchid", "Quartz", "Meridian",
|
||||
}
|
||||
|
||||
var serviceScopes = []string{
|
||||
"Billing", "Identity", "Storage", "Messaging", "Search", "Payments",
|
||||
"Analytics", "Provisioning", "Fulfilment", "Telemetry", "Consent", "Routing",
|
||||
}
|
||||
|
||||
// documentTitle builds a stable, human title for the nth generated document.
|
||||
func documentTitle(index int) string {
|
||||
scope := serviceScopes[index%len(serviceScopes)]
|
||||
noun := serviceNouns[(index/len(serviceScopes))%len(serviceNouns)]
|
||||
return fmt.Sprintf("%s %s Service %03d", scope, noun, index)
|
||||
}
|
||||
|
||||
func documentBody(title, slug string, minBodyKB int, rng *rand.Rand) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", title)
|
||||
fmt.Fprintf(&b, "> Generated performance fixture for `%s`. Regenerate with `make seed`.\n\n", slug)
|
||||
b.WriteString("This page isolates the same system across three audiences. Only the section that\n")
|
||||
b.WriteString("matches the selected tab is rendered in the reader.\n\n")
|
||||
|
||||
b.WriteString("## For Users\n\n")
|
||||
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||
"The guided flow walks a new user from an empty workspace to a first successful run.",
|
||||
"Every destructive action asks for confirmation and explains what is about to change.",
|
||||
"Keyboard shortcuts mirror the visual toolbar so the workflow stays fast.",
|
||||
})
|
||||
b.WriteString("\n### Common tasks\n\n")
|
||||
for _, task := range []string{"Create your first workspace", "Invite a teammate", "Export a report", "Reset your password"} {
|
||||
fmt.Fprintf(&b, "- %s\n", task)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString("## For Operators\n\n")
|
||||
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||
"The deployment is idempotent: re-running the pipeline converges on the declared state.",
|
||||
"Health checks probe the read path before the write path is allowed to serve traffic.",
|
||||
"Rollback restores the previous artefact and replays the migration in reverse.",
|
||||
})
|
||||
b.WriteString("\n### Runbook\n\n")
|
||||
b.WriteString("```bash\n# verify the release is healthy\ncurl -fsS https://localhost:8080/api/health\n\n# roll back to the previous artefact\nmake rollback RELEASE=previous\n```\n\n")
|
||||
b.WriteString("| Signal | Threshold | Action |\n| --- | --- | --- |\n| p99 latency | 250 ms | Page the on-call |\n| error rate | 1 % | Halt the rollout |\n| queue depth | 1000 | Scale workers |\n\n")
|
||||
|
||||
b.WriteString("## For Developers\n\n")
|
||||
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||
"The API is versioned in the path; a breaking change ships under a new major prefix.",
|
||||
"Every write is atomic: readers observe either the previous file or the new one, never a partial write.",
|
||||
"The engine slugifies titles into lower-case, hyphen-separated identifiers.",
|
||||
})
|
||||
b.WriteString("\n### Endpoints\n\n")
|
||||
b.WriteString("```http\nGET /api/docs\nGET /api/docs/{slug}\nPOST /api/docs/{slug}\nDELETE /api/docs/{slug}\nGET /api/docs/{slug}/comments\nPOST /api/docs/{slug}/comments\n```\n\n")
|
||||
|
||||
b.WriteString("### Integration notes\n\n")
|
||||
fmt.Fprintf(&b, "Inline references such as `data/docs/%s.md` and **bold emphasis** must survive a round trip through the editor.\n", slug)
|
||||
|
||||
// Top up the body so every fixture is at least minBodyKB.
|
||||
for b.Len() < minBodyKB*1024 {
|
||||
b.WriteString("\nAdditional reference material for load testing. ")
|
||||
fmt.Fprintf(&b, "Section %d of the generated body for `%s`. ", rng.Intn(1<<20), slug)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeParagraphs(b *strings.Builder, rng *rand.Rand, count, targetLen int, seeds []string) {
|
||||
for i := 0; i < count; i++ {
|
||||
seed := seeds[i%len(seeds)]
|
||||
fmt.Fprintf(b, "%s ", seed)
|
||||
for len(b.String()) < targetLen*1024 && i == count-1 {
|
||||
fmt.Fprintf(b, "Reference sentence %d covering the behaviour of `%s`. ", rng.Intn(1<<20), "service")
|
||||
break
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
var givenNames = []string{"ana", "bo", "chi", "dee", "eli", "fen", "gita", "hugo", "iris", "jo", "kit", "lena", "mo", "nia", "omar", "pia"}
|
||||
|
||||
func authorName(rng *rand.Rand, role storage.Role) string {
|
||||
name := givenNames[rng.Intn(len(givenNames))]
|
||||
suffix := map[storage.Role]string{
|
||||
storage.RoleUser: " (user)",
|
||||
storage.RoleOperator: " (ops)",
|
||||
storage.RoleDeveloper: " (dev)",
|
||||
}[role]
|
||||
return name + suffix
|
||||
}
|
||||
|
||||
var commentSeeds = map[storage.Role][]string{
|
||||
storage.RoleUser: {
|
||||
"The guided setup was easier to follow than the older docs.",
|
||||
"I could not find the export button on the first screen.",
|
||||
"Step 3 assumes a workspace that most new accounts do not have.",
|
||||
"The keyboard shortcut list is genuinely useful.",
|
||||
"Copy in the empty state does not match what actually happens.",
|
||||
},
|
||||
storage.RoleOperator: {
|
||||
"Rollback took 20 minutes; the runbook should call that out.",
|
||||
"Health checks pass while the write path is still degraded.",
|
||||
"We need the p99 latency threshold surfaced on the dashboard.",
|
||||
"The idempotent redeploy saved us during last week's incident.",
|
||||
"Queue depth alerting fires too late to be actionable.",
|
||||
},
|
||||
storage.RoleDeveloper: {
|
||||
"The retry budget is undocumented; please add the numbers.",
|
||||
"A `--dry-run` flag would make this safe to run in CI.",
|
||||
"The response schema changed without a version bump.",
|
||||
"Slug collisions are possible when titles differ only by punctuation.",
|
||||
"Atomic writes look correct, but the rename is not fsynced in CI images.",
|
||||
},
|
||||
}
|
||||
|
||||
func commentBody(role storage.Role, slug string, index int, rng *rand.Rand) string {
|
||||
seeds := commentSeeds[role]
|
||||
base := seeds[rng.Intn(len(seeds))]
|
||||
return fmt.Sprintf("%s (finding %d on %s)", base, index+1, slug)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue