Skip to content

Commit c6327b1

Browse files
author
Tim Riddell
committed
aws_s3_stream: add opt-in whole-object streaming gzip (compression: gzip)
1 parent a612b65 commit c6327b1

5 files changed

Lines changed: 350 additions & 13 deletions

File tree

internal/impl/aws/output_s3_stream.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
ssoFieldBatching = "batching"
2727
ssoFieldContentType = "content_type"
2828
ssoFieldContentEncoding = "content_encoding"
29+
ssoFieldCompression = "compression"
2930
ssoFieldBackoff = "backoff"
3031
ssoFieldMaxRetries = "max_retries"
3132
)
@@ -94,9 +95,13 @@ You can find out more [in this document](/docs/guides/cloud/aws).
9495
Default("application/octet-stream").
9596
Advanced(),
9697
service.NewInterpolatedStringField(ssoFieldContentEncoding).
97-
Description("The content encoding to set for uploaded files (e.g., gzip).").
98+
Description("The content encoding to set for uploaded files (e.g., gzip). This only sets the object's `Content-Encoding` metadata and does not itself compress anything; use `compression` for that. Leave unset when using `compression`, which sets it automatically.").
9899
Optional().
99100
Advanced(),
101+
service.NewStringEnumField(ssoFieldCompression, "none", "gzip").
102+
Description("Compress the object in-stream as it is uploaded. When set to `gzip`, a single gzip stream is written across the whole object (including true multipart uploads that exceed the 5 MiB part size) and the object's `Content-Encoding` is set to `gzip` automatically. This differs from a per-message `compress` processor, which would produce a multi-member gzip file; and from batch-level `archive`+`compress`, which would merge records across partitions. When enabled, do not also set `content_encoding` or a `compress` batch processor.").
103+
Default("none").
104+
Advanced(),
100105
service.NewIntField(ssoFieldMaxRetries).
101106
Description("The maximum number of retries for each individual part upload. Set to zero to disable retries.").
102107
Advanced().Default(2),
@@ -159,6 +164,7 @@ type s3StreamConfig struct {
159164
MaxBufferPeriod time.Duration
160165
ContentType *service.InterpolatedString
161166
ContentEncoding *service.InterpolatedString
167+
Compression string
162168

163169
aconf aws.Config
164170
backoffCtor func() backoff.BackOff
@@ -210,6 +216,11 @@ func s3StreamConfigFromParsed(pConf *service.ParsedConfig) (conf s3StreamConfig,
210216
}
211217
}
212218

219+
// In-writer compression (defaults to "none").
220+
if conf.Compression, err = pConf.FieldString(ssoFieldCompression); err != nil {
221+
return
222+
}
223+
213224
// AWS config
214225
if conf.aconf, err = GetSession(context.TODO(), pConf); err != nil {
215226
return
@@ -388,6 +399,7 @@ func (s *s3StreamOutput) writeToPartition(ctx context.Context, partitionKey stri
388399
MaxBufferPeriod: s.conf.MaxBufferPeriod,
389400
ContentType: contentType,
390401
ContentEncoding: contentEncoding,
402+
Compression: s.conf.Compression,
391403
BackoffCtor: s.conf.backoffCtor,
392404
})
393405
if err != nil {

internal/impl/aws/output_s3_stream_integration_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package aws
22

33
import (
4+
"bytes"
5+
"compress/gzip"
46
"context"
57
"fmt"
68
"io"
@@ -364,3 +366,96 @@ max_buffer_period: 1s
364366

365367
assert.Equal(t, "single message\n", string(content))
366368
}
369+
370+
// TestS3StreamOutput_IntegrationGzipCompression tests the compression: gzip
371+
// option end-to-end: the object must download as a single valid gzip stream
372+
// (Content-Encoding gzip) that decompresses to the exact concatenation of the
373+
// records — for both a small PutObject-path object and a large multipart one.
374+
func TestS3StreamOutput_IntegrationGzipCompression(t *testing.T) {
375+
integration.CheckSkip(t)
376+
377+
servicePort := GetLocalStack(t, nil)
378+
bucketName := "test-stream-gzip"
379+
380+
s3Client := getTestS3Client(context.Background(), t, servicePort)
381+
_, err := s3Client.CreateBucket(context.Background(), &s3.CreateBucketInput{
382+
Bucket: aws.String(bucketName),
383+
})
384+
require.NoError(t, err)
385+
386+
cases := []struct {
387+
name string
388+
key string
389+
records int
390+
// bufBytes seals parts at this compressed size; small value + many
391+
// records forces a true multipart upload.
392+
bufBytes int
393+
}{
394+
{name: "small_putobject", key: "gz/small.jsonl.gz", records: 20, bufBytes: 10 * 1024 * 1024},
395+
{name: "large_multipart", key: "gz/large.jsonl.gz", records: 400000, bufBytes: 5 * 1024 * 1024},
396+
}
397+
398+
for _, tc := range cases {
399+
t.Run(tc.name, func(t *testing.T) {
400+
configYAML := fmt.Sprintf(`
401+
bucket: %s
402+
path: '%s'
403+
region: us-east-1
404+
force_path_style_urls: true
405+
endpoint: http://localhost:%s
406+
content_type: 'application/json'
407+
compression: gzip
408+
max_buffer_bytes: %d
409+
max_buffer_count: 1000000
410+
max_buffer_period: 1s
411+
`, bucketName, tc.key, servicePort, tc.bufBytes)
412+
413+
parsedConf, err := s3StreamOutputSpec().ParseYAML(configYAML, nil)
414+
require.NoError(t, err)
415+
416+
wConf, err := s3StreamConfigFromParsed(parsedConf)
417+
require.NoError(t, err)
418+
require.Equal(t, "gzip", wConf.Compression)
419+
420+
output, err := newS3StreamOutput(wConf, service.MockResources())
421+
require.NoError(t, err)
422+
423+
ctx := context.Background()
424+
require.NoError(t, output.Connect(ctx))
425+
426+
var expected bytes.Buffer
427+
batch := service.MessageBatch{}
428+
for i := range tc.records {
429+
line := fmt.Appendf(nil, `{"id":%d,"msg":"gzip stream test record"}%s`, i, "\n")
430+
expected.Write(line)
431+
batch = append(batch, service.NewMessage(line))
432+
}
433+
434+
require.NoError(t, output.WriteBatch(ctx, batch))
435+
require.NoError(t, output.Close(ctx))
436+
437+
getResp, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
438+
Bucket: aws.String(bucketName),
439+
Key: aws.String(tc.key),
440+
})
441+
require.NoError(t, err)
442+
defer getResp.Body.Close()
443+
444+
assert.Equal(t, "gzip", aws.ToString(getResp.ContentEncoding), "Content-Encoding should be gzip")
445+
446+
raw, err := io.ReadAll(getResp.Body)
447+
require.NoError(t, err)
448+
449+
// The stored object must be ONE valid gzip stream (not multi-member
450+
// per-record) that decompresses to the exact input.
451+
zr, err := gzip.NewReader(bytes.NewReader(raw))
452+
require.NoError(t, err, "object is not a valid gzip stream")
453+
got, err := io.ReadAll(zr)
454+
require.NoError(t, err)
455+
require.NoError(t, zr.Close())
456+
457+
assert.Equal(t, expected.Bytes(), got)
458+
assert.Less(t, len(raw), expected.Len(), "gzip object should be smaller than the raw input")
459+
})
460+
}
461+
}

internal/impl/aws/output_s3_stream_writer.go

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package aws
22

33
import (
44
"bytes"
5+
"compress/gzip"
56
"context"
67
"errors"
78
"fmt"
@@ -45,10 +46,17 @@ type S3StreamingWriter struct {
4546
completedParts []types.CompletedPart
4647
backoffCtor func() backoff.BackOff
4748

48-
// Buffering
49-
messageBuffer [][]byte
49+
// Buffering.
50+
//
51+
// uploadBuffer holds the bytes pending upload as the next S3 part. When
52+
// compression is enabled, gzipWriter streams compressed bytes into
53+
// uploadBuffer, so uploadSize (and therefore all part-size decisions) is
54+
// measured in COMPRESSED bytes; the single gzip stream is simply split
55+
// across parts at arbitrary byte boundaries and reassembled by S3.
56+
gzipWriter *gzip.Writer
5057
uploadBuffer *bytes.Buffer
5158
uploadSize int64
59+
bufferedCount int
5260

5361
// Statistics tracking
5462
totalMessages int64
@@ -75,6 +83,7 @@ type S3StreamingWriterConfig struct {
7583
MaxBufferPeriod time.Duration // Maximum time to buffer before flushing (default: 10s)
7684
ContentType string // Content type for S3 object
7785
ContentEncoding string // Content encoding for S3 object (optional)
86+
Compression string // In-writer compression over the whole object: "" / "none" / "gzip"
7887
BackoffCtor func() backoff.BackOff
7988
}
8089

@@ -118,12 +127,20 @@ func NewS3StreamingWriter(config S3StreamingWriterConfig) (*S3StreamingWriter, e
118127
key: config.Key,
119128
backoffCtor: config.BackoffCtor,
120129
uploadBuffer: bytes.NewBuffer(nil),
121-
messageBuffer: make([][]byte, 0, config.MaxBufferCount),
122130
created: time.Now(),
123131
lastWrite: time.Now(),
124132
lastFlush: time.Now(),
125133
}
126134

135+
// When gzip compression is enabled the writer owns the compression: a single
136+
// gzip stream is written across all parts and the object is tagged with the
137+
// gzip Content-Encoding. This is the only way to get whole-object (not
138+
// per-record) gzip that can still exceed the 5 MiB multipart threshold.
139+
if config.Compression == "gzip" {
140+
w.gzipWriter = gzip.NewWriter(w.uploadBuffer)
141+
w.contentEncoding = "gzip"
142+
}
143+
127144
return w, nil
128145
}
129146

@@ -177,16 +194,24 @@ func (w *S3StreamingWriter) WriteBytes(ctx context.Context, data []byte) error {
177194
return errors.New("writer not initialized")
178195
}
179196

180-
// Add to message buffer
181-
w.messageBuffer = append(w.messageBuffer, data)
182-
w.uploadBuffer.Write(data)
183-
w.uploadSize += int64(len(data))
197+
if w.gzipWriter != nil {
198+
// Stream the record through gzip into uploadBuffer. Part-size decisions
199+
// are made on the compressed bytes accumulated so far.
200+
if _, err := w.gzipWriter.Write(data); err != nil {
201+
return fmt.Errorf("failed to write to gzip stream: %w", err)
202+
}
203+
w.uploadSize = int64(w.uploadBuffer.Len())
204+
} else {
205+
w.uploadBuffer.Write(data)
206+
w.uploadSize += int64(len(data))
207+
}
208+
w.bufferedCount++
184209
w.totalMessages++
185210
w.totalBytes += int64(len(data))
186211
w.lastWrite = time.Now()
187212

188213
// Check if we should flush
189-
if w.uploadSize >= w.maxBufferBytes || len(w.messageBuffer) >= w.maxBufferCount {
214+
if w.uploadSize >= w.maxBufferBytes || w.bufferedCount >= w.maxBufferCount {
190215
return w.flush(ctx)
191216
}
192217

@@ -228,10 +253,11 @@ retryLoop:
228253
PartNumber: aws.Int32(w.partNumber),
229254
})
230255

231-
// Clear buffers
256+
// Clear buffers (gzipWriter keeps streaming into the now-empty
257+
// uploadBuffer; the uploaded part is a prefix of the gzip stream).
232258
w.uploadBuffer.Reset()
233259
w.uploadSize = 0
234-
w.messageBuffer = w.messageBuffer[:0]
260+
w.bufferedCount = 0
235261
w.lastFlush = time.Now()
236262

237263
// Reset flush timer
@@ -323,7 +349,7 @@ func (w *S3StreamingWriter) forceFlush(ctx context.Context) error {
323349
// Clear buffers
324350
w.uploadBuffer.Reset()
325351
w.uploadSize = 0
326-
w.messageBuffer = w.messageBuffer[:0]
352+
w.bufferedCount = 0
327353
w.lastFlush = time.Now()
328354

329355
return nil
@@ -370,6 +396,18 @@ func (w *S3StreamingWriter) Close(ctx context.Context) error {
370396
w.flushTimer.Stop()
371397
}
372398

399+
// Finalize the gzip stream: this flushes any bytes still held inside the
400+
// compressor and writes the gzip footer into uploadBuffer, after which
401+
// uploadSize reflects the final compressed tail. Must happen before the
402+
// size checks below so small/empty objects are sized correctly.
403+
if w.gzipWriter != nil {
404+
if err := w.gzipWriter.Close(); err != nil {
405+
w.abortMultipartUpload(ctx)
406+
return fmt.Errorf("failed to finalize gzip stream: %w", err)
407+
}
408+
w.uploadSize = int64(w.uploadBuffer.Len())
409+
}
410+
373411
// A small or empty object (no parts yet and < 5 MiB buffered) cannot be a
374412
// standalone multipart part, so finalize it with a single PutObject.
375413
if len(w.completedParts) == 0 && w.uploadSize < s3MinMultipartPartSize {

0 commit comments

Comments
 (0)