Skip to content

Commit 4bff86e

Browse files
authored
Merge pull request #323 from thand-io/feat/temporal-replay-workflowcheck-guardrails
test: add Temporal replay tests and workflowcheck static analysis
2 parents 332f8cb + d022eb7 commit 4bff86e

10 files changed

Lines changed: 286 additions & 12 deletions

File tree

.github/workflows/test-and-build.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,29 @@ jobs:
5252
# Run all tests except functional and integration tests
5353
go test -v -short $(go list ./... | grep -v '/test/functional' | grep -v '/test/integration')
5454
55+
workflowcheck:
56+
runs-on: ubuntu-latest
57+
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
58+
59+
steps:
60+
- name: Checkout code
61+
uses: actions/checkout@v4
62+
with:
63+
fetch-depth: 1
64+
submodules: recursive
65+
66+
- name: Set up Go
67+
uses: actions/setup-go@v5
68+
with:
69+
go-version: ${{ env.GO_VERSION }}
70+
cache: true
71+
72+
- name: Install workflowcheck
73+
run: go install go.temporal.io/sdk/contrib/tools/workflowcheck@v0.4.0
74+
75+
- name: Run workflowcheck
76+
run: workflowcheck -test=false -config workflowcheck.yaml ./internal/... ./sdk/...
77+
5578
functional-tests:
5679
runs-on: ubuntu-latest
5780
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')

Makefile

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ generate-data:
8383
go run tools/generate-iam-dataset/main.go
8484
@echo "Data generation complete!"
8585

86+
# Run workflowcheck static analysis to detect non-deterministic workflow code.
87+
# workflowcheck inspects workflow functions for forbidden calls (time.Now,
88+
# goroutines, select, etc.) that cause Temporal replay failures.
89+
# Install once with: go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest
90+
workflowcheck:
91+
@if command -v workflowcheck >/dev/null 2>&1; then \
92+
echo "Running workflowcheck..."; \
93+
workflowcheck -test=false -config workflowcheck.yaml ./internal/... ./sdk/...; \
94+
else \
95+
echo "workflowcheck not found. Install with:"; \
96+
echo " go install go.temporal.io/sdk/contrib/tools/workflowcheck@latest"; \
97+
exit 1; \
98+
fi
99+
86100
# Generate Swagger documentation
87101
swagger:
88102
@echo "Generating Swagger documentation..."
@@ -96,4 +110,4 @@ swagger:
96110
exit 1; \
97111
fi
98112

99-
.PHONY: all build build-all build-linux-amd64 clean install run test test-functional test-integration test-e2e submodules update-submodules compress generate-data swagger
113+
.PHONY: all build build-all build-linux-amd64 clean install run test test-functional test-integration test-e2e submodules update-submodules compress generate-data swagger workflowcheck

internal/models/provider_sync.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -249,21 +249,14 @@ func paginatedSync[Req SynchronizeRequestImpl, Resp SynchronizeResponseImpl](
249249
onPage func(Resp),
250250
) error {
251251
for {
252-
logrus.Debugf("Making synchronization request: %s", name)
253-
254252
resp, err := executePage(req)
255253
if err != nil {
256254
if errors.Is(err, ErrNotImplemented) {
257-
logrus.Debugf("Synchronization operation %s is not implemented, skipping", name)
258255
return nil
259256
}
260257
return err
261258
}
262259

263-
logrus.WithFields(logrus.Fields{
264-
"response": provider,
265-
}).Debugf("Received synchronization response for %s", name)
266-
267260
resp.AddToProvider(provider)
268261

269262
if onPage != nil {
@@ -272,7 +265,6 @@ func paginatedSync[Req SynchronizeRequestImpl, Resp SynchronizeResponseImpl](
272265

273266
pagination := resp.GetPagination()
274267
if pagination == nil || len(pagination.Token) == 0 {
275-
logrus.Debugf("Synchronization operation %s completed with no more pages", name)
276268
break
277269
}
278270

internal/providers/azure/rbac.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,10 @@ func (p *azureProvider) revokeRoleTemporal(
375375
},
376376
).Get(wfCtx, nil); err != nil {
377377
// Non-fatal: the assignment was already removed. Log but don't fail the workflow.
378-
logrus.WithError(err).WithField("role_definition_id", roleDefResp.RoleDefinitionID).
379-
Warn("DeleteRoleDefinition activity failed for composite role")
378+
workflow.GetLogger(wfCtx).Warn("DeleteRoleDefinition activity failed for composite role",
379+
"error", err,
380+
"role_definition_id", roleDefResp.RoleDefinitionID,
381+
)
380382
}
381383
}
382384

internal/workflows/manager/workflows.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ func (m *ThandWorkflowManager) runCleanup(
184184
}
185185

186186
// Check if a user or role is associated with the workflow
187+
//workflowcheck:ignore — GetContextAsElevationRequest uses encoding/json for struct
188+
// coercion of the workflow context; the output is deterministic for a given input.
187189
elevationRequest, err := workflowTask.GetContextAsElevationRequest()
188190
if err != nil || !elevationRequest.IsValid() {
189191
log.Info("No valid elevation context found, skipping cleanup activity.")
@@ -202,6 +204,8 @@ func (m *ThandWorkflowManager) runCleanup(
202204
// Resume the workflow task with the specified entrypoint
203205
workflowTask.SetEntrypoint(terminationRequest.EntryPoint)
204206

207+
//workflowcheck:ignore — ResumeWorkflowTask runs the serverless workflow runtime;
208+
// its non-determinism is isolated to observability logging only.
205209
result, err := m.ResumeWorkflowTask(
206210
workflowTask,
207211
)
@@ -252,3 +256,13 @@ func (m *ThandWorkflowManager) runCleanup(
252256
log.Info("Cleanup completed successfully")
253257
return nil
254258
}
259+
260+
// ElevationWorkflowHandlerForReplay returns the elevation workflow handler
261+
// registered under models.TemporalExecuteElevationWorkflowName. It is
262+
// exported solely so that replay tests can register the exact same function
263+
// with a worker.WorkflowReplayer to guard against determinism regressions.
264+
//
265+
// Use only in test code. Do not call this from production paths.
266+
func (m *ThandWorkflowManager) ElevationWorkflowHandlerForReplay() func(workflow.Context, *models.ElevateWorkflowTask) (*models.ElevateWorkflowTask, error) {
267+
return m.createElevationWorkflowHandler()
268+
}

sdk/workflows/manager/workflows_serverless.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,11 @@ func (m *serverlessWorkflow) executeWorkflowStep(
156156
return m.handleWorkflowStatus(workflowTask)
157157
}
158158

159-
// handleWorkflowStatus handles different workflow status cases
159+
// handleWorkflowStatus handles different workflow status cases.
160+
//
161+
// Temporal context is set; this is intentional and does not affect replay determinism.
162+
//
163+
//workflowcheck:ignore — GetLogger falls back to logrus for observability when no
160164
func (m *serverlessWorkflow) handleWorkflowStatus(
161165
workflowTask *sdkWorkflowsModel.WorkflowTask,
162166
) (*sdkWorkflowsModel.WorkflowTask, error) {
@@ -245,6 +249,10 @@ func (m *serverlessWorkflow) shouldContinueAsNew(ctx workflow.Context) bool {
245249

246250
// ResumeWorkflowTask resumes a workflow task using the internal runner
247251
// This maybe called as part of a temporal workflow or directly
252+
//
253+
// executes the serverless workflow runtime and logging is observability-only.
254+
//
255+
//workflowcheck:ignore — logrus/runner non-determinism is intentional here; this function
248256
func ResumeWorkflowTask(
249257
cfg config.Config,
250258
workflowTask sdkWorkflowsModel.WorkflowTaskSupport,

sdk/workflows/models/workflow_ctx.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ func (ctx *WorkflowTask) getWorkflowDefAsMap() map[string]any {
181181
return map[string]any{}
182182
}
183183

184+
// SetStatus appends a new status phase entry. The timestamp in StatusPhaseLog is
185+
// observability-only and does not affect Temporal replay correctness.
186+
//
187+
// the status value itself is deterministic and driven by workflow logic.
188+
//
189+
//workflowcheck:ignore — NewStatusPhaseLog calls time.Now for the log timestamp only;
184190
func (ctx *WorkflowTask) SetStatus(status swctx.StatusPhase) {
185191
ctx.mu.Lock()
186192
defer ctx.mu.Unlock()

test/integration/testinfra/infrastructure.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"fmt"
1111
"net"
1212
"net/http"
13+
"os"
14+
"path/filepath"
1315
"regexp"
1416
"strings"
1517
"testing"
@@ -29,10 +31,12 @@ import (
2931
"google.golang.org/protobuf/types/known/durationpb"
3032

3133
"go.temporal.io/api/enums/v1"
34+
historypb "go.temporal.io/api/history/v1"
3235
"go.temporal.io/api/operatorservice/v1"
3336
"go.temporal.io/api/serviceerror"
3437
"go.temporal.io/api/workflowservice/v1"
3538
"go.temporal.io/sdk/client"
39+
"google.golang.org/protobuf/encoding/protojson"
3640
)
3741

3842
const (
@@ -428,6 +432,53 @@ system.forceSearchAttributesCacheRefreshOnRead:
428432
infra.t.Log("Temporal client connected")
429433
}
430434

435+
// SaveWorkflowHistory fetches the complete event history for a completed workflow
436+
// execution and writes it as a protojson file at destPath. The file can later be
437+
// loaded by a WorkflowReplayer replay test to guard against non-determinism.
438+
//
439+
// Typical usage (at the end of an integration test, after the workflow has finished):
440+
//
441+
// infra.SaveWorkflowHistory(t, ctx, workflowID, "", "testdata/my-case/history.json")
442+
func (infra *TestInfrastructure) SaveWorkflowHistory(t *testing.T, ctx context.Context, workflowID, runID, destPath string) {
443+
t.Helper()
444+
445+
iter := infra.TemporalClient.GetWorkflowHistory(ctx, workflowID, runID, false, enums.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
446+
447+
var events []*historypb.HistoryEvent
448+
for iter.HasNext() {
449+
ev, err := iter.Next()
450+
if err != nil {
451+
t.Logf("Warning: error reading history event for %s: %v; skipping history save", workflowID, err)
452+
return
453+
}
454+
events = append(events, ev)
455+
}
456+
457+
if len(events) == 0 {
458+
t.Logf("Warning: no history events found for workflow %s — skipping history save", workflowID)
459+
return
460+
}
461+
462+
history := &historypb.History{Events: events}
463+
jsonBytes, err := protojson.MarshalOptions{Multiline: true}.Marshal(history)
464+
if err != nil {
465+
t.Logf("Warning: failed to marshal history for %s: %v", workflowID, err)
466+
return
467+
}
468+
469+
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
470+
t.Logf("Warning: failed to create history directory %s: %v", filepath.Dir(destPath), err)
471+
return
472+
}
473+
474+
if err := os.WriteFile(destPath, jsonBytes, 0o600); err != nil {
475+
t.Logf("Warning: failed to write history file %s: %v", destPath, err)
476+
return
477+
}
478+
479+
t.Logf("Saved workflow history (%d events) → %s", len(events), destPath)
480+
}
481+
431482
// RegisterCleanup adds a cleanup callback that will be called before container teardown.
432483
// Use this to gracefully shutdown Temporal workers and other resources.
433484
func (infra *TestInfrastructure) RegisterCleanup(cleanup func()) {

0 commit comments

Comments
 (0)