Skip to content

Commit 2e6f01b

Browse files
committed
feat: rolling writer in s3 destination
1 parent f0a6e9e commit 2e6f01b

7 files changed

Lines changed: 568 additions & 46 deletions

File tree

destination/parquet/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import (
44
"github.com/datazip-inc/olake/utils"
55
)
66

7+
// defaultMaxFileSizeMB is the parquet roll threshold used when MaxFileSizeMB is unset.
8+
const (
9+
defaultMaxFileSizeMB = 512
10+
defaultRollCheckInterval = 10000
11+
)
12+
713
type Config struct {
814
Path string `json:"local_path,omitempty"` // Local file path (for local file system usage)
915
Bucket string `json:"s3_bucket,omitempty"`
@@ -13,6 +19,10 @@ type Config struct {
1319
Prefix string `json:"s3_path,omitempty"`
1420
// S3 endpoint for custom S3-compatible services (like MinIO)
1521
S3Endpoint string `json:"s3_endpoint,omitempty"`
22+
// MaxFileSizeMB rolls a partition into a new parquet file once its on-disk size reaches
23+
// this many MB. Fractional values are allowed (e.g. 0.125 for a 128KB roll size), which
24+
// keeps integration tests light. When unset (<= 0) the writer falls back to defaultMaxFileSizeMB.
25+
MaxFileSizeMB float64 `json:"max_file_size_mb,omitempty"`
1626
}
1727

1828
func (c *Config) Validate() error {

destination/parquet/parquet.go

Lines changed: 137 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import (
3333

3434
type FileMetadata struct {
3535
fileName string
36-
writer any
37-
file source.ParquetFile
36+
writer *pqgo.GenericWriter[any]
37+
file source.ParquetFile // local file the parquet is streamed to; nil once finalized
38+
path string // full local path; used for size checks and S3 upload
3839
}
3940

4041
// Parquet destination writes Parquet files to a local path and optionally uploads them to S3.
@@ -43,10 +44,13 @@ type Parquet struct {
4344
config *Config
4445
stream types.StreamInterface
4546
basePath string // construct with streamNamespace/streamName
46-
partitionedFiles map[string][]*FileMetadata // mapping of basePath/{regex} -> pqFiles
47+
partitionedFiles map[string][]*FileMetadata // mapping of basePath/{regex} -> open (un-finalized) pqFiles
4748
s3Client *s3.S3
4849
s3Uploader *s3manager.Uploader
4950
schema typeutils.Fields
51+
52+
maxFileBytes int64 // roll a partition into a new file once its on-disk size reaches this
53+
checkIntervalForRoll int // number of []RawRecord written between on-disk size checks within a batch
5054
}
5155

5256
// GetConfigRef returns the config reference for the parquet writer.
@@ -91,7 +95,6 @@ func (p *Parquet) initS3Writer() error {
9195
func (p *Parquet) createNewPartitionFile(basePath string) error {
9296
// construct directory path
9397
directoryPath := filepath.Join(p.config.Path, basePath)
94-
9598
if err := os.MkdirAll(directoryPath, os.ModePerm); err != nil {
9699
return fmt.Errorf("failed to create directories[%s]: %s", directoryPath, err)
97100
}
@@ -104,7 +107,7 @@ func (p *Parquet) createNewPartitionFile(basePath string) error {
104107
return fmt.Errorf("failed to create parquet file writer: %s", err)
105108
}
106109

107-
writer := func() any {
110+
writer := func() *pqgo.GenericWriter[any] {
108111
if p.stream.NormalizationEnabled() {
109112
return pqgo.NewGenericWriter[any](pqFile, p.schema.ToTypeSchema().ToParquet(false, p.stream), pqgo.Compression(&pqgo.Snappy))
110113
}
@@ -115,6 +118,7 @@ func (p *Parquet) createNewPartitionFile(basePath string) error {
115118
fileName: fileName,
116119
file: pqFile,
117120
writer: writer,
121+
path: filePath,
118122
})
119123

120124
logger.Infof("Thread[%s]: created new partition file[%s]", p.options.ThreadID, filePath)
@@ -129,6 +133,17 @@ func (p *Parquet) Setup(_ context.Context, stream types.StreamInterface, schema
129133
p.basePath = filepath.Join(p.stream.GetDestinationDatabase(nil), p.stream.GetDestinationTable())
130134
p.schema = make(typeutils.Fields)
131135

136+
if p.maxFileBytes <= 0 {
137+
maxFileSizeMB := float64(defaultMaxFileSizeMB)
138+
if p.config.MaxFileSizeMB > 0 {
139+
maxFileSizeMB = p.config.MaxFileSizeMB
140+
}
141+
p.maxFileBytes = int64(maxFileSizeMB * 1024 * 1024)
142+
}
143+
if p.checkIntervalForRoll <= 0 {
144+
p.checkIntervalForRoll = defaultRollCheckInterval
145+
}
146+
132147
// for s3 p.config.path may not be provided
133148
if p.config.Path == "" {
134149
p.config.Path = os.TempDir()
@@ -161,7 +176,7 @@ func (p *Parquet) Setup(_ context.Context, stream types.StreamInterface, schema
161176
// Write writes a record to the Parquet file.
162177
func (p *Parquet) Write(_ context.Context, records []types.RawRecord) error {
163178
// TODO: use batch writing feature of pq writer
164-
for _, record := range records {
179+
for i, record := range records {
165180
// Normalise "i" -? "c": Parquet has no equality-delete concept; downstream
166181
// consumers must see a consistent "c" for all CDC inserts.
167182
// OlakeColumns covers the non-normalized path; Data covers the normalized path
@@ -174,41 +189,109 @@ func (p *Parquet) Write(_ context.Context, records []types.RawRecord) error {
174189
}
175190
partitionedPath := p.getPartitionedFilePath(record.Data, record.OlakeColumns[constants.OlakeTimestamp].(time.Time))
176191
partitionFiles, exists := p.partitionedFiles[partitionedPath]
177-
if !exists {
178-
err := p.createNewPartitionFile(partitionedPath)
179-
if err != nil {
192+
// Open a file when the partition is new or its previous file was just rolled away.
193+
if !exists || len(partitionFiles) == 0 {
194+
if err := p.createNewPartitionFile(partitionedPath); err != nil {
180195
return fmt.Errorf("failed to create partition file: %s", err)
181196
}
182197
partitionFiles = p.partitionedFiles[partitionedPath]
183198
}
184199

185-
if len(partitionFiles) == 0 {
186-
return fmt.Errorf("failed to create partition file for path[%s]", partitionedPath)
187-
}
188-
189200
partitionFile := partitionFiles[len(partitionFiles)-1]
190201

191202
var err error
192203
if p.stream.NormalizationEnabled() {
193-
_, err = partitionFile.writer.(*pqgo.GenericWriter[any]).Write([]any{record.Data})
204+
_, err = partitionFile.writer.Write([]any{record.Data})
194205
} else {
195206
dataBytes, merr := json.Marshal(record.Data)
196207
if merr != nil {
197-
return fmt.Errorf("failed to marshal data: %s", err)
208+
return fmt.Errorf("failed to marshal data: %s", merr)
198209
}
199210
recordsMap := map[string]any{constants.StringifiedData: string(dataBytes)}
200211
maps.Copy(recordsMap, record.OlakeColumns)
201212

202-
_, err = partitionFile.writer.(*pqgo.GenericWriter[any]).Write([]any{recordsMap})
213+
_, err = partitionFile.writer.Write([]any{recordsMap})
203214
}
204215
if err != nil {
205216
return fmt.Errorf("failed to write in parquet file: %s", err)
206217
}
218+
219+
if p.checkForRoll(i, len(records)) {
220+
if err := p.rollPartitionFile(partitionedPath, partitionFile); err != nil {
221+
return fmt.Errorf("failed to roll partition file: %s", err)
222+
}
223+
}
207224
}
208225

209226
return nil
210227
}
211228

229+
// roll gives true when we need to check for rolling based on the current index of record
230+
func (p *Parquet) checkForRoll(index, len int) bool {
231+
interval := p.checkIntervalForRoll
232+
if interval == 0 {
233+
return false
234+
}
235+
236+
return index%interval == 0 || index == len-1
237+
}
238+
239+
// rollPartitionFile flushes the partition's active writer so its buffered rows hit disk,
240+
// then—if the on-disk file has reached maxFileBytes—finalizes it (writing the footer) and
241+
// detaches it from the partition so the next Write opens a fresh file. Flushing on every
242+
// check also caps the writer's in-memory row-group buffer, keeping memory bounded as the
243+
// file grows. When S3 is configured the finalized file is uploaded (and deleted locally)
244+
// immediately so local disk stays bounded during the sync; for local-only destinations it
245+
// remains on disk as a completed output file, picked up nowhere else since it is detached.
246+
func (p *Parquet) rollPartitionFile(basePath string, pf *FileMetadata) error {
247+
// Flush buffered rows to disk so the size check reflects the real bytes written so far.
248+
if err := pf.writer.Flush(); err != nil {
249+
return fmt.Errorf("failed to flush parquet writer[%s]: %s", pf.path, err)
250+
}
251+
252+
info, err := os.Stat(pf.path)
253+
if err != nil {
254+
return fmt.Errorf("failed to stat parquet file[%s]: %s", pf.path, err)
255+
}
256+
if info.Size() < p.maxFileBytes {
257+
return nil
258+
}
259+
260+
// Threshold reached: write the footer and detach so the partition opens a new file next.
261+
if err := pf.writer.Close(); err != nil {
262+
return fmt.Errorf("failed to close parquet writer on roll[%s]: %s", pf.path, err)
263+
}
264+
if err := pf.file.Close(); err != nil {
265+
return fmt.Errorf("failed to close parquet file on roll[%s]: %s", pf.path, err)
266+
}
267+
pf.file = nil // mark finalized
268+
p.detachPartitionFile(basePath, pf)
269+
270+
logger.Infof("Thread[%s]: rolled partition file[%s] at %d bytes", p.options.ThreadID, pf.path, info.Size())
271+
272+
// Upload immediately in the S3 case to keep local disk bounded; local-only keeps the file.
273+
if p.s3Client != nil {
274+
if err := p.uploadLocalFileToS3(p.s3KeyFor(basePath, pf.fileName), pf.path); err != nil {
275+
return err
276+
}
277+
}
278+
return nil
279+
}
280+
281+
// detachPartitionFile removes a finalized file from its partition so the partition's next
282+
// Write recreates a fresh file (via the len(partitionFiles)==0 branch in Write). It matches
283+
// by pointer identity so it removes exactly the rolled file, leaving any other files (e.g.
284+
// an older-schema file kept around by EvolveSchema) in place.
285+
func (p *Parquet) detachPartitionFile(basePath string, pf *FileMetadata) {
286+
files := p.partitionedFiles[basePath]
287+
for i, f := range files {
288+
if f == pf {
289+
p.partitionedFiles[basePath] = append(files[:i], files[i+1:]...)
290+
return
291+
}
292+
}
293+
}
294+
212295
// Check validates local paths and S3 credentials if applicable.
213296
func (p *Parquet) Check(_ context.Context) error {
214297
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
@@ -260,6 +343,41 @@ func (p *Parquet) Check(_ context.Context) error {
260343
return nil
261344
}
262345

346+
// s3KeyFor builds the destination S3 object key for a finalized local file under basePath.
347+
func (p *Parquet) s3KeyFor(basePath, fileName string) string {
348+
key := basePath
349+
if p.config.Prefix != "" {
350+
key = filepath.Join(p.config.Prefix, key)
351+
}
352+
return filepath.Join(key, fileName)
353+
}
354+
355+
// uploadLocalFileToS3 uploads filePath to the given key via multipart upload (handles files
356+
// > 5GB automatically) and, on success, deletes the local copy. Shared by the roll path and
357+
// the final Close flush.
358+
func (p *Parquet) uploadLocalFileToS3(key, filePath string) error {
359+
file, err := os.Open(filePath)
360+
if err != nil {
361+
return fmt.Errorf("failed to open file %s: %s", filePath, err)
362+
}
363+
defer file.Close()
364+
365+
if _, err = p.s3Uploader.Upload(&s3manager.UploadInput{
366+
Bucket: aws.String(p.config.Bucket),
367+
Key: aws.String(key),
368+
Body: file,
369+
}); err != nil {
370+
return fmt.Errorf("failed to put object into s3 (%s): %s", key, err)
371+
}
372+
373+
// Remove local file after successful upload (best-effort; a leftover file is not fatal).
374+
if rerr := os.Remove(filePath); rerr != nil {
375+
logger.Warnf("Thread[%s]: Failed to delete file [%s] after S3 upload: %s", p.options.ThreadID, filePath, rerr)
376+
}
377+
logger.Infof("Thread[%s]: successfully uploaded file to S3: s3://%s/%s", p.options.ThreadID, p.config.Bucket, key)
378+
return nil
379+
}
380+
263381
func (p *Parquet) closePqFiles(ctx context.Context, _ any, closeOnError bool) error {
264382
removeLocalFile := func(filePath, reason string) {
265383
err := os.Remove(filePath)
@@ -284,7 +402,7 @@ func (p *Parquet) closePqFiles(ctx context.Context, _ any, closeOnError bool) er
284402
filePath := filepath.Join(p.config.Path, basePath, parquetFile.fileName)
285403

286404
// Close writers
287-
err := parquetFile.writer.(*pqgo.GenericWriter[any]).Close()
405+
err := parquetFile.writer.Close()
288406
if err != nil {
289407
return fmt.Errorf("failed to close writer: %s", err)
290408
}
@@ -303,16 +421,9 @@ func (p *Parquet) closePqFiles(ctx context.Context, _ any, closeOnError bool) er
303421
}
304422

305423
if p.s3Client != nil {
306-
// Construct S3 key path
307-
s3KeyPath := basePath
308-
if p.config.Prefix != "" {
309-
s3KeyPath = filepath.Join(p.config.Prefix, s3KeyPath)
310-
}
311-
s3KeyPath = filepath.Join(s3KeyPath, parquetFile.fileName)
312-
313424
filesToUpload = append(filesToUpload, uploadInfo{
314425
filePath: filePath,
315-
s3KeyPath: s3KeyPath,
426+
s3KeyPath: p.s3KeyFor(basePath, parquetFile.fileName),
316427
})
317428
}
318429
}
@@ -322,27 +433,7 @@ func (p *Parquet) closePqFiles(ctx context.Context, _ any, closeOnError bool) er
322433
concurrency := min(runtime.GOMAXPROCS(0)*2, len(filesToUpload))
323434

324435
err := utils.Concurrent(ctx, filesToUpload, concurrency, func(_ context.Context, info uploadInfo, _ int) error {
325-
// Open file for S3 upload
326-
file, err := os.Open(info.filePath)
327-
if err != nil {
328-
return fmt.Errorf("failed to open file %s: %s", info.filePath, err)
329-
}
330-
defer file.Close()
331-
332-
// Upload to S3 using multipart upload (automatically handles files > 5GB)
333-
_, err = p.s3Uploader.Upload(&s3manager.UploadInput{
334-
Bucket: aws.String(p.config.Bucket),
335-
Key: aws.String(info.s3KeyPath),
336-
Body: file,
337-
})
338-
if err != nil {
339-
return fmt.Errorf("failed to put object into s3 (%s): %s", info.s3KeyPath, err)
340-
}
341-
342-
// Remove local file after successful upload
343-
removeLocalFile(info.filePath, "uploaded to S3")
344-
logger.Infof("Thread[%s]: successfully uploaded file to S3: s3://%s/%s", p.options.ThreadID, p.config.Bucket, info.s3KeyPath)
345-
return nil
436+
return p.uploadLocalFileToS3(info.s3KeyPath, info.filePath)
346437
})
347438
if err != nil {
348439
return err

0 commit comments

Comments
 (0)