Skip to content

Commit 7b8b190

Browse files
committed
add line length truncate buffer
1 parent a4670b0 commit 7b8b190

File tree

2 files changed

+164
-1
lines changed

2 files changed

+164
-1
lines changed

pkg/buffer/buffer.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ package buffer
33
import (
44
"bufio"
55
"fmt"
6+
"io"
67
"net/http"
78
"strings"
89
)
910

11+
// maxLineSize is the maximum size for a single log line (10MB).
12+
// GitHub Actions logs can contain extremely long lines (base64 content, minified JS, etc.)
13+
const maxLineSize = 10 * 1024 * 1024
14+
1015
// ProcessResponseAsRingBufferToEnd reads the body of an HTTP response line by line,
1116
// storing only the last maxJobLogLines lines using a ring buffer (sliding window).
1217
// This efficiently retains the most recent lines, overwriting older ones as needed.
@@ -25,6 +30,7 @@ import (
2530
//
2631
// The function uses a ring buffer to efficiently store only the last maxJobLogLines lines.
2732
// If the response contains more lines than maxJobLogLines, only the most recent lines are kept.
33+
// Lines exceeding maxLineSize are truncated with a marker.
2834
func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) {
2935
if maxJobLogLines > 100000 {
3036
maxJobLogLines = 100000
@@ -36,7 +42,8 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in
3642
writeIndex := 0
3743

3844
scanner := bufio.NewScanner(httpResp.Body)
39-
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
45+
// Set initial buffer to 64KB and max token size to 10MB to handle very long lines
46+
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
4047

4148
for scanner.Scan() {
4249
line := scanner.Text()
@@ -48,6 +55,11 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in
4855
}
4956

5057
if err := scanner.Err(); err != nil {
58+
// If we hit a token too long error, fall back to byte-by-byte reading
59+
// with line truncation to handle extremely long lines gracefully
60+
if err == bufio.ErrTooLong {
61+
return processWithLongLineHandling(httpResp.Body, lines, validLines, totalLines, writeIndex, maxJobLogLines)
62+
}
5163
return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
5264
}
5365

@@ -71,3 +83,75 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in
7183

7284
return strings.Join(result, "\n"), totalLines, httpResp, nil
7385
}
86+
87+
// processWithLongLineHandling continues processing after encountering a line
88+
// that exceeds the scanner's max token size. It reads byte-by-byte and
89+
// truncates extremely long lines instead of failing.
90+
func processWithLongLineHandling(body io.Reader, lines []string, validLines []bool, totalLines, writeIndex, maxJobLogLines int) (string, int, *http.Response, error) {
91+
// Add a marker that we encountered truncated content
92+
truncatedMarker := "[LINE TRUNCATED - exceeded maximum line length of 10MB]"
93+
lines[writeIndex] = truncatedMarker
94+
validLines[writeIndex] = true
95+
totalLines++
96+
writeIndex = (writeIndex + 1) % maxJobLogLines
97+
98+
// Continue reading with a buffered reader, truncating long lines
99+
reader := bufio.NewReader(body)
100+
var currentLine strings.Builder
101+
const maxDisplayLength = 1000 // Keep first 1000 chars of truncated lines
102+
103+
for {
104+
b, err := reader.ReadByte()
105+
if err == io.EOF {
106+
// Handle final line without newline
107+
if currentLine.Len() > 0 {
108+
line := currentLine.String()
109+
if len(line) > maxLineSize {
110+
line = line[:maxDisplayLength] + "... [TRUNCATED]"
111+
}
112+
lines[writeIndex] = line
113+
validLines[writeIndex] = true
114+
totalLines++
115+
}
116+
break
117+
}
118+
if err != nil {
119+
return "", 0, nil, fmt.Errorf("failed to read log content: %w", err)
120+
}
121+
122+
if b == '\n' {
123+
line := currentLine.String()
124+
if len(line) > maxLineSize {
125+
line = line[:maxDisplayLength] + "... [TRUNCATED]"
126+
}
127+
lines[writeIndex] = line
128+
validLines[writeIndex] = true
129+
totalLines++
130+
writeIndex = (writeIndex + 1) % maxJobLogLines
131+
currentLine.Reset()
132+
} else if currentLine.Len() < maxLineSize+maxDisplayLength {
133+
// Stop accumulating bytes once we exceed the limit (plus buffer for truncation message)
134+
currentLine.WriteByte(b)
135+
}
136+
}
137+
138+
var result []string
139+
linesInBuffer := totalLines
140+
if linesInBuffer > maxJobLogLines {
141+
linesInBuffer = maxJobLogLines
142+
}
143+
144+
startIndex := 0
145+
if totalLines > maxJobLogLines {
146+
startIndex = writeIndex
147+
}
148+
149+
for i := 0; i < linesInBuffer; i++ {
150+
idx := (startIndex + i) % maxJobLogLines
151+
if validLines[idx] {
152+
result = append(result, lines[idx])
153+
}
154+
}
155+
156+
return strings.Join(result, "\n"), totalLines, nil, nil
157+
}

pkg/buffer/buffer_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package buffer
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestProcessResponseAsRingBufferToEnd(t *testing.T) {
14+
t.Run("normal lines", func(t *testing.T) {
15+
body := "line1\nline2\nline3\n"
16+
resp := &http.Response{
17+
Body: io.NopCloser(strings.NewReader(body)),
18+
}
19+
20+
result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10)
21+
if respOut != nil && respOut.Body != nil {
22+
defer respOut.Body.Close()
23+
}
24+
require.NoError(t, err)
25+
assert.Equal(t, 3, totalLines)
26+
assert.Equal(t, "line1\nline2\nline3", result)
27+
})
28+
29+
t.Run("ring buffer keeps last N lines", func(t *testing.T) {
30+
body := "line1\nline2\nline3\nline4\nline5\n"
31+
resp := &http.Response{
32+
Body: io.NopCloser(strings.NewReader(body)),
33+
}
34+
35+
result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 3)
36+
if respOut != nil && respOut.Body != nil {
37+
defer respOut.Body.Close()
38+
}
39+
require.NoError(t, err)
40+
assert.Equal(t, 5, totalLines)
41+
assert.Equal(t, "line3\nline4\nline5", result)
42+
})
43+
44+
t.Run("handles very long line exceeding 10MB", func(t *testing.T) {
45+
// Create a line that exceeds maxLineSize (10MB)
46+
longLine := strings.Repeat("x", 11*1024*1024) // 11MB
47+
body := "line1\n" + longLine + "\nline3\n"
48+
resp := &http.Response{
49+
Body: io.NopCloser(strings.NewReader(body)),
50+
}
51+
52+
result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100)
53+
if respOut != nil && respOut.Body != nil {
54+
defer respOut.Body.Close()
55+
}
56+
require.NoError(t, err)
57+
// Should have processed lines with truncation marker
58+
assert.Greater(t, totalLines, 0)
59+
assert.Contains(t, result, "TRUNCATED")
60+
})
61+
62+
t.Run("handles line at exactly max size", func(t *testing.T) {
63+
// Create a line just under maxLineSize
64+
longLine := strings.Repeat("a", 1024*1024) // 1MB - should work fine
65+
body := "start\n" + longLine + "\nend\n"
66+
resp := &http.Response{
67+
Body: io.NopCloser(strings.NewReader(body)),
68+
}
69+
70+
result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100)
71+
if respOut != nil && respOut.Body != nil {
72+
defer respOut.Body.Close()
73+
}
74+
require.NoError(t, err)
75+
assert.Equal(t, 3, totalLines)
76+
assert.Contains(t, result, "start")
77+
assert.Contains(t, result, "end")
78+
})
79+
}

0 commit comments

Comments
 (0)