Skip to content

Commit 35a4620

Browse files
authored
Merge pull request #117 from flashcatcloud/fix/incident-verify-concurrent
perf(incident): verify comment write-back concurrently
2 parents 41d49a1 + 590a252 commit 35a4620

3 files changed

Lines changed: 245 additions & 21 deletions

File tree

internal/cli/command_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import (
1010
"os"
1111
"path/filepath"
1212
"strings"
13+
"sync"
14+
"sync/atomic"
1315
"testing"
16+
"time"
1417

1518
"github.com/flashcatcloud/go-flashduty"
1619
"github.com/spf13/cobra"
@@ -820,14 +823,17 @@ func TestCommandIncidentCommentFailsWhenNoCommentEntryFound(t *testing.T) {
820823
func TestCommandIncidentCommentChecksEntireBatchNotJustFirstFailure(t *testing.T) {
821824
saveAndResetGlobals(t)
822825
stub := newGFStub(t)
826+
var mu sync.Mutex
823827
feedCallsByIncident := map[string]int{}
824828
stub.dataForPath = func(path string, body map[string]any) any {
825829
switch path {
826830
case "/incident/comment":
827831
return map[string]any{}
828832
case "/incident/feed":
829833
incID, _ := body["incident_id"].(string)
834+
mu.Lock()
830835
feedCallsByIncident[incID]++
836+
mu.Unlock()
831837
switch incID {
832838
case "inc-2":
833839
return map[string]any{"items": []any{
@@ -984,6 +990,163 @@ func TestCommandIncidentCommentVerifiesAcrossFeedPages(t *testing.T) {
984990
}
985991
}
986992

993+
// TestCommandIncidentCommentVerifyOrderStableDespiteCompletionOrder guards
994+
// that concurrent verification's problem list comes out in ctx.Args order,
995+
// never completion order. The stub sleeps longer for earlier-indexed
996+
// incidents than later ones, so completion order is guaranteed to be the
997+
// exact reverse of argument order; a test that let requests finish in
998+
// whatever order they naturally would (e.g. all equally fast) could pass
999+
// even with results appended in completion order, since ctx.Args order and
1000+
// completion order would coincidentally match. Forcing the inversion is what
1001+
// makes this a real test of the ordering guarantee rather than of luck.
1002+
func TestCommandIncidentCommentVerifyOrderStableDespiteCompletionOrder(t *testing.T) {
1003+
saveAndResetGlobals(t)
1004+
stub := newGFStub(t)
1005+
1006+
const n = 4
1007+
ids := make([]string, n)
1008+
delay := make(map[string]time.Duration, n)
1009+
for i := range ids {
1010+
ids[i] = fmt.Sprintf("inc-%d", i+1)
1011+
// inc-1 sleeps longest, inc-n shortest: completion order is guaranteed
1012+
// to be inc-n, ..., inc-1 — the reverse of ids' (== ctx.Args') order.
1013+
delay[ids[i]] = time.Duration(n-i) * 15 * time.Millisecond
1014+
}
1015+
stub.dataForPath = func(path string, body map[string]any) any {
1016+
switch path {
1017+
case "/incident/comment":
1018+
return map[string]any{}
1019+
case "/incident/feed":
1020+
incID, _ := body["incident_id"].(string)
1021+
time.Sleep(delay[incID])
1022+
// Empty timeline: every incident fails verification, so every one
1023+
// of them appears in the problem list this test inspects.
1024+
return map[string]any{"items": []any{}}
1025+
default:
1026+
return map[string]any{}
1027+
}
1028+
}
1029+
commentFile := writeCommentFile(t, "the real comment")
1030+
1031+
args := append([]string{"incident", "comment"}, ids...)
1032+
args = append(args, "--comment-file", commentFile)
1033+
_, err := execCommand(args...)
1034+
if err == nil {
1035+
t.Fatal("[verify-order] expected a non-zero exit, got nil error")
1036+
}
1037+
1038+
lastIdx := -1
1039+
for _, id := range ids {
1040+
idx := strings.Index(err.Error(), "incident "+id+":")
1041+
if idx == -1 {
1042+
t.Fatalf("[verify-order] expected %q named in the error: %v", id, err)
1043+
}
1044+
if idx <= lastIdx {
1045+
t.Fatalf("[verify-order] problems are not in ctx.Args order (completion order leaked through) in: %v", err)
1046+
}
1047+
lastIdx = idx
1048+
}
1049+
}
1050+
1051+
// incidentFeedConcurrencyProbe returns a stub.dataForPath function that
1052+
// answers /incident/comment and every /incident/feed request successfully
1053+
// (the feed always carries want as an i_comm entry), sleeping delay on each
1054+
// feed request and recording, via inFlight/maxInFlight, the peak number of
1055+
// feed requests in flight at once. Tests use the recorded peak to prove
1056+
// verifyIncidentCommentsWritten's requests actually overlap (and by how
1057+
// much), which can't be observed from the command's exit code or output
1058+
// alone.
1059+
func incidentFeedConcurrencyProbe(want string, delay time.Duration, inFlight, maxInFlight *atomic.Int32) func(path string, body map[string]any) any {
1060+
return func(path string, body map[string]any) any {
1061+
switch path {
1062+
case "/incident/comment":
1063+
return map[string]any{}
1064+
case "/incident/feed":
1065+
cur := inFlight.Add(1)
1066+
defer inFlight.Add(-1)
1067+
for {
1068+
peak := maxInFlight.Load()
1069+
if cur <= peak || maxInFlight.CompareAndSwap(peak, cur) {
1070+
break
1071+
}
1072+
}
1073+
time.Sleep(delay)
1074+
return map[string]any{"items": []any{
1075+
map[string]any{"type": "i_comm", "created_at": 1, "detail": map[string]any{"comment": want}},
1076+
}}
1077+
default:
1078+
return map[string]any{}
1079+
}
1080+
}
1081+
}
1082+
1083+
// TestCommandIncidentCommentVerifyRunsConcurrently guards against a future
1084+
// refactor silently reverting verifyIncidentCommentsWritten to a sequential
1085+
// walk: with 4 incidents each sleeping on their /incident/feed request, a
1086+
// sequential implementation would never have more than one request in flight
1087+
// at once, while a correctly concurrent one will. Nothing about the command's
1088+
// exit code or output would catch that regression — only observing overlap
1089+
// directly, via incidentFeedConcurrencyProbe, does.
1090+
func TestCommandIncidentCommentVerifyRunsConcurrently(t *testing.T) {
1091+
saveAndResetGlobals(t)
1092+
stub := newGFStub(t)
1093+
var inFlight, maxInFlight atomic.Int32
1094+
stub.dataForPath = incidentFeedConcurrencyProbe("the real comment", 20*time.Millisecond, &inFlight, &maxInFlight)
1095+
1096+
const n = 4
1097+
ids := make([]string, n)
1098+
for i := range ids {
1099+
ids[i] = fmt.Sprintf("inc-%d", i+1)
1100+
}
1101+
commentFile := writeCommentFile(t, "the real comment")
1102+
1103+
args := append([]string{"incident", "comment"}, ids...)
1104+
args = append(args, "--comment-file", commentFile)
1105+
out, err := execCommand(args...)
1106+
if err != nil {
1107+
t.Fatalf("[verify-concurrent] unexpected error: %v", err)
1108+
}
1109+
if !strings.Contains(out, fmt.Sprintf("Commented on %d incident(s).", n)) {
1110+
t.Fatalf("[verify-concurrent] unexpected output:\n%s", out)
1111+
}
1112+
if got := maxInFlight.Load(); got < 2 {
1113+
t.Fatalf("[verify-concurrent] observed peak in-flight verify requests = %d, want > 1 (verification never actually overlapped)", got)
1114+
}
1115+
}
1116+
1117+
// TestCommandIncidentCommentVerifyRespectsConcurrencyBound guards the other
1118+
// side of the same fan-out: with 3x maxIncidentVerifyConcurrency incidents,
1119+
// each holding its /incident/feed request open for a while, the peak observed
1120+
// in flight must never exceed the worker limit — a regression that dropped
1121+
// the semaphore (e.g. an unbounded "go func" per incident) would fire every
1122+
// request at once and blow past it.
1123+
func TestCommandIncidentCommentVerifyRespectsConcurrencyBound(t *testing.T) {
1124+
saveAndResetGlobals(t)
1125+
stub := newGFStub(t)
1126+
var inFlight, maxInFlight atomic.Int32
1127+
stub.dataForPath = incidentFeedConcurrencyProbe("the real comment", 20*time.Millisecond, &inFlight, &maxInFlight)
1128+
1129+
const n = maxIncidentVerifyConcurrency * 3
1130+
ids := make([]string, n)
1131+
for i := range ids {
1132+
ids[i] = fmt.Sprintf("inc-%d", i+1)
1133+
}
1134+
commentFile := writeCommentFile(t, "the real comment")
1135+
1136+
args := append([]string{"incident", "comment"}, ids...)
1137+
args = append(args, "--comment-file", commentFile)
1138+
out, err := execCommand(args...)
1139+
if err != nil {
1140+
t.Fatalf("[verify-bound] unexpected error: %v", err)
1141+
}
1142+
if !strings.Contains(out, fmt.Sprintf("Commented on %d incident(s).", n)) {
1143+
t.Fatalf("[verify-bound] unexpected output:\n%s", out)
1144+
}
1145+
if got := maxInFlight.Load(); got > maxIncidentVerifyConcurrency {
1146+
t.Fatalf("[verify-bound] observed peak in-flight verify requests = %d, want <= %d (the worker limit)", got, maxIncidentVerifyConcurrency)
1147+
}
1148+
}
1149+
9871150
// TestCommandIncidentLifecycleRejectsMoreThan100IDs covers the curated
9881151
// commands that still enforce the 100-id batch cap client-side. unack and wake
9891152
// were dropped in favor of their generated twins, which carry no client-side

internal/cli/gfstub_test.go

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"net/http"
77
"net/http/httptest"
8+
"sync"
89
"testing"
910

1011
"github.com/flashcatcloud/go-flashduty"
@@ -19,6 +20,16 @@ import (
1920
type gfStub struct {
2021
server *httptest.Server
2122

23+
// mu guards every field below. Verified commands (e.g. incident comment's
24+
// write-back check) now issue concurrent requests against a single stub
25+
// server, so the handler below runs on more than one goroutine at once;
26+
// without this lock the plain field writes here would race. It is
27+
// deliberately released before invoking dataFor/dataForPath/data (see the
28+
// handler below) so those calls run concurrently rather than being
29+
// serialized by this lock — a test whose own closure touches shared state
30+
// is responsible for synchronizing that state itself.
31+
mu sync.Mutex
32+
2233
// lastPath is the path of the most recent request (no query string).
2334
lastPath string
2435
// lastBody is the decoded JSON body of the most recent request.
@@ -54,26 +65,39 @@ func newGFStub(t *testing.T) *gfStub {
5465
t.Helper()
5566
s := &gfStub{}
5667
s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68+
var body map[string]any
69+
if raw, err := io.ReadAll(r.Body); err == nil && len(raw) > 0 {
70+
_ = json.Unmarshal(raw, &body)
71+
}
72+
path := r.URL.Path
73+
74+
s.mu.Lock()
5775
s.requests++
58-
s.lastPath = r.URL.Path
76+
s.lastPath = path
5977
s.lastAuthorization = r.Header.Get("Authorization")
60-
s.lastBody = nil
61-
if body, err := io.ReadAll(r.Body); err == nil && len(body) > 0 {
62-
_ = json.Unmarshal(body, &s.lastBody)
63-
}
64-
s.bodies = append(s.bodies, s.lastBody)
78+
s.lastBody = body
79+
s.bodies = append(s.bodies, body)
80+
dataForPath, dataFor, data := s.dataForPath, s.dataFor, s.data
81+
s.mu.Unlock()
6582

83+
// dataForPath/dataFor/data run outside the lock: some tests (e.g. ones
84+
// exercising verifyIncidentCommentsWritten's concurrent fan-out) rely on
85+
// being able to observe genuine overlap between in-flight requests, which
86+
// holding s.mu across the call would silently serialize away. A test
87+
// closure that itself touches shared state is responsible for its own
88+
// synchronization, same as any other concurrently-invoked callback.
6689
var payload any
6790
switch {
68-
case s.dataForPath != nil:
69-
payload = s.dataForPath(s.lastPath, s.lastBody)
70-
case s.dataFor != nil:
71-
payload = s.dataFor(s.lastBody)
72-
case s.data != nil:
73-
payload = s.data
91+
case dataForPath != nil:
92+
payload = dataForPath(path, body)
93+
case dataFor != nil:
94+
payload = dataFor(body)
95+
case data != nil:
96+
payload = data
7497
default:
7598
payload = map[string]any{}
7699
}
100+
77101
resp := map[string]any{
78102
"request_id": "test-request-id",
79103
"error": map[string]any{"code": "OK", "message": ""},

internal/cli/incident.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"regexp"
1010
"strconv"
1111
"strings"
12+
"sync"
1213
"time"
1314

1415
"github.com/flashcatcloud/go-flashduty"
@@ -946,6 +947,13 @@ func resolveCommentFile(path string) (string, error) {
946947
// headroom for any real incident, and only bounds a pathological one.
947948
const maxIncidentFeedVerifyPages = 20
948949

950+
// maxIncidentVerifyConcurrency bounds how many incidents
951+
// verifyIncidentCommentsWritten checks at once. These are network round-trips
952+
// against one API, not CPU work, so the bound is a fixed small number rather
953+
// than something scaled off NumCPU or exposed as a flag: it exists only to
954+
// avoid firing all of a 100-incident batch's requests at the same instant.
955+
const maxIncidentVerifyConcurrency = 8
956+
949957
// commentVerificationGuidance is appended, verbatim, to every comment
950958
// verification failure — whether the entry simply couldn't be located (page
951959
// budget) or the re-fetch itself errored (transport failure). Both are
@@ -987,7 +995,18 @@ const commentVerificationNotFoundDetailFmt = "no timeline entry matches the writ
987995
// id before it can say what it says about the batch as a whole — reporting
988996
// only the first failure would leave any incident after it silently,
989997
// permanently unexamined while the agent believes it handled everything the
990-
// error mentioned.
998+
// error mentioned. This rules out an errgroup-style fail-fast fan-out: this
999+
// function's whole point is to keep going after a failure, not stop on one.
1000+
//
1001+
// Per-incident checks run concurrently, up to maxIncidentVerifyConcurrency at
1002+
// a time, instead of walking ctx.Args one at a time: with a batch of up to
1003+
// 100 incidents and up to maxIncidentFeedVerifyPages page fetches apiece, a
1004+
// sequential walk could take thousands of round-trips before the command
1005+
// returned. Each goroutine writes only its own slot of a pre-sized results
1006+
// slice (index == position in ctx.Args), so the problems reported below
1007+
// always come out in ctx.Args order regardless of which request happens to
1008+
// finish first — the message must read the same on every run, not depend on
1009+
// network timing.
9911010
//
9921011
// Neither a page-budget miss nor a transport error while re-fetching the feed
9931012
// means the write was corrupted — corruption can no longer even be observed
@@ -1002,14 +1021,32 @@ const commentVerificationNotFoundDetailFmt = "no timeline entry matches the writ
10021021
// simply couldn't be located within budget). The safe next step is to look,
10031022
// not to write.
10041023
func verifyIncidentCommentsWritten(ctx *RunContext, want string) error {
1005-
var problems []string
1006-
for _, id := range ctx.Args {
1007-
found, err := incidentTimelineHasComment(ctx, id, want)
1008-
switch {
1009-
case err != nil:
1010-
problems = append(problems, fmt.Sprintf("incident %s: %v", id, err))
1011-
case !found:
1012-
problems = append(problems, fmt.Sprintf("incident %s: "+commentVerificationNotFoundDetailFmt, id, maxIncidentFeedVerifyPages))
1024+
slots := make([]string, len(ctx.Args))
1025+
1026+
var wg sync.WaitGroup
1027+
sem := make(chan struct{}, maxIncidentVerifyConcurrency)
1028+
for i, id := range ctx.Args {
1029+
wg.Add(1)
1030+
go func(i int, id string) {
1031+
defer wg.Done()
1032+
sem <- struct{}{}
1033+
defer func() { <-sem }()
1034+
1035+
found, err := incidentTimelineHasComment(ctx, id, want)
1036+
switch {
1037+
case err != nil:
1038+
slots[i] = fmt.Sprintf("incident %s: %v", id, err)
1039+
case !found:
1040+
slots[i] = fmt.Sprintf("incident %s: "+commentVerificationNotFoundDetailFmt, id, maxIncidentFeedVerifyPages)
1041+
}
1042+
}(i, id)
1043+
}
1044+
wg.Wait()
1045+
1046+
problems := make([]string, 0, len(slots))
1047+
for _, p := range slots {
1048+
if p != "" {
1049+
problems = append(problems, p)
10131050
}
10141051
}
10151052
if len(problems) == 0 {

0 commit comments

Comments
 (0)