Skip to content

Commit 590a252

Browse files
committed
perf(incident): verify comment write-back concurrently
incident comment re-fetches every incident's timeline after a batch write to confirm the comment landed. With up to 100 incidents per batch and up to 20 feed pages checked per incident, walking them one at a time could mean thousands of sequential round-trips before the command returned. Fan the per-incident checks out across goroutines, bounded by a small worker limit (8) via a buffered channel semaphore, instead of an errgroup: the existing failure-reporting contract requires examining every incident even after one fails, which a fail-fast fan-out would break. Each goroutine writes only its own slot in a pre-sized results slice, so the reported problem order still matches the input order regardless of completion timing. Error text, exit behavior, and page budget are unchanged. Also make the gfStub test double safe for concurrent requests (it now serves overlapping in-flight requests for the first time), and protect one existing test's own local map access accordingly.
1 parent 41d49a1 commit 590a252

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)