Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- **Breaking:** the `ScanIterator` constructor and the reader's Pco helpers (`PcoTansDecoder`, `PcoBin`, `LeBitReader`) are no longer public, and the built-in extension codecs are reached only through their `INSTANCE`. ([4ba6ea12](https://github.com/dfa1/vortex-java/commit/4ba6ea12))
- SQL `ORDER BY ... LIMIT` on a string column is up to 3 times faster: rows that cannot make the top are ranked by their bytes without building a `String`, and a chunk stored in the opposite order is read backward. ([#575](https://github.com/dfa1/vortex-java/pull/575))

## [0.18.0] — 2026-10-20

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ final class VortexEnumerator implements Enumerator<Object[]> {
private final DType[] types;
/// Per output column, its temporal conversion or `null`: resolved once, not per row.
private final Temporal[] temporals;
private final RowFilter exactFilter;
/// The row test per chunk of `exactFilter`, or `null`: one per scan, so a shared dictionary is
/// The row test per chunk of the exact filter, or `null`: one per scan, so a shared dictionary is
/// evaluated once.
private final Function<Chunk, RowSelection> matcher;
private final VortexReader reader;
Expand Down Expand Up @@ -83,7 +82,6 @@ final class VortexEnumerator implements Enumerator<Object[]> {
for (int c = 0; c < types.length; c++) {
temporals[c] = Temporal.of(types[c]);
}
this.exactFilter = exactFilter;
this.matcher = exactFilter == null ? null : Compute.matcher(exactFilter);
chunksScannedLastQuery.set(0);
VortexReader openedReader = null;
Expand Down Expand Up @@ -133,11 +131,10 @@ boolean advance() {
return true;
}

/// Moves to the next row the filter keeps in the current chunk, leaving the chunk open at its end
/// so a caller can still read rows of it by index ([#column(int, long)]).
/// Moves to the next row the filter keeps in the current chunk.
///
/// @return `false` at the end of the chunk, or before the first [#nextChunk()]
boolean advanceInChunk() {
private boolean advanceInChunk() {
if (chunk == null || rowInChunk >= chunkRows) {
return false;
}
Expand Down Expand Up @@ -174,23 +171,35 @@ boolean nextChunk() {
return true;
}

/// The index in its chunk of the row [#advance()] moved to.
long rowInChunk() {
return currentRow;
/// The number of rows of the current chunk, before the filter.
long chunkRowCount() {
return chunkRows;
}

/// The rows of the current chunk the filter keeps, or `null` when there is no filter and every
/// row is kept.
RowSelection selection() {
return rowMatcher;
}

/// Column `c` of the row [#advance()] moved to.
Object column(int c) {
return column(c, currentRow);
}

/// Column `c` of row `r` of the current chunk.
Object column(int c, long r) {
/// Column `c` of the current chunk as decoded, read on first use.
Object array(int c) {
Object array = columns[c];
if (array == null) {
array = chunk.column(names[c]);
columns[c] = array;
}
return array;
}

/// Column `c` of row `r` of the current chunk.
Object column(int c, long r) {
Object array = array(c);
Temporal temporal = temporals[c];
return temporal == null ? value(array, types[c], r) : temporal.read(array, r);
}
Expand Down
119 changes: 109 additions & 10 deletions calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -633,7 +634,9 @@ public record SortKey(int field, boolean descending, boolean nullsFirst) {
/// read per row, and each value is compared as itself: the sort Calcite runs otherwise converts
/// every number to a `BigDecimal` per comparison. A row is read in full only if it is still among
/// the first `count` when its chunk ends, so a column sorted against the query, where every row
/// beats the last, costs `count` full rows per chunk instead of one per row.
/// beats the last, costs `count` full rows per chunk instead of one per row. Such a chunk is also
/// walked backward, best rows first, so the rest lose to the worst row kept; a string row that
/// loses on its first key is told by its bytes, without building its `String`.
///
/// @param cols the table columns to emit, by position
/// @param filter the rows to keep, or `null` for every row
Expand All @@ -653,30 +656,52 @@ public List<Object[]> topN(int[] cols, RowFilter filter, List<SortKey> keys, int
int capacity = count + Math.max(1024, Math.min(count, 1 << 20));
List<Candidate> kept = new ArrayList<>();
List<Candidate> fromChunk = new ArrayList<>();
// the worst row kept once there are count of them: a row equal to it or worse cannot enter, as a
// later row never beats an equal key
SortKey first = keys.getFirst();
boolean stringFirst = struct().fieldTypes().get(cols[first.field()]) instanceof DType.Utf8;
// the worst row kept once there are count of them, and its first key as bytes to reject a
// string row by without building it
Object[] worst = null;
byte[] bound = null;
Object[] probe = new Object[cols.length];
long position = 0;
Object[] sample = new Object[cols.length];
long chunkStart = 0;
try (VortexEnumerator rows = enumerator(cols, filter)) {
while (rows.nextChunk()) {
while (rows.advanceInChunk()) {
long n = rows.chunkRowCount();
RowSelection selection = rows.selection();
// a chunk sorted against the query is walked backward: its best rows come first, and
// every row after them loses to the bound; forward, the selection skips the rows the
// filter rejects a word at a time
boolean backward = sortedAgainst(rows, selection, keys, order, n, probe, sample);
for (long r = backward ? n - 1 : nextSelected(selection, 0, n); r >= 0;
r = backward ? r - 1 : nextSelected(selection, r + 1, n)) {
if (backward && selection != null && !selection.test(r)
|| bound != null && firstKeyAfter(rows, first, r, bound)) {
continue;
}
for (SortKey key : keys) {
probe[key.field()] = rows.column(key.field());
probe[key.field()] = rows.column(key.field(), r);
}
if (worst != null && order.compare(probe, worst) >= 0) {
position++;
continue;
// a row equal to the worst cannot enter when it comes later, but can when walking
// backward: equal keys rank by position
if (worst != null) {
int cmp = order.compare(probe, worst);
if (cmp > 0 || cmp == 0 && !backward) {
continue;
}
}
Candidate candidate = new Candidate(probe.clone(), position++, rows.rowInChunk());
Candidate candidate = new Candidate(probe.clone(), chunkStart + r, r);
kept.add(candidate);
fromChunk.add(candidate);
if (kept.size() == capacity) {
worst = cut(kept, ranking, count);
bound = stringFirst ? asciiBound(worst, first) : null;
fromChunk.removeIf(c -> c.rowInChunk < 0);
}
}
chunkStart += n;
worst = cut(kept, ranking, count);
bound = stringFirst ? asciiBound(worst, first) : null;
// the survivors are read in full while their chunk is still open
for (Candidate c : fromChunk) {
if (c.rowInChunk >= 0) {
Expand All @@ -692,6 +717,80 @@ public List<Object[]> topN(int[] cols, RowFilter filter, List<SortKey> keys, int
return kept.stream().map(c -> c.row).toList();
}

/// The first row of `selection` at or after `from` among `n`, every row when it is `null`.
///
/// @return the row, or `-1` past the last
private static long nextSelected(RowSelection selection, long from, long n) {
if (selection != null) {
return selection.nextSelected(from);
}
return from < n ? from : -1;
}

/// Whether the current chunk looks sorted against `order`: five evenly spaced rows each rank
/// strictly before the one above them. Walking a chunk backward gains nothing on rows in no
/// order, so those must almost never pass (one chunk in 120). A sample row the filter rejects
/// is never read, as reading it may fail (a `u64` past `BIGINT`), so the chunk is walked forward.
///
/// @param selection the rows the filter keeps, or `null` for every row
/// @param a scratch row for the sampled keys
/// @param b another scratch row
private static boolean sortedAgainst(VortexEnumerator rows, RowSelection selection, List<SortKey> keys,
java.util.Comparator<Object[]> order, long n, Object[] a, Object[] b) {
// ponytail: a sample, not a scan; a chunk sorted only in parts, or whose sample the filter
// cuts, is walked forward
if (n < 5) {
return false;
}
Object[] above = a;
Object[] below = b;
for (int s = 0; s <= 4; s++) {
long r = s * (n - 1) / 4;
if (selection != null && !selection.test(r)) {
return false;
}
for (SortKey key : keys) {
below[key.field()] = rows.column(key.field(), r);
}
if (s > 0 && order.compare(below, above) >= 0) {
return false;
}
Object[] swap = above;
above = below;
below = swap;
}
return true;
}

/// The first key of `worst` as bytes, when it is an ASCII string. Comparing a row's UTF-8 bytes
/// with ASCII bytes gives [String#compareTo]'s answer: they agree up to the first differing byte,
/// where a non-ASCII byte of the row, valid or not, decodes to a character above every ASCII one.
///
/// @return the bytes, or `null` when there is no worst row or its key is null or not ASCII
private static byte[] asciiBound(Object[] worst, SortKey first) {
// ponytail: a non-ASCII bound falls back to building every row; beyond ASCII, byte order and
// String order disagree on supplementary characters, which a fix-up at the first differing
// byte would handle
if (worst != null && worst[first.field()] instanceof String s && s.chars().allMatch(c -> c < 0x80)) {
return s.getBytes(StandardCharsets.US_ASCII);
}
return null;
}

/// Whether row `r`'s first key, a string, ranks strictly after `bound`, read from its bytes. A null
/// key answers `false`, leaving the row to the full comparison.
private static boolean firstKeyAfter(VortexEnumerator rows, SortKey first, long r, byte[] bound) {
Object array = rows.array(first.field());
if (array instanceof MaskedArray masked) {
if (!masked.isValid(r)) {
return false;
}
array = masked.inner();
}
int cmp = ((VarBinArray) array).compareUnsigned(r, bound);
return first.descending() ? cmp < 0 : cmp > 0;
}

/// Sorts `kept` and drops all but the first `count`, marking the dropped ones so they are never read.
///
/// @return the worst row kept when there are `count` of them, else `null`
Expand Down
Loading
Loading