@@ -33,8 +33,9 @@ import (
3333
3434type 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 {
9195func (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.
162177func (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.
213296func (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+
263381func (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