Skip to content

Commit 0a1ea17

Browse files
committed
fix: calculate uncompressed size correctly for CSV and Parquet formats
1 parent 13e83bf commit 0a1ea17

1 file changed

Lines changed: 91 additions & 5 deletions

File tree

cmd/archiver.go

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,6 +2074,85 @@ func convertPostgreSQLValue(value interface{}, pgType string) interface{} {
20742074
return value
20752075
}
20762076

2077+
// calculateUncompressedRowSize estimates the uncompressed size of a single row
2078+
// based on the output format. This is used for metrics and capacity planning.
2079+
func calculateUncompressedRowSize(row map[string]interface{}, format string, columnNames []string) int64 {
2080+
switch format {
2081+
case formatters.FormatJSONL:
2082+
// JSONL: JSON marshaling + newline
2083+
jsonBytes, _ := json.Marshal(row)
2084+
return int64(len(jsonBytes)) + 1 // +1 for newline
2085+
2086+
case formatters.FormatCSV:
2087+
// CSV: Estimate size accounting for commas, escaping, and newline
2088+
// CSV writer handles escaping, so we estimate conservatively
2089+
var size int64
2090+
for i, col := range columnNames {
2091+
if i > 0 {
2092+
size++ // comma
2093+
}
2094+
val := row[col]
2095+
if val == nil {
2096+
// Empty field
2097+
continue
2098+
}
2099+
// Estimate: string representation + potential escaping (double quotes + escaped quotes)
2100+
// CSV escaping: if value contains comma, newline, or quote, it's wrapped in quotes
2101+
// and internal quotes are doubled
2102+
valStr := fmt.Sprintf("%v", val)
2103+
needsEscaping := strings.Contains(valStr, ",") || strings.Contains(valStr, "\n") || strings.Contains(valStr, `"`)
2104+
if needsEscaping {
2105+
size += int64(len(valStr)) + 2 + int64(strings.Count(valStr, `"`)) // quotes + escaped quotes
2106+
} else {
2107+
size += int64(len(valStr))
2108+
}
2109+
}
2110+
size++ // newline
2111+
return size
2112+
2113+
case formatters.FormatParquet:
2114+
// Parquet: Binary columnar format - estimate based on data types
2115+
// Parquet is typically more compact than JSON, but we can't easily calculate
2116+
// without actually writing. Use a type-based estimate as approximation.
2117+
var size int64
2118+
for _, val := range row {
2119+
if val == nil {
2120+
continue // Nulls take minimal space in Parquet
2121+
}
2122+
switch v := val.(type) {
2123+
case bool:
2124+
size += 1 // 1 byte
2125+
case int, int8, int16, int32:
2126+
size += 4 // 4 bytes
2127+
case int64:
2128+
size += 8 // 8 bytes
2129+
case float32:
2130+
size += 4 // 4 bytes
2131+
case float64:
2132+
size += 8 // 8 bytes
2133+
case string:
2134+
// String length + overhead (dictionary encoding can reduce this, but we estimate conservatively)
2135+
size += int64(len(v)) + 4 // length prefix + string data
2136+
case []byte:
2137+
size += int64(len(v)) + 4 // length prefix + bytes
2138+
case time.Time:
2139+
size += 8 // timestamp typically 8 bytes
2140+
default:
2141+
// Fallback: estimate as string representation
2142+
size += int64(len(fmt.Sprintf("%v", v))) + 4
2143+
}
2144+
}
2145+
// Add overhead for Parquet metadata (column overhead, row group overhead)
2146+
// This is a rough estimate - actual Parquet files have additional overhead
2147+
return size + int64(len(row)*2) // ~2 bytes overhead per column
2148+
2149+
default:
2150+
// Fallback: use JSON size
2151+
jsonBytes, _ := json.Marshal(row)
2152+
return int64(len(jsonBytes)) + 1
2153+
}
2154+
}
2155+
20772156
// extractPartitionDataStreaming extracts partition data using streaming architecture
20782157
// This streams data in chunks to a temp file, avoiding loading everything into memory
20792158
//
@@ -2167,14 +2246,23 @@ func (a *Archiver) extractPartitionDataStreaming(partition PartitionInfo, progra
21672246
// Build column list for SELECT query
21682247
columns := schema.GetColumns()
21692248
columnNames := make([]string, len(columns))
2249+
unquotedColumnNames := make([]string, len(columns))
21702250
for i, col := range columns {
2251+
unquotedColumnNames[i] = col.GetName()
21712252
columnNames[i] = pq.QuoteIdentifier(col.GetName())
21722253
}
21732254

21742255
quotedTable := pq.QuoteIdentifier(partition.TableName)
21752256
//nolint:gosec // G201: SQL string formatting is safe here - all identifiers are properly quoted via pq.QuoteIdentifier
21762257
query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(columnNames, ", "), quotedTable)
21772258

2259+
// Account for CSV header row in uncompressed size
2260+
if a.config.OutputFormat == formatters.FormatCSV {
2261+
// CSV header: column names separated by commas + newline
2262+
headerSize := int64(len(strings.Join(unquotedColumnNames, ",")) + 1) // commas + newline
2263+
uncompressedSize += headerSize
2264+
}
2265+
21782266
rows, queryErr := a.db.QueryContext(a.ctx, query)
21792267
if queryErr != nil {
21802268
streamWriter.Close()
@@ -2251,10 +2339,9 @@ func (a *Archiver) extractPartitionDataStreaming(partition PartitionInfo, progra
22512339
return
22522340
}
22532341

2254-
// Track uncompressed size (approximate - JSON size of chunk)
2342+
// Track uncompressed size based on output format
22552343
for _, row := range chunk {
2256-
jsonBytes, _ := json.Marshal(row)
2257-
uncompressedSize += int64(len(jsonBytes)) + 1 // +1 for newline
2344+
uncompressedSize += calculateUncompressedRowSize(row, a.config.OutputFormat, unquotedColumnNames)
22582345
}
22592346

22602347
chunk = chunk[:0] // Reset slice, keeping capacity
@@ -2289,8 +2376,7 @@ func (a *Archiver) extractPartitionDataStreaming(partition PartitionInfo, progra
22892376

22902377
// Track uncompressed size of final chunk
22912378
for _, row := range chunk {
2292-
jsonBytes, _ := json.Marshal(row)
2293-
uncompressedSize += int64(len(jsonBytes)) + 1
2379+
uncompressedSize += calculateUncompressedRowSize(row, a.config.OutputFormat, unquotedColumnNames)
22942380
}
22952381
}
22962382

0 commit comments

Comments
 (0)