Skip to content

Commit f046124

Browse files
authored
fix: remove Glue RCA Check 2 causing 100% false-positive failures (#60)
The verifyGlueRCA Check 2 filtered /aws-glue/jobs/error for error indicators (?Exception ?Error ?FATAL ...), but every Glue job's stderr contains benign JVM startup output with "Error" in classpath entries (e.g., -XX:OnOutOfMemoryError) and Glue's internal AnalyzerLogHelper messages. This caused every SUCCEEDED job to be reclassified as FAILED. Check 1 (GlueExceptionAnalysisJobFailed in the RCA log stream) is Glue's purpose-built mechanism for detecting false successes and is sufficient. Post-run validation provides the application-level safety net for data quality issues.
1 parent 53383e1 commit f046124

3 files changed

Lines changed: 25 additions & 112 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.1] - 2026-03-08
9+
10+
### Fixed
11+
12+
- **Glue RCA false-positive failure classification (Check 2 removed)**: The `verifyGlueRCA` Check 2 filter pattern (`?Exception ?Error ?FATAL ...`) matched benign JVM startup output in Glue's stderr (`/aws-glue/jobs/error`), causing every SUCCEEDED Glue job to be reclassified as FAILED. Classpath entries like `-XX:OnOutOfMemoryError` and Glue's internal `AnalyzerLogHelper` messages contain "Error" as substrings, producing a 100% false positive rate. Removed Check 2 entirely — Check 1 (GlueExceptionAnalysisJobFailed in the RCA log stream) is Glue's purpose-built mechanism for detecting false successes and is sufficient. Post-run validation provides the application-level safety net for data quality issues.
13+
814
## [0.7.0] - 2026-03-08
915

1016
### Added

internal/trigger/glue.go

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,6 @@ type CloudWatchLogsAPI interface {
2727
// defaultGlueLogGroup is the standard CloudWatch log group for Glue v2 jobs.
2828
const defaultGlueLogGroup = "/aws-glue/jobs/logs-v2"
2929

30-
// defaultGlueErrorLogGroup is the CloudWatch log group for Glue job errors.
31-
const defaultGlueErrorLogGroup = "/aws-glue/jobs/error"
32-
3330
// ExecuteGlue starts an AWS Glue job run.
3431
func ExecuteGlue(ctx context.Context, cfg *types.GlueTriggerConfig, client GlueAPI) (map[string]interface{}, error) {
3532
if cfg.JobName == "" {
@@ -105,22 +102,19 @@ func (r *Runner) checkGlueStatus(ctx context.Context, metadata map[string]interf
105102
}
106103
}
107104

108-
// verifyGlueRCA checks CloudWatch logs for a Glue job run to detect false
109-
// successes. It performs two checks:
110-
// 1. RCA log stream for GlueExceptionAnalysisJobFailed events (Spark exceptions
111-
// where the driver exits 0).
112-
// 2. Error log group (/aws-glue/jobs/error) for entries matching error indicators
113-
// (catches failures like disk-full errors that Glue's RCA may not detect).
105+
// verifyGlueRCA checks the RCA (root cause analysis) log stream for a Glue
106+
// job run to detect false successes. Glue can report SUCCEEDED when the Spark
107+
// job actually failed (driver exits 0 despite SparkException). The RCA stream
108+
// contains GlueExceptionAnalysisJobFailed events for these cases.
114109
//
115-
// Returns (true, reason) if either check finds failure evidence. Returns
116-
// (false, "") on any error or if no failure is found.
110+
// Returns (true, reason) if failure evidence is found. Returns (false, "") on
111+
// any error or if no failure is found.
117112
func (r *Runner) verifyGlueRCA(ctx context.Context, runID string, logGroupName *string) (failed bool, reason string) {
118113
client, err := r.getCWLogsClient(ctx, "")
119114
if err != nil {
120115
return false, ""
121116
}
122117

123-
// Check 1: RCA log stream for GlueExceptionAnalysisJobFailed.
124118
logGroup := defaultGlueLogGroup
125119
if logGroupName != nil && *logGroupName != "" {
126120
logGroup = *logGroupName
@@ -141,22 +135,6 @@ func (r *Runner) verifyGlueRCA(ctx context.Context, runID string, logGroupName *
141135
return true, "RCA: JobFailed"
142136
}
143137

144-
// Check 2: Error log group for entries matching error indicators for this run.
145-
errorLogGroup := defaultGlueErrorLogGroup
146-
errOut, err := client.FilterLogEvents(ctx, &cloudwatchlogs.FilterLogEventsInput{
147-
LogGroupName: &errorLogGroup,
148-
LogStreamNames: []string{runID},
149-
FilterPattern: aws.String(`?Exception ?Error ?FATAL ?Traceback ?OutOfMemoryError ?StackOverflowError`),
150-
Limit: aws.Int32(1),
151-
})
152-
if err == nil && len(errOut.Events) > 0 {
153-
msg := aws.ToString(errOut.Events[0].Message)
154-
if len(msg) > 200 {
155-
msg = msg[:200]
156-
}
157-
return true, "error-log: " + msg
158-
}
159-
160138
return false, ""
161139
}
162140

internal/trigger/glue_test.go

Lines changed: 13 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ func TestCheckGlueStatus_RCAUsesJobRunLogGroup(t *testing.T) {
238238
cwClient := &mockCWLogsClient{
239239
filterOut: &cloudwatchlogs.FilterLogEventsOutput{Events: []cwltypes.FilteredLogEvent{}},
240240
}
241-
// Wrap to capture the log group parameter for each call
242241
captureClient := &capturingCWLogsClient{
243242
delegate: cwClient,
244243
onFilter: func(input *cloudwatchlogs.FilterLogEventsInput) {
@@ -252,9 +251,8 @@ func TestCheckGlueStatus_RCAUsesJobRunLogGroup(t *testing.T) {
252251
"glue_job_run_id": "jr_abc123",
253252
})
254253
require.NoError(t, err)
255-
require.Len(t, capturedLogGroups, 2)
254+
require.Len(t, capturedLogGroups, 1)
256255
assert.Equal(t, customLogGroup, capturedLogGroups[0], "RCA check should use custom log group")
257-
assert.Equal(t, "/aws-glue/jobs/error", capturedLogGroups[1], "error log check should use standard error group")
258256
}
259257

260258
type capturingCWLogsClient struct {
@@ -269,76 +267,11 @@ func (c *capturingCWLogsClient) FilterLogEvents(ctx context.Context, params *clo
269267
return c.delegate.FilterLogEvents(ctx, params, optFns...)
270268
}
271269

272-
// funcCWLogsClient allows routing FilterLogEvents responses based on input.
273-
type funcCWLogsClient struct {
274-
filterFn func(context.Context, *cloudwatchlogs.FilterLogEventsInput) (*cloudwatchlogs.FilterLogEventsOutput, error)
275-
}
276-
277-
func (f *funcCWLogsClient) FilterLogEvents(ctx context.Context, params *cloudwatchlogs.FilterLogEventsInput, _ ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.FilterLogEventsOutput, error) {
278-
return f.filterFn(ctx, params)
279-
}
280-
281-
func TestCheckGlueStatus_ErrorLogsDetectFalseSuccess(t *testing.T) {
282-
glueClient := &mockGlueClient{
283-
getOut: &glue.GetJobRunOutput{
284-
JobRun: &gluetypes.JobRun{
285-
JobRunState: gluetypes.JobRunStateSucceeded,
286-
},
287-
},
288-
}
289-
cwClient := &funcCWLogsClient{
290-
filterFn: func(_ context.Context, params *cloudwatchlogs.FilterLogEventsInput) (*cloudwatchlogs.FilterLogEventsOutput, error) {
291-
if aws.ToString(params.LogGroupName) == "/aws-glue/jobs/error" {
292-
return &cloudwatchlogs.FilterLogEventsOutput{
293-
Events: []cwltypes.FilteredLogEvent{
294-
{Message: aws.String("java.io.IOException: No space left on device")},
295-
},
296-
}, nil
297-
}
298-
// RCA check — return empty (no GlueExceptionAnalysisJobFailed)
299-
return &cloudwatchlogs.FilterLogEventsOutput{Events: []cwltypes.FilteredLogEvent{}}, nil
300-
},
301-
}
302-
303-
r := NewRunner(WithGlueClient(glueClient), WithCloudWatchLogsClient(cwClient))
304-
result, err := r.checkGlueStatus(context.Background(), map[string]interface{}{
305-
"glue_job_name": "my-job",
306-
"glue_job_run_id": "jr_abc123",
307-
})
308-
require.NoError(t, err)
309-
assert.Equal(t, RunCheckFailed, result.State)
310-
assert.Contains(t, result.Message, "error-log")
311-
assert.Contains(t, result.Message, "No space left on device")
312-
assert.Equal(t, types.FailureTransient, result.FailureCategory)
313-
}
314-
315-
func TestCheckGlueStatus_BenignStderrDoesNotCauseFailure(t *testing.T) {
316-
glueClient := &mockGlueClient{
317-
getOut: &glue.GetJobRunOutput{
318-
JobRun: &gluetypes.JobRun{
319-
JobRunState: gluetypes.JobRunStateSucceeded,
320-
},
321-
},
322-
}
323-
// Simulate benign stderr: FilterPattern excludes "Preparing ..." so
324-
// the error log group returns empty events despite the log existing.
325-
cwClient := &funcCWLogsClient{
326-
filterFn: func(_ context.Context, params *cloudwatchlogs.FilterLogEventsInput) (*cloudwatchlogs.FilterLogEventsOutput, error) {
327-
return &cloudwatchlogs.FilterLogEventsOutput{Events: []cwltypes.FilteredLogEvent{}}, nil
328-
},
329-
}
330-
331-
r := NewRunner(WithGlueClient(glueClient), WithCloudWatchLogsClient(cwClient))
332-
result, err := r.checkGlueStatus(context.Background(), map[string]interface{}{
333-
"glue_job_name": "my-job",
334-
"glue_job_run_id": "jr_abc123",
335-
})
336-
require.NoError(t, err)
337-
assert.Equal(t, RunCheckSucceeded, result.State)
338-
assert.Equal(t, "SUCCEEDED", result.Message)
339-
}
340-
341-
func TestCheckGlueStatus_ErrorLogFilterPatternPassed(t *testing.T) {
270+
func TestCheckGlueStatus_RCAOnlyCheck(t *testing.T) {
271+
// Verify that only the RCA check is performed (no error log group check).
272+
// The error log group check was removed because Glue's stderr always
273+
// contains benign JVM startup output with "Error" in class names,
274+
// causing 100% false positive rate.
342275
glueClient := &mockGlueClient{
343276
getOut: &glue.GetJobRunOutput{
344277
JobRun: &gluetypes.JobRun{
@@ -358,21 +291,17 @@ func TestCheckGlueStatus_ErrorLogFilterPatternPassed(t *testing.T) {
358291
}
359292

360293
r := NewRunner(WithGlueClient(glueClient), WithCloudWatchLogsClient(cwClient))
361-
_, err := r.checkGlueStatus(context.Background(), map[string]interface{}{
294+
result, err := r.checkGlueStatus(context.Background(), map[string]interface{}{
362295
"glue_job_name": "my-job",
363296
"glue_job_run_id": "jr_abc123",
364297
})
365298
require.NoError(t, err)
366-
require.Len(t, capturedInputs, 2)
367-
368-
// Check 2 (error log group) must have a FilterPattern
369-
errorLogInput := capturedInputs[1]
370-
assert.Equal(t, "/aws-glue/jobs/error", aws.ToString(errorLogInput.LogGroupName))
371-
require.NotNil(t, errorLogInput.FilterPattern, "Check 2 must pass FilterPattern")
372-
fp := aws.ToString(errorLogInput.FilterPattern)
373-
for _, term := range []string{"Exception", "Error", "FATAL", "Traceback"} {
374-
assert.Contains(t, fp, term, "FilterPattern should include %s", term)
375-
}
299+
assert.Equal(t, RunCheckSucceeded, result.State)
300+
301+
// Only one FilterLogEvents call (RCA check), no error log group check.
302+
require.Len(t, capturedInputs, 1)
303+
assert.Equal(t, "/aws-glue/jobs/logs-v2", aws.ToString(capturedInputs[0].LogGroupName))
304+
assert.Contains(t, aws.ToString(capturedInputs[0].FilterPattern), "GlueExceptionAnalysisJobFailed")
376305
}
377306

378307
func TestExtractGlueFailureReason(t *testing.T) {

0 commit comments

Comments
 (0)