Cache immutable command bridge metadata

This commit is contained in:
2026-10-09 01:34:24 +00:00
parent 5816dd3eb9
commit 3ad355927c
5 changed files with 125 additions and 4 deletions

7
app.go
View File

@@ -1,11 +1,16 @@
package commandkit
import "fmt"
import (
"fmt"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
)
// App owns one command registry and its application identity.
type App struct {
identity Identity
commands map[string]*commandNode
commandTree *internalcobra.Command
configRegistered bool
}

View File

@@ -1,6 +1,6 @@
# Performance implementation plan
Status: implementation in progress; Stages 1–6 are complete, Stages 7–11 have not started.
Status: implementation in progress; Stages 1–7 are complete, Stages 8–11 have not started.
Date: 2026-10-09.
@@ -549,7 +549,7 @@ issues remain open.
## Stage 7: cache immutable bridge metadata per app
**Status:** not started. **Prerequisite:** Stage 6.
**Status:** complete. **Prerequisite:** Stage 6.
**Files:** [app.go](../../app.go), [parser.go](../../parser.go), registry invalidation,
root parser/registry tests, dispatch benchmarks, and evidence.
@@ -581,7 +581,37 @@ invocation still creates fresh backend values at this stage. Use
**Completion:** unchanged registrations reuse translated metadata; successful
registration is visible to later parsing and help.
**Evidence:** pending.
**Evidence:** `GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none
GOSUMDB=off go test -mod=readonly -count=1 .` passed. The new behavioral test
builds help metadata, registers a command and both standard command families,
then confirms later help and parsing see them without invoking provider
callbacks. It also checks visibility after failed and empty registration.
Source review confirms the tree is built lazily, contains only neutral
declarations/flag/operand metadata, and is cleared only after successful
nonempty registration. Root and child ordering is performed by the uncached
builder once per metadata generation.
Measurements used Go 1.26.8, linux/amd64, Linux 6.17.0-41-generic, Intel Core
i5-7260U CPU @ 2.20 GHz, based on source revision `5816dd3` with this stage's
changes in the working tree. The five-sample command passed:
`GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off go test -mod=readonly -run '^$' -bench '^BenchmarkPerformanceDispatch$' -benchmem -benchtime=200ms -count=5 .`.
Raw output is in `/tmp/commandkit-performance-dispatch-after-stage7.txt`.
Repeated small success changed from 31,066–41,041 ns/op, 33,209–33,210 B/op,
and 287 allocations to 26,008–31,223 ns/op, 29,408–29,409 B/op, and 267
allocations. Wide success changed from 218,073–253,010 ns/op, 269,063–269,070
B/op, and 1,898 allocations to 182,431–194,243 ns/op, 247,626–247,635 B/op,
and 1,756 allocations. Deep success changed from 119,958–139,625 ns/op,
139,787–139,791 B/op, and 1,074 allocations to 103,208–124,879 ns/op,
124,240–124,242 B/op, and 879 allocations. Help and usage paths also reduced
allocations and bytes. Cold construction plus first execution remained similar
for the small app (38,152–50,235 ns/op, 38,275–38,276 B/op, 339 allocations
baseline; 39,158–47,365 ns/op, 38,243 B/op, 338 allocations now). The wide
cold case changed from 251,945–315,276 ns/op, 291,822–291,827 B/op, and 2,184
allocations to 271,403–321,746 ns/op, 305,046–305,051 B/op, and 2,194
allocations. Its timing range overlaps baseline; the extra cold storage is the
retained neutral tree. The measured tradeoff is lower repeated-execution cost
with one tree allocation retained per app metadata generation.
## Stage 8: introduce an invocation-owned adapter session

View File

@@ -17,6 +17,13 @@ func (a *App) bridgeTree() *internalcobra.Command {
if a == nil {
return nil
}
if a.commandTree == nil {
a.commandTree = a.buildBridgeTree()
}
return a.commandTree
}
func (a *App) buildBridgeTree() *internalcobra.Command {
root := &internalcobra.Command{Name: a.identity.Name}
names := make([]string, 0, len(a.commands))
for name, node := range a.commands {

View File

@@ -1,11 +1,15 @@
package commandkit
import (
"bytes"
"context"
"io"
"reflect"
"strings"
"testing"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
@@ -48,3 +52,77 @@ func TestAppParseUsesRegisteredBindingsWithTheAdapter(t *testing.T) {
t.Fatal("accepted a child flag before the complete command path")
}
}
func TestBridgeTreeRefreshesAfterSuccessfulRegistration(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
streams := Streams{Out: &output, Err: io.Discard}
renderRootHelp := func() string {
t.Helper()
output.Reset()
outcome := app.Execute(context.Background(), []string{"--help"}, streams)
if outcome.Kind != OutcomeSuccess {
t.Fatalf("root help outcome = %#v", outcome)
}
return output.String()
}
initial := renderRootHelp()
if strings.Contains(initial, "custom") || strings.Contains(initial, "config") || strings.Contains(initial, "promptkit") {
t.Fatalf("initial help unexpectedly lists future registrations: %q", initial)
}
custom, err := NewCommand(CommandSpec[struct{}]{Name: "custom", Run: func(Invocation, struct{}) Outcome { return Success() }})
if err != nil {
t.Fatal(err)
}
if err := app.Register(custom); err != nil {
t.Fatal(err)
}
providerCalls := 0
if err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{
Name: "app",
Operations: []ConfigOperation{ConfigShow},
Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
providerCalls++
return Acquired[ConfigView]{}, nil
},
}}}); err != nil {
t.Fatal(err)
}
if err := RegisterPromptKit(app, PromptKitSuite[struct{}]{InspectPrompt: func(context.Context, Selection, struct{}, string, string) (Acquired[*promptkit.PromptInspection], error) {
providerCalls++
return Acquired[*promptkit.PromptInspection]{}, nil
}}); err != nil {
t.Fatal(err)
}
updated := renderRootHelp()
for _, name := range []string{"custom", "config", "promptkit"} {
if !strings.Contains(updated, name) {
t.Errorf("updated help omits %q: %q", name, updated)
}
}
if providerCalls != 0 {
t.Fatalf("help invoked inspection or configuration callbacks %d times", providerCalls)
}
late, err := NewCommand(CommandSpec[struct{}]{Name: "late"})
if err != nil {
t.Fatal(err)
}
if err := app.Register(late, custom); err == nil {
t.Fatal("Register accepted a batch with an existing-name collision")
}
if err := app.Register(); err != nil {
t.Fatalf("empty registration failed: %v", err)
}
final := renderRootHelp()
if !strings.Contains(final, "custom") || !strings.Contains(final, "config") || !strings.Contains(final, "promptkit") || strings.Contains(final, "late") {
t.Fatalf("help after failed and empty registration = %q", final)
}
if outcome := app.Execute(context.Background(), []string{"custom"}, streams); outcome.Kind != OutcomeSuccess {
t.Fatalf("new command did not parse after registration: %#v", outcome)
}
}

View File

@@ -31,5 +31,6 @@ func (a *App) Register(commands ...Command) error {
for name, node := range pending {
a.commands[name] = node
}
a.commandTree = nil
return nil
}