Skip to content

Commit d5280ae

Browse files
dfa1claude
andcommitted
fix(csv): stream import rows to fix OOM on large files (fixes #52)
Replace readAllRows (full List<String[]> in heap) with two-pass streaming: - Pass 1: infer schema by streaming the file with O(1) memory - Pass 2: write in chunks of chunkSize without buffering the full file Schema provided via withSchema() skips pass 1. Progress now reports (rowsDone, -1) since total is unknown during streaming; ImportCommand.renderProgress shows "imported N rows" instead of a percentage bar when total < 0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5e83a5c commit d5280ae

3 files changed

Lines changed: 141 additions & 88 deletions

File tree

cli/src/main/java/io/github/dfa1/vortex/cli/ImportCommand.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,14 @@ vortexPath, formatBytes(inputBytes), formatBytes(vortexBytes),
113113
}
114114

115115
private static void renderProgress(long done, long total) {
116-
int pct = total > 0 ? (int) (done * 100L / total) : 100;
117-
int filled = pct * 30 / 100;
118-
String bar = "=".repeat(filled) + (filled < 30 ? ">" : "") + " ".repeat(Math.max(0, 29 - filled));
119-
System.err.printf("\r [%s] %3d%% %,d / %,d rows", bar, pct, done, total);
116+
if (total < 0) {
117+
System.err.printf("\r imported %,d rows", done);
118+
} else {
119+
int pct = total > 0 ? (int) (done * 100L / total) : 100;
120+
int filled = pct * 30 / 100;
121+
String bar = "=".repeat(filled) + (filled < 30 ? ">" : "") + " ".repeat(Math.max(0, 29 - filled));
122+
System.err.printf("\r [%s] %3d%% %,d / %,d rows", bar, pct, done, total);
123+
}
120124
System.err.flush();
121125
}
122126

csv/src/main/java/io/github/dfa1/vortex/csv/CsvImporter.java

Lines changed: 128 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.nio.file.Path;
1212
import java.nio.file.StandardOpenOption;
1313
import java.util.ArrayList;
14+
import java.util.Arrays;
1415
import java.util.LinkedHashMap;
1516
import java.util.List;
1617
import java.util.Map;
@@ -20,126 +21,169 @@
2021
/// Column types are inferred in priority order: long → double → boolean → utf8.
2122
/// Provide a schema via [ImportOptions#withSchema] to skip inference.
2223
/// Empty cell values are treated as 0 / false / "" for typed columns.
24+
///
25+
/// Import is streaming: rows are read and written in chunks of [ImportOptions#chunkSize]
26+
/// without loading the entire file into memory. Schema inference requires a first
27+
/// sequential pass over the file; writing is a second pass. When a schema is provided
28+
/// via [ImportOptions#withSchema] only one pass is needed.
2329
public final class CsvImporter {
2430

2531
private CsvImporter() {
2632
}
2733

34+
/// Imports a CSV file to a Vortex file using default options.
35+
///
36+
/// @param csvPath path to the source CSV file
37+
/// @param vortexPath path to write the output Vortex file
38+
/// @throws IOException if reading or writing fails
2839
public static void importCsv(Path csvPath, Path vortexPath) throws IOException {
2940
importCsv(csvPath, vortexPath, ImportOptions.defaults());
3041
}
3142

43+
/// Imports a CSV file to a Vortex file.
44+
///
45+
/// Rows are streamed in chunks — the file is never fully loaded into memory.
46+
/// If no schema is provided, a first streaming pass infers column types; a
47+
/// second pass writes the data. Progress is reported via
48+
/// {@link ProgressListener#onProgress(long, long)} with `rowsTotal = -1`
49+
/// (total unknown) on each completed chunk.
50+
///
51+
/// @param csvPath path to the source CSV file
52+
/// @param vortexPath path to write the output Vortex file
53+
/// @param options import configuration
54+
/// @throws IOException if reading or writing fails
55+
/// @throws IllegalArgumentException if the CSV file is empty
3256
public static void importCsv(Path csvPath, Path vortexPath, ImportOptions options) throws IOException {
33-
List<String[]> rows = readAllRows(csvPath, options);
34-
if (rows.isEmpty()) {
35-
throw new IllegalArgumentException("CSV file is empty");
36-
}
37-
38-
String[] headers;
39-
int dataStart;
40-
if (options.hasHeader()) {
41-
headers = rows.getFirst();
42-
dataStart = 1;
43-
} else {
44-
headers = generateHeaders(rows.getFirst().length);
45-
dataStart = 0;
46-
}
47-
48-
List<String[]> dataRows = rows.subList(dataStart, rows.size());
49-
int colCount = headers.length;
57+
String[] headers = readHeader(csvPath, options);
5058

51-
DType.Struct schema;
52-
if (options.schema() != null) {
53-
schema = options.schema();
54-
} else {
55-
schema = inferSchema(headers, dataRows, colCount);
56-
}
59+
DType.Struct schema = options.schema() != null
60+
? options.schema()
61+
: inferSchemaStreaming(csvPath, options, headers);
5762

5863
try (FileChannel channel = FileChannel.open(
5964
vortexPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
6065
StandardOpenOption.TRUNCATE_EXISTING);
6166
VortexWriter writer = VortexWriter.create(channel, schema, options.writeOptions())) {
62-
int chunkSize = options.chunkSize();
63-
int total = dataRows.size();
64-
for (int start = 0; start < total; start += chunkSize) {
65-
int end = Math.min(start + chunkSize, total);
66-
writer.writeChunk(buildChunk(schema, dataRows.subList(start, end)));
67-
if (options.progressListener() != null) {
68-
options.progressListener().onProgress(end, total);
69-
}
70-
}
67+
writeChunksStreaming(csvPath, options, schema, writer);
7168
}
7269
}
7370

74-
private static List<String[]> readAllRows(Path path, ImportOptions options) throws IOException {
75-
List<String[]> rows = new ArrayList<>();
76-
try (CsvReader<CsvRecord> reader = CsvReader.builder()
77-
.fieldSeparator(options.delimiter())
78-
.ofCsvRecord(path)) {
79-
for (CsvRecord record : reader) {
80-
rows.add(record.getFields().toArray(String[]::new));
71+
private static String[] readHeader(Path path, ImportOptions options) throws IOException {
72+
try (CsvReader<CsvRecord> reader = csvReader(path, options)) {
73+
CsvRecord first = reader.stream().findFirst().orElse(null);
74+
if (first == null) {
75+
throw new IllegalArgumentException("CSV file is empty");
8176
}
77+
String[] fields = first.getFields().toArray(String[]::new);
78+
return options.hasHeader() ? fields : generateHeaders(fields.length);
8279
}
83-
return rows;
8480
}
8581

86-
private static String[] generateHeaders(int colCount) {
87-
String[] headers = new String[colCount];
88-
for (int i = 0; i < colCount; i++) {
89-
headers[i] = "col" + i;
82+
private static DType.Struct inferSchemaStreaming(Path path, ImportOptions options, String[] headers)
83+
throws IOException {
84+
int colCount = headers.length;
85+
boolean[] canBeLong = new boolean[colCount];
86+
boolean[] canBeDouble = new boolean[colCount];
87+
boolean[] canBeBool = new boolean[colCount];
88+
Arrays.fill(canBeLong, true);
89+
Arrays.fill(canBeDouble, true);
90+
Arrays.fill(canBeBool, true);
91+
92+
try (CsvReader<CsvRecord> reader = csvReader(path, options)) {
93+
boolean skipFirst = options.hasHeader();
94+
for (CsvRecord record : reader) {
95+
if (skipFirst) {
96+
skipFirst = false;
97+
continue;
98+
}
99+
String[] fields = record.getFields().toArray(String[]::new);
100+
for (int c = 0; c < colCount; c++) {
101+
String val = safeGet(fields, c);
102+
if (val.isEmpty()) {
103+
continue;
104+
}
105+
if (canBeLong[c]) {
106+
try {
107+
Long.parseLong(val);
108+
} catch (NumberFormatException e) {
109+
canBeLong[c] = false;
110+
}
111+
}
112+
if (canBeDouble[c]) {
113+
try {
114+
Double.parseDouble(val);
115+
} catch (NumberFormatException e) {
116+
canBeDouble[c] = false;
117+
}
118+
}
119+
if (canBeBool[c]) {
120+
if (!val.equalsIgnoreCase("true") && !val.equalsIgnoreCase("false")) {
121+
canBeBool[c] = false;
122+
}
123+
}
124+
}
125+
}
90126
}
91-
return headers;
92-
}
93127

94-
private static DType.Struct inferSchema(String[] headers, List<String[]> rows, int colCount) {
95128
List<String> names = List.of(headers);
96129
List<DType> types = new ArrayList<>(colCount);
97130
for (int c = 0; c < colCount; c++) {
98-
types.add(inferColumnType(rows, c));
131+
types.add(resolveType(canBeLong[c], canBeDouble[c], canBeBool[c]));
99132
}
100133
return new DType.Struct(names, types, false);
101134
}
102135

103-
/// Infers the narrowest type for a column using a single pass.
104-
///
105-
/// Priority: long → double → bool → utf8. Each flag starts true and can only
106-
/// transition to false. Empty cells are skipped (compatible with any type).
107-
/// An all-empty column is inferred as long (all flags remain true).
108-
///
109-
/// Integer values always infer as i64 (not i32/i16): CSV has no type annotations,
110-
/// so the widest safe integer is chosen. Use [ImportOptions#withSchema] to force i32/i16.
111-
/// Floating-point values always infer as f64 (not f32) for the same reason.
112-
private static DType inferColumnType(List<String[]> rows, int colIdx) {
113-
boolean canBeLong = true;
114-
boolean canBeDouble = true;
115-
boolean canBeBool = true;
116-
117-
for (String[] row : rows) {
118-
String val = safeGet(row, colIdx);
119-
if (val.isEmpty()) {
120-
continue;
121-
}
122-
if (canBeLong) {
123-
try {
124-
Long.parseLong(val);
125-
} catch (NumberFormatException e) {
126-
canBeLong = false;
127-
}
128-
}
129-
if (canBeDouble) {
130-
try {
131-
Double.parseDouble(val);
132-
} catch (NumberFormatException e) {
133-
canBeDouble = false;
136+
private static void writeChunksStreaming(Path path, ImportOptions options, DType.Struct schema,
137+
VortexWriter writer) throws IOException {
138+
int chunkSize = options.chunkSize();
139+
List<String[]> chunk = new ArrayList<>(chunkSize);
140+
long totalRows = 0;
141+
142+
try (CsvReader<CsvRecord> reader = csvReader(path, options)) {
143+
boolean skipFirst = options.hasHeader();
144+
for (CsvRecord record : reader) {
145+
if (skipFirst) {
146+
skipFirst = false;
147+
continue;
134148
}
135-
}
136-
if (canBeBool) {
137-
if (!val.equalsIgnoreCase("true") && !val.equalsIgnoreCase("false")) {
138-
canBeBool = false;
149+
chunk.add(record.getFields().toArray(String[]::new));
150+
if (chunk.size() == chunkSize) {
151+
writer.writeChunk(buildChunk(schema, chunk));
152+
totalRows += chunk.size();
153+
reportProgress(options, totalRows);
154+
chunk.clear();
139155
}
140156
}
141157
}
142158

159+
if (!chunk.isEmpty()) {
160+
writer.writeChunk(buildChunk(schema, chunk));
161+
totalRows += chunk.size();
162+
reportProgress(options, totalRows);
163+
}
164+
}
165+
166+
private static void reportProgress(ImportOptions options, long totalRows) {
167+
if (options.progressListener() != null) {
168+
options.progressListener().onProgress(totalRows, -1);
169+
}
170+
}
171+
172+
private static CsvReader<CsvRecord> csvReader(Path path, ImportOptions options) throws IOException {
173+
return CsvReader.builder()
174+
.fieldSeparator(options.delimiter())
175+
.ofCsvRecord(path);
176+
}
177+
178+
private static String[] generateHeaders(int colCount) {
179+
String[] headers = new String[colCount];
180+
for (int i = 0; i < colCount; i++) {
181+
headers[i] = "col" + i;
182+
}
183+
return headers;
184+
}
185+
186+
private static DType resolveType(boolean canBeLong, boolean canBeDouble, boolean canBeBool) {
143187
if (canBeLong) {
144188
return new DType.Primitive(PType.I64, false);
145189
}
@@ -152,7 +196,7 @@ private static DType inferColumnType(List<String[]> rows, int colIdx) {
152196
return new DType.Utf8(false);
153197
}
154198

155-
private static Map<String, Object> buildChunk(DType.Struct schema, List<String[]> rows) {
199+
static Map<String, Object> buildChunk(DType.Struct schema, List<String[]> rows) {
156200
int n = rows.size();
157201
Map<String, Object> chunk = new LinkedHashMap<>();
158202
for (int c = 0; c < schema.fieldNames().size(); c++) {

csv/src/main/java/io/github/dfa1/vortex/csv/ProgressListener.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,10 @@
33
/// Callback invoked during CSV import or export to report row-level progress.
44
@FunctionalInterface
55
public interface ProgressListener {
6+
7+
/// Called after each chunk is processed.
8+
///
9+
/// @param rowsDone number of data rows processed so far
10+
/// @param rowsTotal total row count, or `-1` if the total is not known (streaming import)
611
void onProgress(long rowsDone, long rowsTotal);
712
}

0 commit comments

Comments
 (0)