Skip to content

Commit 015f19b

Browse files
committed
GEOMESA-3582 Added connector-configurable BBOX-containment short-circuiting logic to the plugin connector, which shortcuts the more expensive WKB decode during row-filtering, provides additional performance improvement for coarsely-pruned datasets.
1 parent 61e41d6 commit 015f19b

4 files changed

Lines changed: 350 additions & 5 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/***********************************************************************
2+
* Copyright (c) 2013-2025 General Atomics Integrated Intelligence, Inc.
3+
* All rights reserved. This program and the accompanying materials
4+
* are made available under the terms of the Apache License, Version 2.0
5+
* which accompanies this distribution and is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
***********************************************************************/
8+
9+
package org.locationtech.geomesa.trino.spatial.iceberg.connector;
10+
11+
import io.trino.spi.Page;
12+
import io.trino.spi.block.Block;
13+
import io.trino.spi.connector.ConnectorPageSource;
14+
import io.trino.spi.connector.SourcePage;
15+
import io.trino.spi.metrics.Metrics;
16+
import io.trino.spi.type.RealType;
17+
18+
import java.io.IOException;
19+
import java.util.List;
20+
import java.util.OptionalLong;
21+
import java.util.concurrent.CompletableFuture;
22+
import java.util.function.ObjLongConsumer;
23+
24+
/**
25+
* Wraps the Iceberg page source with a sound, pre-decode <em>cheap-reject</em>: for a spatial
26+
* query the connector has already injected the query envelope as REAL domains on a geometry's
27+
* {@code __X_bbox__} sub-fields, and those domains ride to the worker in the table handle's
28+
* unenforced predicate. This page source reads only those cheap bbox columns and drops any row
29+
* whose bbox cannot overlap the envelope — a necessary condition for any intersection-implying
30+
* predicate — so the engine's exact {@code ST_Intersects}/{@code ST_Within} residual never
31+
* decodes those rows' geometry WKB.
32+
*
33+
* <p><strong>Correctness.</strong> Only rows that <em>fail</em> a necessary condition are
34+
* dropped, so no matching row is ever removed; the exact predicate still runs on every survivor
35+
* (the engine's residual is untouched — this is a pre-filter, not a replacement). Null bbox
36+
* values are kept (cannot prove non-overlap). Bounds come from the domains'
37+
* {@link io.trino.spi.predicate.SortedRangeSet#getSpan span}, so a disjunction (OR) of envelopes
38+
* degrades to their bounding box — still a superset, still sound.
39+
*
40+
* <p><strong>Laziness.</strong> Only the bbox columns are materialized here; the drop is applied
41+
* via {@link SourcePage#selectPositions} <em>before</em> the geometry column is read, so the
42+
* engine decodes WKB only for surviving rows. The bbox columns this page source added to the
43+
* physical read (beyond what the query projected) are stripped from the returned page.
44+
*/
45+
final class BboxRejectingPageSource implements ConnectorPageSource {
46+
47+
/** One injected bbox-envelope bound: the row's value in {@code channel} must lie within
48+
* {@code [low, high]} (either side may be infinite) or the row cannot overlap. */
49+
record BboxBound(int channel, float low, float high) {}
50+
51+
private final ConnectorPageSource delegate;
52+
private final List<BboxBound> bounds;
53+
/** Number of channels the query actually requested; any beyond this were added to read the
54+
* bbox sub-fields and must be hidden from the engine. */
55+
private final int outputChannelCount;
56+
private final boolean stripAddedChannels;
57+
58+
BboxRejectingPageSource(ConnectorPageSource delegate, int outputChannelCount,
59+
boolean stripAddedChannels, List<BboxBound> bounds) {
60+
this.delegate = delegate;
61+
this.outputChannelCount = outputChannelCount;
62+
this.stripAddedChannels = stripAddedChannels;
63+
this.bounds = bounds;
64+
}
65+
66+
@Override
67+
public SourcePage getNextSourcePage() {
68+
SourcePage page = delegate.getNextSourcePage();
69+
if (page == null) {
70+
return null;
71+
}
72+
int positions = page.getPositionCount();
73+
74+
// Materialize ONLY the cheap bbox columns (geometry stays lazy).
75+
Block[] boundBlocks = new Block[bounds.size()];
76+
for (int i = 0; i < bounds.size(); i++) {
77+
boundBlocks[i] = page.getBlock(bounds.get(i).channel());
78+
}
79+
80+
int[] retained = new int[positions];
81+
int kept = 0;
82+
for (int p = 0; p < positions; p++) {
83+
if (overlaps(p, boundBlocks)) {
84+
retained[kept++] = p;
85+
}
86+
}
87+
88+
// Apply the drop BEFORE the geometry column is read, so survivors alone decode WKB.
89+
if (kept < positions) {
90+
page.selectPositions(retained, 0, kept);
91+
}
92+
return stripAddedChannels ? new ChannelPrefixSourcePage(page, outputChannelCount) : page;
93+
}
94+
95+
/** True iff the row's bbox could overlap the query envelope (every bound satisfied); a null
96+
* bbox value cannot prove non-overlap, so it does not reject. */
97+
private boolean overlaps(int position, Block[] boundBlocks) {
98+
for (int i = 0; i < bounds.size(); i++) {
99+
Block b = boundBlocks[i];
100+
if (b.isNull(position)) {
101+
continue;
102+
}
103+
float v = RealType.REAL.getFloat(b, position);
104+
BboxBound bound = bounds.get(i);
105+
if (v < bound.low() || v > bound.high()) {
106+
return false;
107+
}
108+
}
109+
return true;
110+
}
111+
112+
/**
113+
* Pure delegation
114+
*/
115+
@Override public long getCompletedBytes() { return delegate.getCompletedBytes(); }
116+
@Override public OptionalLong getCompletedPositions() { return delegate.getCompletedPositions(); }
117+
@Override public long getReadTimeNanos() { return delegate.getReadTimeNanos(); }
118+
@Override public boolean isFinished() { return delegate.isFinished(); }
119+
@Override public long getMemoryUsage() { return delegate.getMemoryUsage(); }
120+
@Override public void close() throws IOException { delegate.close(); }
121+
@Override public CompletableFuture<?> isBlocked() { return delegate.isBlocked(); }
122+
@Override public Metrics getMetrics() { return delegate.getMetrics(); }
123+
124+
/** A {@link SourcePage} view exposing only the first {@code channelCount} channels of a
125+
* delegate, hiding the bbox sub-field columns this page source added to the physical read. */
126+
private static final class ChannelPrefixSourcePage implements SourcePage {
127+
private final SourcePage delegate;
128+
private final int channelCount;
129+
130+
ChannelPrefixSourcePage(SourcePage delegate, int channelCount) {
131+
this.delegate = delegate;
132+
this.channelCount = channelCount;
133+
}
134+
135+
@Override public int getPositionCount() { return delegate.getPositionCount(); }
136+
@Override public long getSizeInBytes() { return delegate.getSizeInBytes(); }
137+
@Override public long getRetainedSizeInBytes() { return delegate.getRetainedSizeInBytes(); }
138+
@Override public void retainedBytesForEachPart(ObjLongConsumer<Object> consumer) {
139+
delegate.retainedBytesForEachPart(consumer);
140+
}
141+
@Override public int getChannelCount() { return channelCount; }
142+
@Override public Block getBlock(int channel) {
143+
if (channel >= channelCount) {
144+
throw new IndexOutOfBoundsException("channel " + channel + " >= " + channelCount);
145+
}
146+
return delegate.getBlock(channel);
147+
}
148+
@Override public Page getPage() {
149+
int[] channels = new int[channelCount];
150+
for (int i = 0; i < channelCount; i++) {
151+
channels[i] = i;
152+
}
153+
return delegate.getColumns(channels);
154+
}
155+
@Override public Page getColumns(int[] channels) { return delegate.getColumns(channels); }
156+
@Override public void selectPositions(int[] positions, int offset, int size) {
157+
delegate.selectPositions(positions, offset, size);
158+
}
159+
}
160+
}

geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/connector/SpatialConnector.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@ public class SpatialConnector implements Connector {
3434
private final Connector delegate;
3535
private final GeoMesaColumnCatalog geomCatalog;
3636
private final ConnectorAccessControl accessControl;
37+
private final boolean bboxPageFilter;
3738

3839
/**
3940
* Wraps a delegate connector with no Trino-layer visibility enforcement.
4041
*
4142
* @param delegate the underlying iceberg connector
4243
*/
4344
public SpatialConnector(Connector delegate) {
44-
this(delegate, null, null);
45+
this(delegate, null, null, true);
4546
}
4647

4748
/**
@@ -55,10 +56,26 @@ public SpatialConnector(Connector delegate) {
5556
* access control delegates to Iceberg (no extra filter).
5657
*/
5758
public SpatialConnector(Connector delegate, String catalogName, AuthorizationResolver resolver) {
59+
this(delegate, catalogName, resolver, true);
60+
}
61+
62+
/**
63+
* Wraps a delegate connector, optionally installing Trino-layer row-visibility enforcement
64+
* and the page-source bbox cheap-reject.
65+
*
66+
* @param delegate the underlying iceberg connector
67+
* @param catalogName the Trino catalog name; may be null when no resolver.
68+
* @param resolver identity→auths resolver; null disables Trino-layer enforcement.
69+
* @param bboxPageFilter when true, wrap the page source with the bbox cheap-reject
70+
* ({@link SpatialPageSourceProvider}).
71+
*/
72+
public SpatialConnector(Connector delegate, String catalogName, AuthorizationResolver resolver,
73+
boolean bboxPageFilter) {
5874
this.delegate = delegate;
5975
this.geomCatalog = new GeoMesaColumnCatalog();
6076
this.accessControl = resolver == null ? null
6177
: new VisibilityAccessControl(catalogName, geomCatalog, resolver);
78+
this.bboxPageFilter = bboxPageFilter;
6279
}
6380

6481
/**
@@ -125,7 +142,8 @@ public ConnectorSplitManager getSplitManager() {
125142
*/
126143
@Override
127144
public ConnectorPageSourceProvider getPageSourceProvider() {
128-
return delegate.getPageSourceProvider();
145+
ConnectorPageSourceProvider p = delegate.getPageSourceProvider();
146+
return bboxPageFilter ? new SpatialPageSourceProvider(p) : p;
129147
}
130148

131149
/**
@@ -135,7 +153,10 @@ public ConnectorPageSourceProvider getPageSourceProvider() {
135153
*/
136154
@Override
137155
public ConnectorPageSourceProviderFactory getPageSourceProviderFactory() {
138-
return delegate.getPageSourceProviderFactory();
156+
ConnectorPageSourceProviderFactory factory = delegate.getPageSourceProviderFactory();
157+
return bboxPageFilter
158+
? () -> new SpatialPageSourceProvider(factory.createPageSourceProvider())
159+
: factory;
139160
}
140161

141162
/**

geomesa-trino/geomesa-trino-plugin/src/main/java/org/locationtech/geomesa/trino/spatial/iceberg/connector/SpatialConnectorFactory.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ public class SpatialConnectorFactory implements ConnectorFactory {
4040
private static final String AUTH_RESOLVER = SECURITY_PREFIX + "auth-resolver";
4141
private static final String AUTH_MAPPING = SECURITY_PREFIX + "auth-mapping-file";
4242

43+
44+
/** Enables page-source bbox cheap-reject (see {@link BboxRejectingPageSource}): drops rows
45+
* whose bbox can't overlap the query envelope before their geometry WKB is decoded. Sound and
46+
* safety-net-preserving. OFF by default: its value is inversely proportional to spatial
47+
* partition quality — on a well-Z2/XZ2-partitioned table file/row-group pruning already
48+
* removes most non-matching rows, so the extra bbox-column read can outweigh the saved
49+
* decodes. Enable ({@code geomesa.spatial.bbox-page-filter=true}) for coarsely-partitioned
50+
* tables (few, large files) where row-level rejection catches what file pruning misses. */
51+
private static final String SPATIAL_PREFIX = "geomesa.spatial.";
52+
private static final String BBOX_PAGE_FILTER = SPATIAL_PREFIX + "bbox-page-filter";
53+
4354
/** Default constructor; instances are created by {@link SpatialIcebergPlugin}. */
4455
public SpatialConnectorFactory() {}
4556

@@ -65,16 +76,19 @@ public String getName() {
6576
public Connector create(String catalogName, Map<String, String> config,
6677
ConnectorContext context) {
6778
AuthorizationResolver resolver = buildResolver(config);
79+
boolean bboxPageFilter = Boolean.parseBoolean(config.getOrDefault(BBOX_PAGE_FILTER, "false"));
6880

6981
// Iceberg uses strict config validation; strip our keys so it doesn't
7082
// reject them as unused.
7183
Map<String, String> icebergConfig = new LinkedHashMap<>();
72-
config.forEach((k, v) -> { if (!k.startsWith(SECURITY_PREFIX)) icebergConfig.put(k, v); });
84+
config.forEach((k, v) -> {
85+
if (!k.startsWith(SECURITY_PREFIX) && !k.startsWith(SPATIAL_PREFIX)) icebergConfig.put(k, v);
86+
});
7387

7488
ConnectorFactory icebergFactory =
7589
new IcebergPlugin().getConnectorFactories().iterator().next();
7690
Connector icebergConnector = icebergFactory.create(catalogName, icebergConfig, context);
77-
return new SpatialConnector(icebergConnector, catalogName, resolver);
91+
return new SpatialConnector(icebergConnector, catalogName, resolver, bboxPageFilter);
7892
}
7993

8094
/** Builds the identity→auths resolver from catalog config, or null when no

0 commit comments

Comments
 (0)