Skip to content

Commit 49b0088

Browse files
authored
Support reporting statistics in spark datasource (#8057)
Spark mostly focuses on sizeInBytes which we populate from file sizes with scaling. We also report numRows since that exists in our datasource. Signed-off-by: Robert Kruszewski <github@robertk.io>
1 parent d737639 commit 49b0088

11 files changed

Lines changed: 627 additions & 52 deletions

File tree

java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,57 @@ public OptionalLong asOptional() {
128128
}
129129
}
130130

131+
/**
132+
* Sum of the on-storage byte sizes of all files included in this data source along with the precision of that
133+
* estimate. Mirrors the Rust {@code Option<Precision<u64>>} returned by {@code DataSource::byte_size}:
134+
* {@link ByteSize.Unknown} when no estimate is available (for example when the filesystem listing did not return
135+
* sizes), {@link ByteSize.Estimate} for an inexact hint (some files contribute extrapolated sizes), and
136+
* {@link ByteSize.Exact} when every file has a known size.
137+
*/
138+
public ByteSize byteSize() {
139+
long[] out = new long[2];
140+
NativeDataSource.byteSize(pointer, out);
141+
return switch ((int) out[1]) {
142+
case 1 -> new ByteSize.Estimate(out[0]);
143+
case 2 -> new ByteSize.Exact(out[0]);
144+
default -> ByteSize.Unknown.INSTANCE;
145+
};
146+
}
147+
148+
/** Precision-aware byte size. See {@link #byteSize()}. */
149+
public sealed interface ByteSize {
150+
/** Returns the byte size as a long, or {@code OptionalLong.empty()} when unknown. */
151+
OptionalLong asOptional();
152+
153+
/** Byte size is not known. */
154+
final class Unknown implements ByteSize {
155+
public static final Unknown INSTANCE = new Unknown();
156+
157+
private Unknown() {}
158+
159+
@Override
160+
public OptionalLong asOptional() {
161+
return OptionalLong.empty();
162+
}
163+
}
164+
165+
/** Estimated byte size; the actual value may differ. */
166+
record Estimate(long value) implements ByteSize {
167+
@Override
168+
public OptionalLong asOptional() {
169+
return OptionalLong.of(value);
170+
}
171+
}
172+
173+
/** Exact byte size. */
174+
record Exact(long value) implements ByteSize {
175+
@Override
176+
public OptionalLong asOptional() {
177+
return OptionalLong.of(value);
178+
}
179+
}
180+
}
181+
131182
/** Submit a scan. */
132183
public Scan scan(ScanOptions options) {
133184
Objects.requireNonNull(options, "options");

java/vortex-jni/src/main/java/dev/vortex/jni/NativeDataSource.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,10 @@ private NativeDataSource() {}
3333
* {@code 1=estimate}, {@code 2=exact}.
3434
*/
3535
public static native void rowCount(long pointer, long[] out);
36+
37+
/**
38+
* Populate {@code out} with {@code [bytes, precision]}, the sum of on-storage file sizes for the data source.
39+
* Precision is one of {@code 0=unknown}, {@code 1=estimate}, {@code 2=exact}.
40+
*/
41+
public static native void byteSize(long pointer, long[] out);
3642
}

java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import dev.vortex.jni.NativeLoader;
1212
import java.io.IOException;
1313
import java.math.BigDecimal;
14+
import java.nio.file.Files;
1415
import java.nio.file.Path;
1516
import java.util.ArrayList;
1617
import java.util.HashMap;
@@ -132,6 +133,7 @@ public void testFullScan() throws Exception {
132133
DataSource ds = DataSource.open(session, writePath);
133134

134135
assertEquals(new DataSource.RowCount.Exact(10L), ds.rowCount());
136+
assertEquals(new DataSource.ByteSize.Exact(Files.size(tempDir.resolve("minimal.vortex"))), ds.byteSize());
135137

136138
var schema = ds.arrowSchema(allocator);
137139
assertEquals(

java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexBatchExec.java

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package dev.vortex.spark.read;
55

66
import com.google.common.collect.ImmutableMap;
7+
import dev.vortex.api.Session;
78
import dev.vortex.jni.NativeFiles;
89
import dev.vortex.spark.VortexFilePartition;
910
import dev.vortex.spark.VortexSparkSession;
@@ -76,14 +77,19 @@ public PartitionReaderFactory createReaderFactory() {
7677
}
7778

7879
private List<String> resolvePaths() {
79-
var session = VortexSparkSession.get(formatOptions);
80+
return resolveVortexPaths(VortexSparkSession.get(formatOptions), paths, formatOptions);
81+
}
82+
83+
/**
84+
* Expands directory-like entries to concrete {@code .vortex} files; entries that already name a {@code .vortex}
85+
* file are kept as-is. Shared with {@link VortexScan#estimateStatistics()} so planning and execution resolve paths
86+
* identically.
87+
*/
88+
static List<String> resolveVortexPaths(Session session, List<String> paths, Map<String, String> formatOptions) {
8089
return paths.stream()
81-
.flatMap(path -> {
82-
if (path.endsWith(".vortex")) {
83-
return Stream.of(path);
84-
}
85-
return NativeFiles.listFiles(session, path, formatOptions).stream();
86-
})
90+
.flatMap(path -> path.endsWith(".vortex")
91+
? Stream.of(path)
92+
: NativeFiles.listFiles(session, path, formatOptions).stream())
8793
.collect(Collectors.toList());
8894
}
8995

java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexScan.java

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,62 @@
33

44
package dev.vortex.spark.read;
55

6+
import dev.vortex.api.DataSource;
7+
import dev.vortex.api.Session;
8+
import dev.vortex.spark.VortexSparkSession;
69
import java.util.Arrays;
710
import java.util.List;
811
import java.util.Map;
12+
import java.util.OptionalLong;
913
import org.apache.spark.sql.connector.catalog.CatalogV2Util;
1014
import org.apache.spark.sql.connector.catalog.Column;
15+
import org.apache.spark.sql.connector.expressions.NamedReference;
1116
import org.apache.spark.sql.connector.expressions.filter.Predicate;
1217
import org.apache.spark.sql.connector.read.Batch;
1318
import org.apache.spark.sql.connector.read.Scan;
19+
import org.apache.spark.sql.connector.read.Statistics;
20+
import org.apache.spark.sql.connector.read.SupportsReportStatistics;
21+
import org.apache.spark.sql.connector.read.colstats.ColumnStatistics;
22+
import org.apache.spark.sql.internal.SQLConf;
1423
import org.apache.spark.sql.types.StructType;
1524

16-
/** Spark V2 {@link Scan} over a table of Vortex files. */
17-
public final class VortexScan implements Scan {
25+
/**
26+
* Spark V2 {@link Scan} over a table of Vortex files.
27+
*
28+
* <p>Implements {@link SupportsReportStatistics} to surface both the row count Vortex records in each file footer and a
29+
* Spark scan-size estimate. The byte estimate starts from the on-storage file sizes collected by
30+
* {@code MultiFileDataSource}, then follows Spark's file scan convention by applying the SQL file-compression factor
31+
* and scaling by the pushed read schema's default size relative to the full table schema's default size. When the
32+
* listing did not return a size for one or more files the file-byte total is extrapolated before Spark scaling is
33+
* applied.
34+
*/
35+
public final class VortexScan implements Scan, SupportsReportStatistics {
1836

1937
private final List<String> paths;
38+
private final List<Column> tableColumns;
2039
private final List<Column> readColumns;
2140
private final Map<String, String> formatOptions;
2241
private final Predicate[] pushedPredicates;
2342

43+
private volatile Statistics cachedStatistics;
44+
2445
/**
2546
* Creates a new VortexScan for the specified file paths and columns. The caller is responsible for passing
2647
* immutable collections; the constructor does not copy.
2748
*
2849
* @param paths the list of Vortex file paths to scan
50+
* @param tableColumns the full table columns before projection pushdown
2951
* @param readColumns the list of columns to read from the files
3052
* @param pushedPredicates predicates pushed down by Spark; {@code null} or empty means no pushdown
3153
*/
3254
public VortexScan(
3355
List<String> paths,
56+
List<Column> tableColumns,
3457
List<Column> readColumns,
35-
Map<String, String> formatOptions,
36-
Predicate[] pushedPredicates) {
58+
Predicate[] pushedPredicates,
59+
Map<String, String> formatOptions) {
3760
this.paths = paths;
61+
this.tableColumns = tableColumns;
3862
this.readColumns = readColumns;
3963
this.formatOptions = formatOptions;
4064
this.pushedPredicates = pushedPredicates == null ? new Predicate[0] : pushedPredicates.clone();
@@ -83,4 +107,70 @@ public Batch toBatch() {
83107
public ColumnarSupportMode columnarSupportMode() {
84108
return ColumnarSupportMode.SUPPORTED;
85109
}
110+
111+
/**
112+
* Returns statistics for this scan.
113+
*
114+
* <p>Opens the Vortex {@link DataSource} on first invocation and caches the result. The row count is taken from the
115+
* data source (sum of file-footer row counts; extrapolated from the first opened file when other files are
116+
* deferred). {@link Statistics#sizeInBytes()} is derived from the per-file sizes reported by the filesystem
117+
* listing, then adjusted by Spark's compression factor and the ratio between the pushed read schema and the full
118+
* table schema. When a listing did not return a size for some file the file-byte total is extrapolated. When no
119+
* file size is known at all the value is left empty so Spark falls back to its default heuristic.
120+
*
121+
* @return statistics with row-count and Spark scan-size estimates
122+
*/
123+
@Override
124+
public Statistics estimateStatistics() {
125+
Statistics local = cachedStatistics;
126+
if (local != null) {
127+
return local;
128+
}
129+
synchronized (this) {
130+
if (cachedStatistics == null) {
131+
cachedStatistics = computeStatistics();
132+
}
133+
return cachedStatistics;
134+
}
135+
}
136+
137+
private Statistics computeStatistics() {
138+
Session session = VortexSparkSession.get(formatOptions);
139+
List<String> resolvedPaths = VortexBatchExec.resolveVortexPaths(session, paths, formatOptions);
140+
if (resolvedPaths.isEmpty()) {
141+
return new VortexStatistics(OptionalLong.empty(), OptionalLong.empty());
142+
}
143+
144+
DataSource source = DataSource.open(session, resolvedPaths, formatOptions);
145+
return new VortexStatistics(
146+
source.rowCount().asOptional(),
147+
scaleSizeInBytes(source.byteSize().asOptional()));
148+
}
149+
150+
private OptionalLong scaleSizeInBytes(OptionalLong fileBytes) {
151+
if (fileBytes.isEmpty()) {
152+
return OptionalLong.empty();
153+
}
154+
155+
StructType tableSchema = CatalogV2Util.v2ColumnsToStructType(tableColumns.toArray(new Column[0]));
156+
StructType readSchema = readSchema();
157+
int tableDefaultSize = tableSchema.defaultSize();
158+
if (tableDefaultSize <= 0) {
159+
return fileBytes;
160+
}
161+
162+
double scaled = SQLConf.get().fileCompressionFactor()
163+
* fileBytes.getAsLong()
164+
/ tableDefaultSize
165+
* readSchema.defaultSize();
166+
return OptionalLong.of((long) scaled);
167+
}
168+
169+
private record VortexStatistics(OptionalLong numRows, OptionalLong sizeInBytes) implements Statistics {
170+
171+
@Override
172+
public Map<NamedReference, ColumnStatistics> columnStats() {
173+
return Map.of();
174+
}
175+
}
86176
}

java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexScanBuilder.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
public final class VortexScanBuilder
3232
implements ScanBuilder, SupportsPushDownRequiredColumns, SupportsPushDownV2Filters {
3333
private final ImmutableList.Builder<String> paths;
34-
private final List<Column> columns;
34+
private final List<Column> tableColumns;
35+
private final List<Column> readColumns;
3536
private final Map<String, String> formatOptions;
3637
private final Set<String> partitionColumnNames;
3738
private Predicate[] pushedPredicates = new Predicate[0];
@@ -48,10 +49,11 @@ public VortexScanBuilder(Map<String, String> formatOptions) {
4849
*/
4950
public VortexScanBuilder(Map<String, String> formatOptions, Transform[] partitionTransforms) {
5051
this.paths = ImmutableList.builder();
51-
this.columns = new ArrayList<>();
5252
Map<String, String> options = Maps.newHashMap();
5353
options.put("vortex.workerThreads", "4");
5454
options.putAll(formatOptions);
55+
this.tableColumns = new ArrayList<>();
56+
this.readColumns = new ArrayList<>();
5557
this.formatOptions = options;
5658
this.partitionColumnNames = collectPartitionColumnNames(partitionTransforms);
5759
}
@@ -74,7 +76,8 @@ public VortexScanBuilder addPath(String path) {
7476
* @return this builder for method chaining
7577
*/
7678
public VortexScanBuilder addColumn(Column column) {
77-
this.columns.add(column);
79+
this.tableColumns.add(column);
80+
this.readColumns.add(column);
7881
return this;
7982
}
8083

@@ -97,7 +100,7 @@ public VortexScanBuilder addAllPaths(Iterable<String> paths) {
97100
*/
98101
public VortexScanBuilder addAllColumns(Iterable<Column> columns) {
99102
for (Column column : columns) {
100-
this.columns.add(column);
103+
addColumn(column);
101104
}
102105
return this;
103106
}
@@ -116,7 +119,12 @@ public Scan build() {
116119
// Allow empty columns for operations like count() that don't need actual column data
117120
// If no columns are specified, we'll read the minimal schema needed
118121

119-
return new VortexScan(paths, List.copyOf(this.columns), this.formatOptions, pushedPredicates);
122+
return new VortexScan(
123+
paths,
124+
List.copyOf(this.tableColumns),
125+
List.copyOf(this.readColumns),
126+
pushedPredicates,
127+
this.formatOptions);
120128
}
121129

122130
/**
@@ -129,8 +137,8 @@ public Scan build() {
129137
*/
130138
@Override
131139
public void pruneColumns(StructType requiredSchema) {
132-
columns.clear();
133-
columns.addAll(Arrays.asList(CatalogV2Util.structTypeToV2Columns(requiredSchema)));
140+
readColumns.clear();
141+
readColumns.addAll(Arrays.asList(CatalogV2Util.structTypeToV2Columns(requiredSchema)));
134142
}
135143

136144
/**
@@ -145,7 +153,7 @@ public void pruneColumns(StructType requiredSchema) {
145153
@Override
146154
public Predicate[] pushPredicates(Predicate[] predicates) {
147155
Map<String, DataType> dataColumnTypes = new HashMap<>();
148-
for (Column column : columns) {
156+
for (Column column : readColumns) {
149157
if (!partitionColumnNames.contains(column.name())) {
150158
dataColumnTypes.put(column.name(), column.dataType());
151159
}

0 commit comments

Comments
 (0)