Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ jobs:
run: ${{ matrix.install }}
- name: Test Kitchensink ${{ matrix.sdk }}
run: |
SDK=${{ matrix.sdk }} go test -race ./loadgen -run TestKitchensink -v
SDK=${{ matrix.sdk }} go test -race ./loadgen -run TestKitchenSink -v
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed for consistency.


build-ks-gen-and-ensure-protos-up-to-date:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ to test a wide variety of scenarios without having to imagine all possible edge
come up in workflows. Input may be saved for regression testing, or hand written for specific cases.

Build by running `scripts/build-kitchensink.sh`.
Test by running `go test -v ./loadgen -run TestKitchensink`.
Test by running `go test -v ./loadgen -run TestKitchenSink`.
Prefix with env variable `SDK=<sdk>` to test a specific SDK only.

### Scenario Failure
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ require (
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.10.0
github.com/temporalio/features v0.0.0-20250714172315-c9d352c46b16
github.com/temporalio/features v0.0.0-20250808182149-bb2a99cdf200
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to pull in temporalio/features#662

go.temporal.io/api v1.50.0
go.temporal.io/sdk v1.35.0
go.uber.org/zap v1.27.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/temporalio/features v0.0.0-20250714172315-c9d352c46b16 h1:WPyu0MTcqDzQWgExKf3tbRGDVAu1WjhaZdQDHhnyMa8=
github.com/temporalio/features v0.0.0-20250714172315-c9d352c46b16/go.mod h1:OJ6CsyMQcVvR96KdznjdCbEGnq/n0RKAVcpbuaJN6Ww=
github.com/temporalio/features v0.0.0-20250808182149-bb2a99cdf200 h1:QCVAlYX6FNj8mjFyo8g0dn2yzTAppqT52T2I32l8VSE=
github.com/temporalio/features v0.0.0-20250808182149-bb2a99cdf200/go.mod h1:OJ6CsyMQcVvR96KdznjdCbEGnq/n0RKAVcpbuaJN6Ww=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
Expand Down
237 changes: 188 additions & 49 deletions loadgen/helper_historyrequire.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,58 @@ import (
"google.golang.org/protobuf/encoding/protojson"
)

type specLine struct {
EventID int64
Type string
Fields map[string]any
type event struct {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some refactorings to make the events a typed entity.

Type string
Attributes map[string]any
}

// requireHistoryMatches checks that the list of history events match the expected spec.
func (e event) String(lineNum int) string {
out := e.Type
if len(e.Attributes) > 0 {
b, _ := json.Marshal(e.Attributes)
out += " " + string(b)
}
return fmt.Sprintf("%3d: %s\n", lineNum, out)
}

type eventList []event

func (el eventList) findAllByType(eventType string) []struct {
Event *event
Index int
} {
var result []struct {
Event *event
Index int
}
for i, evt := range el {
if evt.Type == eventType {
result = append(result, struct {
Event *event
Index int
}{
Event: &evt,
Index: i,
})
}
}
return result
}

func (el eventList) Strings() []string {
result := make([]string, len(el))
for i, evt := range el {
result[i] = evt.String(i + 1)
}
return result
}

// HistoryMatcher defines the interface for history matching strategies
type HistoryMatcher interface {
Match(t *testing.T, actualHistoryEvents []*historypb.HistoryEvent) error
}

// fullHistoryMatcher checks that the list of history events match the expected spec.
// The expected spec is a string with each line containing an (1) event type, (2) optional
// JSON literal matching the event attributes and (3) an optional comment.
//
Expand All @@ -34,22 +79,26 @@ type specLine struct {
// WorkflowTaskStarted
// WorkflowTaskCompleted
// WorkflowExecutionCompleted
func requireHistoryMatches(t *testing.T, actualEvents []*historypb.HistoryEvent, expectedSpec string) {
type fullHistoryMatcher string

func (m fullHistoryMatcher) Match(t *testing.T, actualHistoryEvents []*historypb.HistoryEvent) error {
t.Helper()

actualEvents := parseEvents(actualHistoryEvents)
expectedEvents := parseSpec(t, string(m))

var diffs []string
var actualDump strings.Builder
var expectedDump strings.Builder
expectedLines := parseSpec(t, expectedSpec)
maxLines := max(len(actualEvents), len(expectedLines))
maxLines := max(len(actualEvents), len(expectedEvents))

for i := 0; i < maxLines; i++ {
lineNumber := i + 1

var expectedLine specLine
if i < len(expectedLines) {
expectedLine = expectedLines[i]
expectedDump.WriteString(formatExpectedLine(lineNumber, expectedLine, expectedLine.Fields))
var expectedEvent event
if i < len(expectedEvents) {
expectedEvent = expectedEvents[i]
expectedDump.WriteString(expectedEvent.String(lineNumber))
}

if i >= len(actualEvents) {
Expand All @@ -59,40 +108,92 @@ func requireHistoryMatches(t *testing.T, actualEvents []*historypb.HistoryEvent,
}
actualEvent := actualEvents[i]

// Transform event for comparison.
actualType := strings.TrimPrefix(actualEvent.GetEventType().String(), "EVENT_TYPE_")
actualFields, _ := flattenEvent(actualEvent)
actualDump.WriteString(formatActualLine(lineNumber, actualType, expectedLine.Fields, actualFields))
var fieldsToShow map[string]any
if expectedEvent.Attributes != nil && len(actualEvent.Attributes) > 0 {
fieldsToShow = actualEvent.Attributes
}
displayEvent := event{Type: actualEvent.Type, Attributes: fieldsToShow}
actualDump.WriteString(displayEvent.String(lineNumber))

// Compare types and fields.
if expectedLine.Type == "" {
if expectedEvent.Type == "" {
diffs = append(diffs, fmt.Sprintf("line %d: unexpected event", lineNumber))
} else if actualType != expectedLine.Type {
} else if actualEvent.Type != expectedEvent.Type {
diffs = append(diffs, fmt.Sprintf("line %d: type mismatch, expected %s got %s",
lineNumber, expectedLine.Type, actualType))
} else if !mapIsSuperset(actualFields, expectedLine.Fields) {
lineNumber, expectedEvent.Type, actualEvent.Type))
} else if !mapIsSuperset(actualEvent.Attributes, expectedEvent.Attributes) {
diffs = append(diffs, fmt.Sprintf("line %d: field(s) mismatch: expected %#v got %#v",
lineNumber, expectedLine.Fields, actualFields))
lineNumber, expectedEvent.Attributes, actualEvent.Attributes))
}
}

if len(diffs) > 0 {
t.Log("--- MISMATCH ---\n")
t.Log("EXPECTED:\n" + expectedDump.String())
t.Log("ACTUAL:\n" + actualDump.String())
logHistoryMismatch(t, string(m), actualEvents)

t.Log("--- DIFFS ---")
t.Log("--- DIFFS ---\n")
for _, d := range diffs {
t.Log(d)
}
require.Equal(t, 0, len(diffs), "history match failed")
return fmt.Errorf("history match failed with %d diffs", len(diffs))
}

return nil
}

func parseSpec(t *testing.T, input string) []specLine {
// partialHistoryMatcher checks that the list of history events contains a subsequence
// that matches the expected spec. The expected spec is a string with each line containing an (1) event type,
// (2) optional JSON literal matching the event attributes and (3) an optional comment.
// Use "..." as an event type to skip any number of events.
//
// Example:
//
// WorkflowExecutionStarted {"taskQueue":"foo-bar"}
// ...
// WorkflowExecutionCompleted
type partialHistoryMatcher string
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed because some tests are not deterministic enough to run the full history matcher.


func (m partialHistoryMatcher) Match(t *testing.T, actualHistoryEvents []*historypb.HistoryEvent) error {
t.Helper()

var lines []specLine
actualEvents := parseEvents(actualHistoryEvents)
expectedEvents := parseSpec(t, string(m))

var matchFromPosition func(actualIdx, expectedIdx int) bool
matchFromPosition = func(actualIdx, expectedIdx int) bool {
if expectedIdx >= len(expectedEvents) {
return true
}
if actualIdx >= len(actualEvents) {
return false
}

expectedEvent := expectedEvents[expectedIdx]

if expectedEvent.Type == "..." {
for i := actualIdx; i <= len(actualEvents); i++ {
if matchFromPosition(i, expectedIdx+1) {
return true
}
}
return false
}

actualEvent := actualEvents[actualIdx]
if actualEvent.Type == expectedEvent.Type && mapIsSuperset(actualEvent.Attributes, expectedEvent.Attributes) {
return matchFromPosition(actualIdx+1, expectedIdx+1)
}
return matchFromPosition(actualIdx+1, expectedIdx)
}

if matchFromPosition(0, 0) {
return nil
}

logHistoryMismatch(t, string(m), actualEvents)
return fmt.Errorf("partialHistory matcher failed: expected sequence not found in history")
}

func parseSpec(t *testing.T, input string) eventList {
var events eventList
for lineNum, raw := range strings.Split(strings.TrimSpace(input), "\n") {
raw = strings.TrimSpace(raw)

Expand All @@ -103,25 +204,38 @@ func parseSpec(t *testing.T, input string) []specLine {
tokens := strings.SplitN(raw, " ", 2)
require.NotEmptyf(t, tokens, "line %d: empty line in spec", lineNum+1)

line := specLine{Type: tokens[0]}
evt := event{Type: tokens[0]}
if len(tokens) > 1 {
err := json.Unmarshal([]byte(tokens[1]), &line.Fields)
err := json.Unmarshal([]byte(tokens[1]), &evt.Attributes)
require.NoErrorf(t, err, "line %d: invalid JSON in line: %q", lineNum+1, raw)
}
lines = append(lines, line)
events = append(events, evt)
}
return lines
return events
}

func flattenEvent(event *historypb.HistoryEvent) (map[string]any, error) {
func parseEvents(events []*historypb.HistoryEvent) eventList {
var result eventList
for _, histEvent := range events {
eventType := strings.TrimPrefix(histEvent.GetEventType().String(), "EVENT_TYPE_")
fields := extractFields(histEvent)
result = append(result, event{
Type: eventType,
Attributes: fields,
})
}
return result
}

func extractFields(event *historypb.HistoryEvent) map[string]any {
b, err := protojson.Marshal(event)
if err != nil {
return nil, err
panic(fmt.Sprintf("failed to marshal protobuf event: %v", err))
}

var raw map[string]any
if err = json.Unmarshal(b, &raw); err != nil {
return nil, err
panic(fmt.Sprintf("failed to unmarshal JSON: %v", err))
}
result := make(map[string]any)

Expand All @@ -136,7 +250,7 @@ func flattenEvent(event *historypb.HistoryEvent) (map[string]any, error) {
result[k] = v
}
}
return result, nil
return result
}

func mapIsSuperset(actual, expected map[string]any) bool {
Expand All @@ -160,24 +274,49 @@ func looselyEqual(x, y any) bool {
}
case string:
return fmt.Sprint(x) == fmt.Sprint(y)
case map[string]any:
if yMap, ok := y.(map[string]any); ok {
return mapIsSuperset(x, yMap)
}
return false
}
return reflect.DeepEqual(x, y)
}

func formatExpectedLine(lineNum int, line specLine, fields map[string]any) string {
out := fmt.Sprintf("%3d: %s", lineNum, line.Type)
if len(fields) > 0 {
b, _ := json.Marshal(fields)
out += " " + string(b)
func formatEventTypeName(event *historypb.HistoryEvent) string {
return strings.TrimPrefix(event.GetEventType().String(), "EVENT_TYPE_")
}

func logHistoryMismatch(t *testing.T, expectedSpec string, actualEvents eventList) {
t.Helper()

t.Logf("\n--- MISMATCH ---\n\n")

if expectedSpec != "" {
expectedEvents := parseSpec(t, expectedSpec)
t.Log("EXPECTED:")
for _, expectedEvent := range expectedEvents {
out := expectedEvent.Type
if len(expectedEvent.Attributes) > 0 {
b, _ := json.Marshal(expectedEvent.Attributes)
out += " " + string(b)
}
t.Logf(" %s\n", out)
}
}
return out + "\n"

t.Log("ACTUAL:")
logHistoryEvents(t, actualEvents)
}

func formatActualLine(lineNum int, typ string, expectedFields map[string]any, actualFields map[string]any) string {
out := fmt.Sprintf("%3d: %s", lineNum, typ)
if expectedFields != nil && len(actualFields) > 0 {
b, _ := json.Marshal(actualFields)
out += " " + string(b)
func logHistoryEvents(t *testing.T, actualEvents eventList) {
t.Helper()
for _, actualEvent := range actualEvents {
out := actualEvent.Type
if len(actualEvent.Attributes) > 0 {
b, _ := json.Marshal(actualEvent.Attributes)
out += " " + string(b)
}
t.Logf(" %s\n", out)
}
return out + "\n"
}
Loading
Loading