Skip to content

Commit b21614e

Browse files
committed
analyze: address review comments and add tests
Addresses F1bonacc1's review on #462: - app/process.go: in handleOutput() replace the direct Health/ReadyTime mutation with a call to setProcHealth(Ready) so the update runs under stateMtx, fixing the data race flagged in the review. - cmd/analyze.go: remove the Run function; Cobra auto-prints help when a parent command with no Run is invoked without a subcommand. - cmd/analyze_critical_chain.go: * extract `unreadySortOrder = time.Duration(math.MaxInt64)` and use it consistently in readyOffsetForSort. * when a positional argument names a process that is not part of the dependency graph but is still known to the project (isolated process), synthesize a DependencyNode so its timings are still rendered, instead of erroring out. * replace the manual string-join loop in formatNodeLine with strings.Join. * small refactor: renderCriticalChain(w io.Writer, ...) and sortRootsByReadyTime(...) are now factored out so they can be unit-tested without a running server. New tests: - cmd/analyze_critical_chain_test.go covers formatDuration, formatOffset, readyOffsetForSort (including the unreadySortOrder sentinel), formatNodeLine for all branches (nil state, ready with/ without readiness gap, not-ready, not-started, status annotations), sibling ordering, and a full renderCriticalChain tree walk. - app/process_timing_test.go covers setProcHealth -- asserts that ProcessReadyTime is set on the first Ready transition, is not overwritten on subsequent Ready calls, is not set for NotReady, and a concurrent-Ready test that is intended to be exercised under the race detector. Verified locally: - `go build ./...` - `go vet ./...` - `go test ./...` (all packages pass) - `go test -race ./...` (equivalent to `make testrace`, all pass) - `golangci-lint run -c .golangci.yaml` (0 issues)
1 parent af6e05d commit b21614e

5 files changed

Lines changed: 524 additions & 56 deletions

File tree

src/app/process.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -747,11 +747,9 @@ func (p *Process) handleOutput(pipe io.ReadCloser, output string, handler func(m
747747
break
748748
}
749749
if p.procConf.ReadyLogLine != "" && p.procState.Health == types.ProcessHealthUnknown && strings.Contains(line, p.procConf.ReadyLogLine) {
750-
p.procState.Health = types.ProcessHealthReady
751-
if p.procState.ProcessReadyTime == nil {
752-
now := time.Now()
753-
p.procState.ProcessReadyTime = &now
754-
}
750+
// setProcHealth takes stateMtx, updates Health and (for Ready)
751+
// records ProcessReadyTime atomically.
752+
p.setProcHealth(types.ProcessHealthReady)
755753
p.cancelReadyLogFunc(nil)
756754
}
757755
p.checkElevatedProcOutput(line)

src/app/process_timing_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package app
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
8+
"github.com/f1bonacc1/process-compose/src/types"
9+
)
10+
11+
// newBareProcessForTimingTests builds the minimum Process needed to exercise
12+
// the timing-tracking paths (setProcHealth, onProcessEnd bookkeeping) in
13+
// isolation -- i.e. without actually spawning a child or starting probes.
14+
func newBareProcessForTimingTests() *Process {
15+
return &Process{
16+
procConf: &types.ProcessConfig{Name: "p", ReplicaName: "p"},
17+
procState: &types.ProcessState{},
18+
}
19+
}
20+
21+
func TestSetProcHealth_SetsReadyTimeOnce(t *testing.T) {
22+
p := newBareProcessForTimingTests()
23+
24+
before := time.Now()
25+
p.setProcHealth(types.ProcessHealthReady)
26+
after := time.Now()
27+
28+
if p.procState.Health != types.ProcessHealthReady {
29+
t.Fatalf("Health = %q, want %q", p.procState.Health, types.ProcessHealthReady)
30+
}
31+
if p.procState.ProcessReadyTime == nil {
32+
t.Fatal("ProcessReadyTime was not set")
33+
}
34+
got := *p.procState.ProcessReadyTime
35+
if got.Before(before) || got.After(after) {
36+
t.Errorf("ProcessReadyTime = %v, want between %v and %v", got, before, after)
37+
}
38+
39+
// Calling setProcHealth(Ready) again must not overwrite the first timestamp.
40+
first := *p.procState.ProcessReadyTime
41+
time.Sleep(2 * time.Millisecond)
42+
p.setProcHealth(types.ProcessHealthReady)
43+
if !p.procState.ProcessReadyTime.Equal(first) {
44+
t.Errorf("ProcessReadyTime changed on second Ready call: first=%v second=%v",
45+
first, *p.procState.ProcessReadyTime)
46+
}
47+
}
48+
49+
func TestSetProcHealth_NotReadyDoesNotSetTime(t *testing.T) {
50+
p := newBareProcessForTimingTests()
51+
p.setProcHealth(types.ProcessHealthNotReady)
52+
if p.procState.ProcessReadyTime != nil {
53+
t.Errorf("ProcessReadyTime unexpectedly set for NotReady: %v",
54+
*p.procState.ProcessReadyTime)
55+
}
56+
}
57+
58+
// TestSetProcHealth_ConcurrentReadyNoRace exercises the mutex protection the
59+
// maintainer asked for. Running under `go test -race` / `make testrace` should
60+
// flag any unsynchronised access to procState.
61+
func TestSetProcHealth_ConcurrentReadyNoRace(t *testing.T) {
62+
p := newBareProcessForTimingTests()
63+
64+
const workers = 32
65+
var wg sync.WaitGroup
66+
wg.Add(workers)
67+
for i := 0; i < workers; i++ {
68+
go func() {
69+
defer wg.Done()
70+
p.setProcHealth(types.ProcessHealthReady)
71+
}()
72+
}
73+
wg.Wait()
74+
75+
if p.procState.ProcessReadyTime == nil {
76+
t.Fatal("ProcessReadyTime not set after concurrent Ready calls")
77+
}
78+
if p.procState.Health != types.ProcessHealthReady {
79+
t.Errorf("Health = %q, want Ready", p.procState.Health)
80+
}
81+
}

src/cmd/analyze.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package cmd
22

33
import (
4-
"os"
5-
64
"github.com/spf13/cobra"
75
)
86

9-
// analyzeCmd represents the analyze command group.
7+
// analyzeCmd represents the analyze command group. It has no Run of its own;
8+
// Cobra auto-prints help when a parent command with no Run is invoked without
9+
// a subcommand.
1010
var analyzeCmd = &cobra.Command{
1111
Use: "analyze",
1212
Short: "Analyze startup timing and dependency information",
@@ -16,12 +16,6 @@ through the dependency graph.
1616
1717
Available subcommands:
1818
critical-chain Print the dependency chains ordered by startup time.`,
19-
Run: func(cmd *cobra.Command, args []string) {
20-
if len(args) == 0 {
21-
_ = cmd.Help()
22-
os.Exit(0)
23-
}
24-
},
2519
}
2620

2721
func init() {

src/cmd/analyze_critical_chain.go

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
6+
"math"
57
"os"
68
"sort"
9+
"strings"
710
"time"
811

912
"github.com/f1bonacc1/process-compose/src/types"
1013
"github.com/fatih/color"
1114
"github.com/spf13/cobra"
1215
)
1316

17+
// unreadySortOrder is the sort-key returned for processes that never became
18+
// ready (or have no recorded state). Using the maximum duration means these
19+
// processes float to the top of the critical-chain listing, mirroring
20+
// systemd-analyze which puts the slowest/stuck paths first.
21+
const unreadySortOrder = time.Duration(math.MaxInt64)
22+
1423
var analyzeCriticalChainCmd = &cobra.Command{
1524
Use: "critical-chain [process...]",
1625
Short: "Print the critical process startup chain",
@@ -71,8 +80,16 @@ func runAnalyzeCriticalChain(cmd *cobra.Command, args []string) {
7180
for _, name := range args {
7281
node, ok := graph.AllNodes[name]
7382
if !ok {
74-
fmt.Fprintf(os.Stderr, "unknown process (or process has no dependencies): %s\n", name)
75-
os.Exit(1)
83+
// The graph only contains processes that participate in a
84+
// dependency edge. If the name refers to an isolated process
85+
// that is still known to the project, synthesize a bare node
86+
// so its timings are still rendered.
87+
if _, exists := stateByName[name]; exists {
88+
node = &types.DependencyNode{Name: name}
89+
} else {
90+
fmt.Fprintf(os.Stderr, "unknown process: %s\n", name)
91+
os.Exit(1)
92+
}
7693
}
7794
roots = append(roots, node)
7895
}
@@ -95,48 +112,70 @@ func runAnalyzeCriticalChain(cmd *cobra.Command, args []string) {
95112
}
96113
}
97114

98-
sort.Slice(roots, func(i, j int) bool {
99-
ai := readyOffsetForSort(stateByName[roots[i].Name], projectState.StartTime)
100-
aj := readyOffsetForSort(stateByName[roots[j].Name], projectState.StartTime)
101-
if ai == aj {
102-
return roots[i].Name < roots[j].Name
103-
}
104-
return ai > aj
105-
})
115+
renderCriticalChain(os.Stdout, projectState, roots, stateByName)
116+
}
106117

107-
// Header
118+
// renderCriticalChain writes the critical-chain report for the given roots to
119+
// w. Split out from runAnalyzeCriticalChain so it can be unit-tested without a
120+
// running server.
121+
func renderCriticalChain(
122+
w io.Writer,
123+
projectState *types.ProjectState,
124+
roots []*types.DependencyNode,
125+
stateByName map[string]*types.ProcessState,
126+
) {
108127
green := color.New(color.FgGreen).SprintFunc()
109-
fmt.Println("The time when unit became ready is printed after the \"@\" character.")
110-
fmt.Println("The time the unit took to become ready is printed after the \"+\" character.")
111-
fmt.Println()
112-
fmt.Printf("%s: %s\n", green("Project"), projectState.ProjectName)
113-
fmt.Printf("%s: %s\n", green("Started"), projectState.StartTime.Format(time.RFC3339))
114-
fmt.Printf("%s: %s\n", green("Up time"), projectState.UpTime.Round(time.Millisecond))
115-
fmt.Println()
128+
fmt.Fprintln(w, "The time when unit became ready is printed after the \"@\" character.")
129+
fmt.Fprintln(w, "The time the unit took to become ready is printed after the \"+\" character.")
130+
fmt.Fprintln(w)
131+
fmt.Fprintf(w, "%s: %s\n", green("Project"), projectState.ProjectName)
132+
fmt.Fprintf(w, "%s: %s\n", green("Started"), projectState.StartTime.Format(time.RFC3339))
133+
fmt.Fprintf(w, "%s: %s\n", green("Up time"), projectState.UpTime.Round(time.Millisecond))
134+
fmt.Fprintln(w)
135+
136+
sortRootsByReadyTime(roots, stateByName, projectState.StartTime)
116137

117138
for i, root := range roots {
118139
last := i == len(roots)-1
119-
printCriticalChain(root, stateByName, projectState.StartTime, "", true, last)
140+
printCriticalChain(w, root, stateByName, projectState.StartTime, "", true, last)
120141
}
121142
}
122143

144+
// sortRootsByReadyTime sorts nodes in-place by descending ready-time (slowest
145+
// first); ties are broken by ascending name.
146+
func sortRootsByReadyTime(
147+
nodes []*types.DependencyNode,
148+
stateByName map[string]*types.ProcessState,
149+
projectStart time.Time,
150+
) {
151+
sort.Slice(nodes, func(i, j int) bool {
152+
ai := readyOffsetForSort(stateByName[nodes[i].Name], projectStart)
153+
aj := readyOffsetForSort(stateByName[nodes[j].Name], projectStart)
154+
if ai == aj {
155+
return nodes[i].Name < nodes[j].Name
156+
}
157+
return ai > aj
158+
})
159+
}
160+
123161
// readyOffsetForSort returns the duration from project start until the process
124-
// became ready. Processes that never became ready sort to the top (return
125-
// max duration) so the "slowest" chain is printed first.
162+
// became ready. Processes that never became ready return unreadySortOrder so
163+
// the "slowest" / stuck chain is printed first.
126164
func readyOffsetForSort(s *types.ProcessState, projectStart time.Time) time.Duration {
127165
if s == nil {
128-
return time.Duration(1<<62 - 1)
166+
return unreadySortOrder
129167
}
130168
if s.ProcessReadyTime != nil {
131169
return s.ProcessReadyTime.Sub(projectStart)
132170
}
133171
if s.ProcessStartTime != nil {
134172
return s.ProcessStartTime.Sub(projectStart)
135173
}
136-
return time.Duration(1<<62 - 1)
174+
return unreadySortOrder
137175
}
138176

139177
func printCriticalChain(
178+
w io.Writer,
140179
node *types.DependencyNode,
141180
stateByName map[string]*types.ProcessState,
142181
projectStart time.Time,
@@ -158,7 +197,7 @@ func printCriticalChain(
158197
}
159198

160199
line := formatNodeLine(node, stateByName[node.Name], projectStart)
161-
fmt.Printf("%s%s%s\n", prefix, branch, line)
200+
fmt.Fprintf(w, "%s%s%s\n", prefix, branch, line)
162201

163202
// Sort dependencies by when they became ready (latest first), mirroring
164203
// systemd-analyze critical-chain.
@@ -168,17 +207,10 @@ func printCriticalChain(
168207
deps = append(deps, link.DependencyNode)
169208
}
170209
}
171-
sort.Slice(deps, func(i, j int) bool {
172-
ai := readyOffsetForSort(stateByName[deps[i].Name], projectStart)
173-
aj := readyOffsetForSort(stateByName[deps[j].Name], projectStart)
174-
if ai == aj {
175-
return deps[i].Name < deps[j].Name
176-
}
177-
return ai > aj
178-
})
210+
sortRootsByReadyTime(deps, stateByName, projectStart)
179211

180212
for i, dep := range deps {
181-
printCriticalChain(dep, stateByName, projectStart, nextPrefix, false, i == len(deps)-1)
213+
printCriticalChain(w, dep, stateByName, projectStart, nextPrefix, false, i == len(deps)-1)
182214
}
183215
}
184216

@@ -233,15 +265,7 @@ func formatNodeLine(
233265
}
234266
}
235267

236-
// Join with spaces.
237-
out := ""
238-
for i, p := range parts {
239-
if i > 0 {
240-
out += " "
241-
}
242-
out += p
243-
}
244-
return out
268+
return strings.Join(parts, " ")
245269
}
246270

247271
// formatOffset formats a duration representing "time since project start".

0 commit comments

Comments
 (0)