mirror of
https://github.com/temporalio/temporal.git
synced 2026-08-30 18:41:49 -07:00
Add data race summary to CI report (#11211)
## What changed? Add data race summary to CI report ## Why? Notify when CI detects data race issues in main. ## How did you test it? - [X] built - [X] run locally and tested manually - [X] covered by existing tests - [X] added new unit test(s) - [ ] added new functional test(s)
This commit is contained in:
33
.github/workflows/run-tests.yml
vendored
33
.github/workflows/run-tests.yml
vendored
@@ -551,3 +551,36 @@ jobs:
|
|||||||
go run ./cmd/tools/ci-notify alert \
|
go run ./cmd/tools/ci-notify alert \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--slack-webhook "$SLACK_WEBHOOK"
|
--slack-webhook "$SLACK_WEBHOOK"
|
||||||
|
|
||||||
|
notify-data-race:
|
||||||
|
name: Notify Slack on Data Race
|
||||||
|
# Runs on both passing and failing main runs: the race detector can trip on
|
||||||
|
# one attempt and pass on a retry, leaving the run green while a data race
|
||||||
|
# was still recorded in the test-summary artifacts.
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
github.ref == 'refs/heads/main' &&
|
||||||
|
(needs.test-status.result == 'success' || needs.test-status.result == 'failure')
|
||||||
|
needs: test-status
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Send Slack notification
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
go run ./cmd/tools/ci-notify data-race \
|
||||||
|
--run-id "${{ github.run_id }}" \
|
||||||
|
--slack-webhook "$SLACK_WEBHOOK"
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ func NewCliApp() *cli.App {
|
|||||||
},
|
},
|
||||||
Action: runAlertCommand,
|
Action: runAlertCommand,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "data-race",
|
||||||
|
Usage: "Send a notification when data races are detected in a CI run",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: FlagRunID,
|
||||||
|
Usage: "GitHub Actions run ID",
|
||||||
|
Required: true,
|
||||||
|
EnvVars: []string{"GITHUB_RUN_ID"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: FlagSlackWebhook,
|
||||||
|
Usage: "Slack webhook URL",
|
||||||
|
Required: false, // Not required for dry-run
|
||||||
|
EnvVars: []string{"SLACK_WEBHOOK"},
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: FlagDryRun,
|
||||||
|
Usage: "Print message without sending to Slack",
|
||||||
|
Value: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: runDataRaceCommand,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "digest",
|
Name: "digest",
|
||||||
Usage: "Generate digest report for CI runs",
|
Usage: "Generate digest report for CI runs",
|
||||||
@@ -157,6 +181,78 @@ func runAlertCommand(c *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runDataRaceCommand(c *cli.Context) error {
|
||||||
|
// Set up logger
|
||||||
|
logger, err := zap.NewProduction()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err)
|
||||||
|
return nil // Don't fail CI if notification fails
|
||||||
|
}
|
||||||
|
defer func() { _ = logger.Sync() }()
|
||||||
|
|
||||||
|
runID := c.String(FlagRunID)
|
||||||
|
slackWebhook := c.String(FlagSlackWebhook)
|
||||||
|
dryRun := c.Bool(FlagDryRun)
|
||||||
|
|
||||||
|
logger.Info("Starting `ci-notify data-race`",
|
||||||
|
zap.String("run_id", runID),
|
||||||
|
zap.Bool("dry_run", dryRun),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build data race report
|
||||||
|
report, err := BuildDataRaceReport(runID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to build data race report", zap.Error(err))
|
||||||
|
// Don't fail CI if notification fails
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Built data race report",
|
||||||
|
zap.Int("data_races", len(report.DataRaces)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nothing to report: stay silent so green runs don't spam the channel.
|
||||||
|
if len(report.DataRaces) == 0 {
|
||||||
|
logger.Info("No data races detected; skipping notification")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dry-run mode
|
||||||
|
if dryRun {
|
||||||
|
logger.Info("Dry-run mode: printing message to stdout")
|
||||||
|
message := BuildDataRaceMessage(report)
|
||||||
|
fmt.Println(message.RenderMarkdown())
|
||||||
|
fmt.Println("\n--- Slack JSON Payload ---")
|
||||||
|
payload, err := marshalIndent(message)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to marshal message for display", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Println(payload)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate webhook URL
|
||||||
|
if slackWebhook == "" {
|
||||||
|
logger.Error("Slack webhook URL is required when not in dry-run mode")
|
||||||
|
// Don't fail CI if notification fails
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and send Slack message
|
||||||
|
message := BuildDataRaceMessage(report)
|
||||||
|
|
||||||
|
logger.Info("Sending Slack notification")
|
||||||
|
if err := message.Send(slackWebhook); err != nil {
|
||||||
|
logger.Error("Failed to send Slack message", zap.Error(err))
|
||||||
|
// Don't fail CI if notification fails
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Slack notification sent successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runDigestCommand(c *cli.Context) error {
|
func runDigestCommand(c *cli.Context) error {
|
||||||
// Set up logger
|
// Set up logger
|
||||||
logger, err := zap.NewProduction()
|
logger, err := zap.NewProduction()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
summaryArtifactPrefix = "test-summary-json--"
|
summaryArtifactPrefix = "test-summary-json--"
|
||||||
summaryFileSuffix = "test-summary.json"
|
summaryFileSuffix = "test-summary.json"
|
||||||
summaryKindOOM = "OOM"
|
summaryKindOOM = "OOM"
|
||||||
|
summaryKindDataRace = "DATA RACE"
|
||||||
)
|
)
|
||||||
|
|
||||||
var trailingFailureSuffixRegex = regexp.MustCompile(`\s*\([^)]+\)$`)
|
var trailingFailureSuffixRegex = regexp.MustCompile(`\s*\([^)]+\)$`)
|
||||||
@@ -28,27 +29,31 @@ type testSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type summaryRow struct {
|
type summaryRow struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Final bool `json:"final"`
|
Details string `json:"details"`
|
||||||
|
Final bool `json:"final"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFailures(ctx context.Context, runID int64) ([]string, error) {
|
// forEachSummaryZip downloads every test-summary artifact for the run and
|
||||||
|
// invokes fn with each artifact's name and the local path of its downloaded zip.
|
||||||
|
// Artifacts that fail to download are skipped. All downloads share a single temp
|
||||||
|
// dir that is removed when the function returns.
|
||||||
|
func forEachSummaryZip(ctx context.Context, runID int64, fn func(artifactName, zipPath string)) error {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
artifacts, err := github.ListRunArtifacts(ctx, temporalRepository, runID)
|
artifacts, err := github.ListRunArtifacts(ctx, temporalRepository, runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tempDir, err := os.MkdirTemp("", "ci-notify-artifacts-*")
|
tempDir, err := os.MkdirTemp("", "ci-notify-artifacts-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||||
|
|
||||||
var failures []string
|
|
||||||
for _, artifact := range artifacts {
|
for _, artifact := range artifacts {
|
||||||
if artifact.Expired || !strings.HasPrefix(artifact.Name, summaryArtifactPrefix) {
|
if artifact.Expired || !strings.HasPrefix(artifact.Name, summaryArtifactPrefix) {
|
||||||
continue
|
continue
|
||||||
@@ -58,40 +63,58 @@ func getFailures(ctx context.Context, runID int64) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
fn(artifact.Name, zipPath)
|
||||||
artifactFailures, err := failuresFromZip(zipPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
failures = append(failures, artifactFailures...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFailures(ctx context.Context, runID int64) ([]string, error) {
|
||||||
|
var failures []string
|
||||||
|
err := forEachSummaryZip(ctx, runID, func(_, zipPath string) {
|
||||||
|
artifactFailures, err := failuresFromZip(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failures = append(failures, artifactFailures...)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return uniqueSorted(failures), nil
|
return uniqueSorted(failures), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func failuresFromZip(zipPath string) ([]string, error) {
|
func failuresFromZip(zipPath string) ([]string, error) {
|
||||||
|
rows, err := summaryRowsFromZip(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reportableFailures(rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryRowsFromZip(zipPath string) ([]summaryRow, error) {
|
||||||
reader, err := zip.OpenReader(zipPath)
|
reader, err := zip.OpenReader(zipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open artifact zip %s: %w", zipPath, err)
|
return nil, fmt.Errorf("failed to open artifact zip %s: %w", zipPath, err)
|
||||||
}
|
}
|
||||||
defer func() { _ = reader.Close() }()
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
var failures []string
|
var rows []summaryRow
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
if file.FileInfo().IsDir() || !strings.HasSuffix(file.Name, summaryFileSuffix) {
|
if file.FileInfo().IsDir() || !strings.HasSuffix(file.Name, summaryFileSuffix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fileFailures, err := failuresFromZipFile(file)
|
fileRows, err := summaryRowsFromZipFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
failures = append(failures, fileFailures...)
|
rows = append(rows, fileRows...)
|
||||||
}
|
}
|
||||||
return failures, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func failuresFromZipFile(file *zip.File) ([]string, error) {
|
func summaryRowsFromZipFile(file *zip.File) ([]summaryRow, error) {
|
||||||
rc, err := file.Open()
|
rc, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open %s in artifact zip: %w", file.Name, err)
|
return nil, fmt.Errorf("failed to open %s in artifact zip: %w", file.Name, err)
|
||||||
@@ -102,7 +125,7 @@ func failuresFromZipFile(file *zip.File) ([]string, error) {
|
|||||||
if err := json.NewDecoder(rc).Decode(&summary); err != nil {
|
if err := json.NewDecoder(rc).Decode(&summary); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse %s in artifact zip: %w", file.Name, err)
|
return nil, fmt.Errorf("failed to parse %s in artifact zip: %w", file.Name, err)
|
||||||
}
|
}
|
||||||
return reportableFailures(summary.Rows), nil
|
return summary.Rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportableFailures(rows []summaryRow) []string {
|
func reportableFailures(rows []summaryRow) []string {
|
||||||
|
|||||||
254
tools/ci-notify/datarace.go
Normal file
254
tools/ci-notify/datarace.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package cinotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.temporal.io/server/tools/common/github"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRaceSites caps how many conflicting-access lines we surface per race in
|
||||||
|
// case the test-runner merged several race reports into one alert.
|
||||||
|
const maxRaceSites = 6
|
||||||
|
|
||||||
|
var (
|
||||||
|
// raceAccessRegex matches a race detector access header. The current access
|
||||||
|
// is "Read at"/"Write at"; the conflicting one is "Previous read at"/
|
||||||
|
// "Previous write at". At least one is always a write, but a race can be
|
||||||
|
// write/write, so both kinds must be matched.
|
||||||
|
raceAccessRegex = regexp.MustCompile(`^(Read|Write|Previous read|Previous write) at 0x[0-9a-fA-F]+ by (goroutine \d+|main goroutine)`)
|
||||||
|
// raceFileLineRegex matches a "<path>.go:<line>" stack frame location.
|
||||||
|
raceFileLineRegex = regexp.MustCompile(`(\S+\.go):(\d+)`)
|
||||||
|
// repoPathPrefixRegex strips the CI checkout prefix (e.g.
|
||||||
|
// /home/runner/work/temporal/temporal/) so paths read as service/history/….
|
||||||
|
repoPathPrefixRegex = regexp.MustCompile(`^.*/temporal/temporal/`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// temporalModulePrefix is trimmed from race locations so we show, e.g.,
|
||||||
|
// "service/history.TestFoo" instead of the fully-qualified package path.
|
||||||
|
const temporalModulePrefix = "go.temporal.io/server/"
|
||||||
|
|
||||||
|
// DataRace is a single data race detected by the Go race detector during a CI
|
||||||
|
// run. The test-runner surfaces these into the JUnit ALERTS suite and the
|
||||||
|
// test-summary.json artifact (see tools/testrunner/junit.go).
|
||||||
|
type DataRace struct {
|
||||||
|
// Location is the package-qualified test where the race was detected,
|
||||||
|
// e.g. "service/history.TestFoo".
|
||||||
|
Location string
|
||||||
|
// Details is the race detector's report (stacktraces), possibly truncated.
|
||||||
|
Details string
|
||||||
|
// JobID is the GitHub Actions job that reported the race, used to link
|
||||||
|
// directly to the offending job. Empty when it can't be determined.
|
||||||
|
JobID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataRaceReport aggregates the data races found in a single CI run along with
|
||||||
|
// the commit context needed to attribute and link them.
|
||||||
|
type DataRaceReport struct {
|
||||||
|
Run github.Run
|
||||||
|
Author string
|
||||||
|
Title string
|
||||||
|
DataRaces []DataRace
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDataRaceReport fetches the run's test summaries, extracts any data races,
|
||||||
|
// and enriches them with commit metadata. It returns a report with an empty
|
||||||
|
// DataRaces slice when no races were detected.
|
||||||
|
func BuildDataRaceReport(runID string) (*DataRaceReport, error) {
|
||||||
|
run, err := getWorkflowRun(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
races, err := getDataRaces(context.Background(), run.DatabaseID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
report := &DataRaceReport{
|
||||||
|
Run: *run,
|
||||||
|
Title: run.DisplayTitle,
|
||||||
|
DataRaces: races,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit metadata is best-effort: a missing author must not suppress the alert.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if commit, err := github.GetCommit(ctx, temporalRepository, run.HeadSHA); err == nil {
|
||||||
|
report.Author = commit.Commit.Author.Name
|
||||||
|
if title := commit.Title(); title != "" {
|
||||||
|
report.Title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDataRaces downloads the run's test-summary artifacts and returns the unique
|
||||||
|
// set of data races reported across all of them.
|
||||||
|
func getDataRaces(ctx context.Context, runID int64) ([]DataRace, error) {
|
||||||
|
var races []DataRace
|
||||||
|
err := forEachSummaryZip(ctx, runID, func(artifactName, zipPath string) {
|
||||||
|
rows, err := summaryRowsFromZip(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
races = append(races, dataRacesFromRows(rows, jobIDFromArtifactName(artifactName))...)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uniqueDataRaces(races), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataRacesFromRows extracts data race rows from a parsed test summary, tagging
|
||||||
|
// each with the job that produced the artifact.
|
||||||
|
func dataRacesFromRows(rows []summaryRow, jobID string) []DataRace {
|
||||||
|
var races []DataRace
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Kind != summaryKindDataRace {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
races = append(races, DataRace{
|
||||||
|
Location: dataRaceLocation(row.Name),
|
||||||
|
Details: row.Details,
|
||||||
|
JobID: jobID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return races
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataRaceLocation reduces a test-runner alert name like
|
||||||
|
// "DATA RACE: Data race detected — in go.temporal.io/server/service/history.TestFoo"
|
||||||
|
// to just the package-qualified test, e.g. "service/history.TestFoo".
|
||||||
|
func dataRaceLocation(name string) string {
|
||||||
|
loc := strings.TrimSpace(strings.TrimPrefix(name, summaryKindDataRace+":"))
|
||||||
|
if _, after, ok := strings.Cut(loc, "— in "); ok {
|
||||||
|
loc = strings.TrimSpace(after)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(loc, temporalModulePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceSite is one conflicting memory access from a race detector report.
|
||||||
|
type raceSite struct {
|
||||||
|
access string // "Read", "Write", "Previous read", or "Previous write"
|
||||||
|
goroutine string // "goroutine 8" or "main goroutine"
|
||||||
|
location string // "service/history/mutable_state.go:127"
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRaceSites reduces a race detector report to the conflicting memory
|
||||||
|
// accesses and the source line each occurred at. It handles read/write and
|
||||||
|
// write/write races. Returns nil when the report can't be parsed, so callers can
|
||||||
|
// degrade to just the location and job link.
|
||||||
|
func parseRaceSites(details string) []raceSite {
|
||||||
|
lines := strings.Split(details, "\n")
|
||||||
|
var sites []raceSite
|
||||||
|
for i, line := range lines {
|
||||||
|
m := raceAccessRegex.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loc := firstGoFrame(lines[i+1:]); loc != "" {
|
||||||
|
sites = append(sites, raceSite{access: m[1], goroutine: m[2], location: loc})
|
||||||
|
if len(sites) == maxRaceSites {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceSites formats the conflicting accesses for display, e.g.
|
||||||
|
//
|
||||||
|
// Read at (goroutine 8): service/history/mutable_state.go:127
|
||||||
|
// Previous write at (goroutine 7): service/history/mutable_state.go:130
|
||||||
|
func raceSites(details string) []string {
|
||||||
|
sites := parseRaceSites(details)
|
||||||
|
out := make([]string, 0, len(sites))
|
||||||
|
for _, s := range sites {
|
||||||
|
out = append(out, fmt.Sprintf("%s at (%s): %s", s.access, s.goroutine, s.location))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceAffectedLines returns the sorted, de-duplicated source lines involved in a
|
||||||
|
// race. Unlike the raw report, this excludes memory addresses and goroutine ids
|
||||||
|
// (which differ every run), so it identifies a race stably across shards.
|
||||||
|
func raceAffectedLines(details string) []string {
|
||||||
|
sites := parseRaceSites(details)
|
||||||
|
locations := make([]string, 0, len(sites))
|
||||||
|
for _, s := range sites {
|
||||||
|
locations = append(locations, s.location)
|
||||||
|
}
|
||||||
|
slices.Sort(locations)
|
||||||
|
return slices.Compact(locations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstGoFrame returns "<path>.go:<line>" for the first application stack frame
|
||||||
|
// following an access header, skipping the race detector's runtime shim frames
|
||||||
|
// (e.g. runtime.raceread in race_amd64.s). It stops at the blank line that ends
|
||||||
|
// the access's stack.
|
||||||
|
func firstGoFrame(lines []string) string {
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "" // end of this access's stack; no application frame found
|
||||||
|
}
|
||||||
|
m := raceFileLineRegex.FindStringSubmatch(trimmed)
|
||||||
|
if m == nil || strings.Contains(m[1], "/src/runtime/") {
|
||||||
|
continue // function-name line, assembly shim, or Go runtime internals
|
||||||
|
}
|
||||||
|
return trimRepoPath(m[1]) + ":" + m[2]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimRepoPath strips the CI checkout prefix from an absolute source path.
|
||||||
|
func trimRepoPath(path string) string {
|
||||||
|
return repoPathPrefixRegex.ReplaceAllString(path, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobIDFromArtifactName extracts the job ID from a test-summary artifact name of
|
||||||
|
// the form "test-summary-json--<run_id>--<job_id>--<run_attempt>--<suffix>".
|
||||||
|
// Returns "" when the name doesn't carry a job ID.
|
||||||
|
func jobIDFromArtifactName(name string) string {
|
||||||
|
parts := strings.Split(name, "--")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueDataRaces removes duplicate races (the same race is reported by every
|
||||||
|
// shard/attempt that hit it) while preserving first-seen order. Races are keyed
|
||||||
|
// by their test and affected source lines rather than the raw report, so the
|
||||||
|
// same race still de-duplicates despite the differing memory addresses and
|
||||||
|
// goroutine ids the detector prints each run.
|
||||||
|
func uniqueDataRaces(races []DataRace) []DataRace {
|
||||||
|
seen := make(map[string]struct{}, len(races))
|
||||||
|
var unique []DataRace
|
||||||
|
for _, race := range races {
|
||||||
|
key := raceFingerprint(race)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
unique = append(unique, race)
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceFingerprint is a stable identity for a race: its test plus the set of
|
||||||
|
// affected source lines. Falls back to the raw report when no source lines can
|
||||||
|
// be parsed, so unparseable reports aren't over-merged.
|
||||||
|
func raceFingerprint(race DataRace) string {
|
||||||
|
lines := raceAffectedLines(race.Details)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return race.Location + "\n" + race.Details
|
||||||
|
}
|
||||||
|
return race.Location + "\n" + strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
210
tools/ci-notify/datarace_test.go
Normal file
210
tools/ci-notify/datarace_test.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package cinotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.temporal.io/server/tools/common/github"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDataRacesFromRows(t *testing.T) {
|
||||||
|
rows := []summaryRow{
|
||||||
|
{Kind: "Failed", Name: "TestFoo (final)", Final: true},
|
||||||
|
{
|
||||||
|
Kind: "DATA RACE",
|
||||||
|
Name: "DATA RACE: Data race detected — in go.temporal.io/server/service/history.TestBar",
|
||||||
|
Details: "WARNING: DATA RACE\nRead at 0x00c0...",
|
||||||
|
},
|
||||||
|
{Kind: "OOM", Name: "OOM prevention"},
|
||||||
|
{Kind: "PANIC", Name: "PANIC: boom"},
|
||||||
|
}
|
||||||
|
|
||||||
|
races := dataRacesFromRows(rows, "job-1")
|
||||||
|
|
||||||
|
require.Len(t, races, 1)
|
||||||
|
require.Equal(t, "service/history.TestBar", races[0].Location)
|
||||||
|
require.Equal(t, "job-1", races[0].JobID)
|
||||||
|
require.Contains(t, races[0].Details, "WARNING: DATA RACE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataRacesFromRowsNone(t *testing.T) {
|
||||||
|
rows := []summaryRow{
|
||||||
|
{Kind: "Failed", Name: "TestFoo (final)", Final: true},
|
||||||
|
{Kind: "OOM", Name: "OOM prevention"},
|
||||||
|
}
|
||||||
|
require.Empty(t, dataRacesFromRows(rows, "job-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataRaceLocation(t *testing.T) {
|
||||||
|
require.Equal(t,
|
||||||
|
"service/history.TestBar",
|
||||||
|
dataRaceLocation("DATA RACE: Data race detected — in go.temporal.io/server/service/history.TestBar"),
|
||||||
|
)
|
||||||
|
// A race not attributed to a temporal package keeps its qualified name.
|
||||||
|
require.Equal(t,
|
||||||
|
"example.com/foo.TestBaz",
|
||||||
|
dataRaceLocation("DATA RACE: Data race detected — in example.com/foo.TestBaz"),
|
||||||
|
)
|
||||||
|
// A name without the "— in" locator falls back to the summary text.
|
||||||
|
require.Equal(t, "Data race detected", dataRaceLocation("DATA RACE: Data race detected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobIDFromArtifactName(t *testing.T) {
|
||||||
|
require.Equal(t, "12345", jobIDFromArtifactName("test-summary-json--999--12345--1--unit-test"))
|
||||||
|
require.Empty(t, jobIDFromArtifactName("test-summary-json--999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniqueDataRaces(t *testing.T) {
|
||||||
|
// The same race reported by two shards: identical source lines, but the race
|
||||||
|
// detector prints different memory addresses and goroutine ids each run.
|
||||||
|
shard1 := readWriteRaceDetails
|
||||||
|
shard2 := strings.NewReplacer(
|
||||||
|
"0x00c0001121e8", "0xdeadbeef",
|
||||||
|
"goroutine 8", "goroutine 42",
|
||||||
|
"goroutine 7", "goroutine 99",
|
||||||
|
).Replace(shard1)
|
||||||
|
|
||||||
|
// A race at a different source line is a distinct race.
|
||||||
|
otherLine := strings.ReplaceAll(shard1, "mutable_state.go:130", "mutable_state.go:200")
|
||||||
|
|
||||||
|
races := []DataRace{
|
||||||
|
{Location: "service/history.TestMS", Details: shard1, JobID: "1"},
|
||||||
|
{Location: "service/history.TestMS", Details: shard2, JobID: "2"}, // dup of shard1
|
||||||
|
{Location: "service/history.TestMS", Details: otherLine}, // different line
|
||||||
|
{Location: "service/matching.TestCache", Details: shard1}, // different test
|
||||||
|
}
|
||||||
|
|
||||||
|
unique := uniqueDataRaces(races)
|
||||||
|
|
||||||
|
require.Len(t, unique, 3)
|
||||||
|
require.Equal(t, "1", unique[0].JobID) // first occurrence wins
|
||||||
|
require.Equal(t, "service/history.TestMS", unique[1].Location)
|
||||||
|
require.Equal(t, "service/matching.TestCache", unique[2].Location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummaryRowsFromZipParsesDataRaceDetails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
zipPath := filepath.Join(dir, "artifact.zip")
|
||||||
|
file, err := os.Create(zipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
writer := zip.NewWriter(file)
|
||||||
|
summaryFile, err := writer.Create("test-summary.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = summaryFile.Write([]byte(`{
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"kind": "DATA RACE",
|
||||||
|
"name": "DATA RACE: Data race detected — in go.temporal.io/server/pkg.TestRacy",
|
||||||
|
"details": "WARNING: DATA RACE\nWrite at 0x00c000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
require.NoError(t, file.Close())
|
||||||
|
|
||||||
|
rows, err := summaryRowsFromZip(zipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
races := dataRacesFromRows(rows, "job-9")
|
||||||
|
require.Len(t, races, 1)
|
||||||
|
require.Equal(t, "pkg.TestRacy", races[0].Location)
|
||||||
|
require.Contains(t, races[0].Details, "WARNING: DATA RACE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWriteRaceDetails is a read/write race whose top frame is the race
|
||||||
|
// detector's runtime shim (which must be skipped) and whose source paths use the
|
||||||
|
// CI checkout prefix (which must be trimmed).
|
||||||
|
const readWriteRaceDetails = `==================
|
||||||
|
WARNING: DATA RACE
|
||||||
|
Read at 0x00c0001121e8 by goroutine 8:
|
||||||
|
runtime.raceread()
|
||||||
|
/usr/local/go/src/runtime/race_amd64.s:260 +0x21
|
||||||
|
go.temporal.io/server/service/history.(*ms).Get()
|
||||||
|
/home/runner/work/temporal/temporal/service/history/mutable_state.go:127 +0x2c
|
||||||
|
|
||||||
|
Previous write at 0x00c0001121e8 by goroutine 7:
|
||||||
|
runtime.racewrite()
|
||||||
|
/usr/local/go/src/runtime/race_amd64.s:269 +0x21
|
||||||
|
go.temporal.io/server/service/history.(*ms).Set()
|
||||||
|
/home/runner/work/temporal/temporal/service/history/mutable_state.go:130 +0x3c
|
||||||
|
|
||||||
|
Goroutine 8 (running) created at:
|
||||||
|
go.temporal.io/server/service/history.TestMutableState()
|
||||||
|
/home/runner/work/temporal/temporal/service/history/mutable_state_test.go:41 +0x88
|
||||||
|
==================`
|
||||||
|
|
||||||
|
// writeWriteRaceDetails is a race between two concurrent writes.
|
||||||
|
const writeWriteRaceDetails = `==================
|
||||||
|
WARNING: DATA RACE
|
||||||
|
Write at 0x00c000000180 by goroutine 7:
|
||||||
|
go.temporal.io/server/service/matching.(*cache).put()
|
||||||
|
/home/runner/work/temporal/temporal/service/matching/cache.go:88 +0x66
|
||||||
|
|
||||||
|
Previous write at 0x00c000000180 by goroutine 12:
|
||||||
|
go.temporal.io/server/service/matching.(*cache).put()
|
||||||
|
/home/runner/work/temporal/temporal/service/matching/cache.go:88 +0x66
|
||||||
|
==================`
|
||||||
|
|
||||||
|
func TestRaceSites(t *testing.T) {
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"Read at (goroutine 8): service/history/mutable_state.go:127",
|
||||||
|
"Previous write at (goroutine 7): service/history/mutable_state.go:130",
|
||||||
|
}, raceSites(readWriteRaceDetails))
|
||||||
|
|
||||||
|
// A write/write race is still a race; both writes must be surfaced.
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"Write at (goroutine 7): service/matching/cache.go:88",
|
||||||
|
"Previous write at (goroutine 12): service/matching/cache.go:88",
|
||||||
|
}, raceSites(writeWriteRaceDetails))
|
||||||
|
|
||||||
|
// Unparseable details yield no sites; the caller falls back to location + link.
|
||||||
|
require.Empty(t, raceSites("some unrelated text"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDataRaceMessageLinksToJob(t *testing.T) {
|
||||||
|
report := &DataRaceReport{
|
||||||
|
Run: github.Run{
|
||||||
|
DatabaseID: 123456,
|
||||||
|
HeadSHA: "abc1234567890defghijk",
|
||||||
|
URL: "https://github.com/temporalio/temporal/actions/runs/123456",
|
||||||
|
},
|
||||||
|
Author: "Test Author",
|
||||||
|
Title: "Some commit title",
|
||||||
|
DataRaces: []DataRace{
|
||||||
|
{Location: "service/history.TestMutableState", Details: readWriteRaceDetails, JobID: "789"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := BuildDataRaceMessage(report).RenderMarkdown()
|
||||||
|
|
||||||
|
require.Contains(t, rendered, "Data Race Detected on Main Branch")
|
||||||
|
require.Contains(t, rendered, "Test Author")
|
||||||
|
require.Contains(t, rendered, "service/history.TestMutableState")
|
||||||
|
require.Contains(t, rendered, "Read at (goroutine 8): service/history/mutable_state.go:127")
|
||||||
|
require.Contains(t, rendered, "Previous write at (goroutine 7): service/history/mutable_state.go:130")
|
||||||
|
require.Contains(t, rendered, "abc1234") // short SHA link
|
||||||
|
// Links to the specific job, not just the top-level run.
|
||||||
|
require.Contains(t, rendered, "actions/runs/123456/job/789")
|
||||||
|
// Runtime shim frames and raw stacktrace noise are not dumped into Slack.
|
||||||
|
require.NotContains(t, rendered, "race_amd64.s")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDataRaceMessageFallsBackToRunLink(t *testing.T) {
|
||||||
|
report := &DataRaceReport{
|
||||||
|
Run: github.Run{DatabaseID: 123456, HeadSHA: "abc1234567890"},
|
||||||
|
DataRaces: []DataRace{{Location: "pkg.TestRacy", Details: "unparseable"}}, // no JobID
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := BuildDataRaceMessage(report).RenderMarkdown()
|
||||||
|
|
||||||
|
require.Contains(t, rendered, "Unknown") // author
|
||||||
|
require.Contains(t, rendered, "pkg.TestRacy")
|
||||||
|
require.Contains(t, rendered, "actions/runs/123456")
|
||||||
|
require.NotContains(t, rendered, "/job/")
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package cinotify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"go.temporal.io/server/tools/common/github"
|
||||||
"go.temporal.io/server/tools/common/slack"
|
"go.temporal.io/server/tools/common/slack"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,6 +72,52 @@ func FormatMessageForDebug(report *FailureReport) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildDataRaceMessage creates a Slack message announcing data races on main.
|
||||||
|
func BuildDataRaceMessage(report *DataRaceReport) *slack.Message {
|
||||||
|
runID := strconv.FormatInt(report.Run.DatabaseID, 10)
|
||||||
|
commitURL := github.CommitURL(temporalRepository, report.Run.HeadSHA)
|
||||||
|
|
||||||
|
message := slack.NewMessage(fmt.Sprintf("Data Race Detected on Main (%d)", len(report.DataRaces)))
|
||||||
|
message.AddSection(":rotating_light: *Data Race Detected on Main Branch* :rotating_light:")
|
||||||
|
message.AddFields(
|
||||||
|
fmt.Sprintf("*Commit:*\n<%s|%s>", commitURL, report.Run.ShortSHA()),
|
||||||
|
fmt.Sprintf("*Author:*\n%s", orUnknown(report.Author)),
|
||||||
|
)
|
||||||
|
if report.Title != "" {
|
||||||
|
message.AddSection(fmt.Sprintf("*Commit message:*\n%s", report.Title))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, race := range report.DataRaces {
|
||||||
|
var sb strings.Builder
|
||||||
|
if race.Location != "" {
|
||||||
|
fmt.Fprintf(&sb, "*%s*", race.Location)
|
||||||
|
}
|
||||||
|
for _, site := range raceSites(race.Details) {
|
||||||
|
fmt.Fprintf(&sb, "\n• %s", site)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "\n<%s|View job logs>", raceLink(runID, race))
|
||||||
|
message.AddSection(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func orUnknown(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceLink points at the specific job that reported the race so the alert is
|
||||||
|
// directly actionable, falling back to the run when the job is unknown.
|
||||||
|
func raceLink(runID string, race DataRace) string {
|
||||||
|
if race.JobID != "" {
|
||||||
|
return github.JobURL(temporalRepository, runID, race.JobID)
|
||||||
|
}
|
||||||
|
return github.RunURL(temporalRepository, runID)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildSuccessReportMessage creates a Slack message for success report
|
// BuildSuccessReportMessage creates a Slack message for success report
|
||||||
func BuildSuccessReportMessage(report *DigestReport) *slack.Message {
|
func BuildSuccessReportMessage(report *DigestReport) *slack.Message {
|
||||||
message := slack.NewMessage(fmt.Sprintf("Weekly CI Report - %s Branch", report.Branch))
|
message := slack.NewMessage(fmt.Sprintf("Weekly CI Report - %s Branch", report.Branch))
|
||||||
|
|||||||
Reference in New Issue
Block a user