Skip to content

Commit 2790c47

Browse files
Merge pull request #108 from sirixdb/claude/vectorized-source-ref-1125
Add source-document identity to the VectorizedExecutor contract
2 parents 66a0e40 + cdaf8d3 commit 2790c47

6 files changed

Lines changed: 701 additions & 5 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* [New BSD License]
3+
* Copyright (c) 2011-2012, Brackit Project Team <info@brackit.org>
4+
* All rights reserved.
5+
*/
6+
package io.brackit.query.compiler.optimizer;
7+
8+
/**
9+
* Immutable identity of the document a vectorized scan reads from, lifted from the loop variable's
10+
* source expression by {@link io.brackit.query.compiler.optimizer.walker.topdown.VectorizedGroupByDetection}
11+
* and carried on the annotated {@code PipeExpr} via {@link VectorizedScanAnnotation#SOURCE_REF}.
12+
*
13+
* <p>{@link VectorizedScanAnnotation#SOURCE_PATH_PREFIX} tells an executor <em>which path</em> inside
14+
* a document a scan walks, but never <em>which document</em> it dereferences. A {@link VectorizedExecutor}
15+
* is typically bound to a single physical resource/revision (e.g. SirixDB's
16+
* {@code SirixVectorizedExecutor}), so a same-shaped query over a <em>different</em> document would be
17+
* answered with the bound resource's data — wrong results. This ref closes that gap: the executor
18+
* inspects it in {@link VectorizedExecutor#acceptsSource(SourceRef)} and declines a scan it is not
19+
* bound to serve, so the translator falls back to the generic (always-correct) pipeline.
20+
*
21+
* <p>Three kinds, matching what the detection can prove from the AST:
22+
* <ul>
23+
* <li>{@link Kind#DOCUMENT} — the scan opens a concrete {@code jn:doc}/{@code jn:open} with literal
24+
* database/resource arguments; {@link #databaseName()}, {@link #resourceName()} and {@link #revision()}
25+
* are populated ({@code revision == } {@link #LATEST_REVISION} when the call names no explicit revision,
26+
* i.e. it opens the most-recent one).</li>
27+
* <li>{@link Kind#CONTEXT_ITEM} — the scan ranges over the query's context item (the caller's own bound
28+
* read transaction); no database/resource is named.</li>
29+
* <li>{@link Kind#UNKNOWN} — the source could not be proven to be a single concrete document (a dynamic
30+
* {@code jn:doc}, a collection/multi-revision opener, an unresolved variable, or a non-document
31+
* source). A resource-bound executor should fail closed on this.</li>
32+
* </ul>
33+
*/
34+
public final class SourceRef {
35+
36+
/** Sentinel {@link #revision()} value: the source names no explicit revision (opens the latest). */
37+
public static final int LATEST_REVISION = -1;
38+
39+
/** What the optimizer could prove about the scan's source document. */
40+
public enum Kind {
41+
DOCUMENT, CONTEXT_ITEM, UNKNOWN
42+
}
43+
44+
private static final SourceRef CONTEXT_ITEM = new SourceRef(Kind.CONTEXT_ITEM, null, null, LATEST_REVISION);
45+
private static final SourceRef UNKNOWN = new SourceRef(Kind.UNKNOWN, null, null, LATEST_REVISION);
46+
47+
private final Kind kind;
48+
private final String databaseName;
49+
private final String resourceName;
50+
private final int revision;
51+
52+
private SourceRef(final Kind kind, final String databaseName, final String resourceName, final int revision) {
53+
this.kind = kind;
54+
this.databaseName = databaseName;
55+
this.resourceName = resourceName;
56+
this.revision = revision;
57+
}
58+
59+
/**
60+
* A concrete document scan.
61+
*
62+
* @param databaseName the literal database name (must not be {@code null})
63+
* @param resourceName the literal resource name (must not be {@code null})
64+
* @param revision the explicit revision, or {@link #LATEST_REVISION} when the source opens the
65+
* most-recent revision
66+
*/
67+
public static SourceRef document(final String databaseName, final String resourceName, final int revision) {
68+
if (databaseName == null || resourceName == null) {
69+
throw new IllegalArgumentException("databaseName and resourceName must not be null");
70+
}
71+
return new SourceRef(Kind.DOCUMENT, databaseName, resourceName, revision);
72+
}
73+
74+
/** The query's context item — the caller's own bound read transaction. */
75+
public static SourceRef contextItem() {
76+
return CONTEXT_ITEM;
77+
}
78+
79+
/** An unprovable / non-single-document source; resource-bound executors should fail closed. */
80+
public static SourceRef unknown() {
81+
return UNKNOWN;
82+
}
83+
84+
public Kind kind() {
85+
return kind;
86+
}
87+
88+
/** {@code true} iff this ref opens a concrete literal document. */
89+
public boolean isDocument() {
90+
return kind == Kind.DOCUMENT;
91+
}
92+
93+
/** {@code true} iff this ref is the query's context item. */
94+
public boolean isContextItem() {
95+
return kind == Kind.CONTEXT_ITEM;
96+
}
97+
98+
/** The literal database name for a {@link Kind#DOCUMENT} ref, else {@code null}. */
99+
public String databaseName() {
100+
return databaseName;
101+
}
102+
103+
/** The literal resource name for a {@link Kind#DOCUMENT} ref, else {@code null}. */
104+
public String resourceName() {
105+
return resourceName;
106+
}
107+
108+
/**
109+
* The explicit revision of a {@link Kind#DOCUMENT} ref, or {@link #LATEST_REVISION} when it opens the
110+
* most-recent revision. Always {@link #LATEST_REVISION} for the other kinds.
111+
*/
112+
public int revision() {
113+
return revision;
114+
}
115+
116+
/** {@code true} iff a {@link Kind#DOCUMENT} ref names no explicit revision (opens the latest). */
117+
public boolean opensLatestRevision() {
118+
return revision == LATEST_REVISION;
119+
}
120+
121+
@Override
122+
public String toString() {
123+
return switch (kind) {
124+
case DOCUMENT -> "SourceRef[doc " + databaseName + "/" + resourceName + (revision == LATEST_REVISION
125+
? ""
126+
: "@" + revision) + "]";
127+
case CONTEXT_ITEM -> "SourceRef[context-item]";
128+
case UNKNOWN -> "SourceRef[unknown]";
129+
};
130+
}
131+
}

src/main/java/io/brackit/query/compiler/optimizer/VectorizedExecutor.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,31 @@ default Sequence executePredicateAggregate(QueryContext ctx, String[] sourcePath
172172

173173
/** Check if this executor can handle the current query context. */
174174
boolean canExecute(QueryContext ctx);
175+
176+
/**
177+
* Whether this executor may serve a scan over the given source document.
178+
*
179+
* <p>{@code sourcePath} tells an executor which path inside a document a scan walks, but never which
180+
* document. An executor bound to a single physical resource/revision (e.g. SirixDB's
181+
* {@code SirixVectorizedExecutor}) would otherwise answer a same-shaped query over a <em>different</em>
182+
* document from its own columns — wrong results. The optimizer therefore lifts the scan's source
183+
* identity into a {@link SourceRef} (see {@link VectorizedScanAnnotation#SOURCE_REF}) and asks here,
184+
* at TRANSLATE time, before substituting the vectorized expression.
185+
*
186+
* <p>Returning {@code false} is not an error: the translator simply builds the generic (always-correct)
187+
* pipeline instead, so declining only ever costs the fast path. A resource-bound executor should fail
188+
* closed — accept {@link SourceRef.Kind#DOCUMENT} refs that match its binding (and the query's
189+
* {@link SourceRef.Kind#CONTEXT_ITEM}, the caller's own transaction), and decline everything else,
190+
* including {@link SourceRef.Kind#UNKNOWN}.
191+
*
192+
* <p>The default accepts every source — correct for executors that are not bound to one resource
193+
* (e.g. bjq's file-backed {@code ParallelGroupByExec}), so the added contract is opt-in and does not
194+
* change their behaviour.
195+
*
196+
* @param source the scan's source identity; never {@code null} when the optimizer annotated the scan
197+
* @return {@code true} to allow vectorized serving of this source, {@code false} to fall back
198+
*/
199+
default boolean acceptsSource(SourceRef source) {
200+
return true;
201+
}
175202
}

src/main/java/io/brackit/query/compiler/optimizer/VectorizedScanAnnotation.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ public final class VectorizedScanAnnotation {
7979
*/
8080
public static final String SOURCE_PATH_PREFIX = "VECTORIZED_SOURCE_PATH_PREFIX";
8181

82+
/**
83+
* Identity of the document the scan reads from. Value is a {@link SourceRef}. Where
84+
* {@link #SOURCE_PATH_PREFIX} captures <em>which path</em> a scan walks, this captures <em>which
85+
* document</em> it dereferences — the concrete {@code jn:doc}/{@code jn:open} resource/revision, the
86+
* query's context item, or {@link SourceRef.Kind#UNKNOWN} when identity can't be proven.
87+
*
88+
* <p>Set on every vectorizable {@code PipeExpr} the walker annotates. The translator hands it to
89+
* {@link VectorizedExecutor#acceptsSource(SourceRef)} so a resource-bound executor can decline a scan
90+
* over a document it is not bound to — falling back to the generic pipeline rather than answering with
91+
* the wrong resource's data. Executors that are not resource-bound (the default) accept every source.
92+
*/
93+
public static final String SOURCE_REF = "VECTORIZED_SOURCE_REF";
94+
8295
// ---- Order-by ----
8396
/** Order field name (String). */
8497
public static final String ORDER_FIELD = "VECTORIZED_ORDER_FIELD";

src/main/java/io/brackit/query/compiler/optimizer/walker/topdown/VectorizedGroupByDetection.java

Lines changed: 142 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@
99
import io.brackit.query.compiler.AST;
1010
import io.brackit.query.compiler.XQ;
1111
import io.brackit.query.compiler.optimizer.PredicateNode;
12+
import io.brackit.query.compiler.optimizer.SourceRef;
1213
import io.brackit.query.compiler.optimizer.Stage;
1314
import io.brackit.query.compiler.optimizer.VectorizedScanAnnotation;
15+
import io.brackit.query.function.json.JSONFun;
1416
import io.brackit.query.module.StaticContext;
1517

1618
import java.util.ArrayList;
19+
import java.util.HashMap;
20+
import java.util.HashSet;
1721
import java.util.List;
22+
import java.util.Map;
23+
import java.util.Set;
1824

1925
/**
2026
* Optimizer stage that detects FLWOR patterns eligible for vectorized execution.
@@ -43,26 +49,34 @@
4349
*/
4450
public final class VectorizedGroupByDetection implements Stage {
4551

52+
/** Guard against pathological ASTs when resolving a scan source through variable bindings. */
53+
private static final int MAX_UNWRAP_STEPS = 64;
54+
4655
@Override
4756
public AST rewrite(StaticContext sctx, AST ast) {
48-
walkAndAnnotate(ast);
57+
// Resolve a scan's source document (a `for $u in $doc[]` reaches its `jn:doc(...)` only through the
58+
// `let $doc := ...` binding), so collect every visible for/let binding once up front and thread the
59+
// map into the per-PipeExpr annotation.
60+
final Map<Object, AST> variableBindings = new HashMap<>();
61+
collectVariableBindings(ast, variableBindings);
62+
walkAndAnnotate(ast, variableBindings);
4963
return ast;
5064
}
5165

52-
private void walkAndAnnotate(AST node) {
66+
private void walkAndAnnotate(AST node, Map<Object, AST> variableBindings) {
5367
if (node == null)
5468
return;
5569
if (node.getType() == XQ.PipeExpr) {
56-
tryAnnotate(node);
70+
tryAnnotate(node, variableBindings);
5771
}
5872
for (int i = 0; i < node.getChildCount(); i++) {
59-
walkAndAnnotate(node.getChild(i));
73+
walkAndAnnotate(node.getChild(i), variableBindings);
6074
}
6175
}
6276

6377
// ==================== Main pattern matcher ====================
6478

65-
private void tryAnnotate(AST pipeExpr) {
79+
private void tryAnnotate(AST pipeExpr, Map<Object, AST> variableBindings) {
6680
if (pipeExpr.getChildCount() < 1)
6781
return;
6882
AST chain = pipeExpr.getChild(0);
@@ -237,6 +251,11 @@ private void tryAnnotate(AST pipeExpr) {
237251
if (sourcePath != null) {
238252
pipeExpr.setProperty(VectorizedScanAnnotation.SOURCE_PATH_PREFIX, sourcePath);
239253
}
254+
// Document identity of the scan source. Set unconditionally (the translator only consults it once
255+
// a vectorized claim exists) so a resource-bound executor can decline a scan over a document it is
256+
// not bound to — see VectorizedExecutor#acceptsSource.
257+
pipeExpr.setProperty(VectorizedScanAnnotation.SOURCE_REF,
258+
resolveSourceRef(forBind.getChild(1), variableBindings));
240259
}
241260

242261
// Sorted scan emits FULL RECORDS sorted by ONE direct `$loopVar.field` key — only
@@ -564,6 +583,124 @@ private static String qnmLocalName(final Object value) {
564583
return null;
565584
}
566585

586+
// ==================== Source-document identity extraction ====================
587+
588+
/**
589+
* Collect every {@link XQ#ForBind}/{@link XQ#LetBind} binding in the tree into {@code out}, keyed by
590+
* the declared variable's QNm. First (outermost) binding wins on shadowing — a heuristic that only
591+
* ever costs precision (a misresolved source yields {@link SourceRef#unknown()}, which fails closed).
592+
*/
593+
private static void collectVariableBindings(final AST node, final Map<Object, AST> out) {
594+
if ((node.getType() == XQ.ForBind || node.getType() == XQ.LetBind) && node.getChildCount() >= 2) {
595+
final Object varKey = bindingVariableKey(node.getChild(0));
596+
if (varKey != null) {
597+
out.putIfAbsent(varKey, node.getChild(1));
598+
}
599+
}
600+
for (int i = 0, n = node.getChildCount(); i < n; i++) {
601+
collectVariableBindings(node.getChild(i), out);
602+
}
603+
}
604+
605+
/**
606+
* The variable QNm bound by a {@code For}/{@code LetBind}'s first child (a
607+
* {@link XQ#TypedVariableBinding} whose own first child, the {@code Variable}, carries the QNm that a
608+
* {@link XQ#VariableRef} later resolves against). Falls back to the node's own value defensively.
609+
*/
610+
private static Object bindingVariableKey(final AST typedVariableBinding) {
611+
if (typedVariableBinding.getChildCount() > 0) {
612+
return typedVariableBinding.getChild(0).getValue();
613+
}
614+
return typedVariableBinding.getValue();
615+
}
616+
617+
/**
618+
* Resolve a loop variable's source expression down to the document it reads from, following
619+
* deref/array/filter layers and variable bindings, and classify it as a {@link SourceRef}. Never
620+
* {@code null}: an unresolvable, dynamic, cyclic, collection, or non-document source resolves to
621+
* {@link SourceRef#unknown()} so a resource-bound executor fails closed.
622+
*/
623+
private SourceRef resolveSourceRef(final AST binding, final Map<Object, AST> variableBindings) {
624+
final Set<Object> resolvingVars = new HashSet<>(4);
625+
AST current = binding;
626+
for (int step = 0; current != null && step < MAX_UNWRAP_STEPS; step++) {
627+
switch (current.getType()) {
628+
case XQ.DerefExpr, XQ.ArrayAccess, XQ.FilterExpr -> {
629+
if (current.getChildCount() < 1) {
630+
return SourceRef.unknown();
631+
}
632+
current = current.getChild(0);
633+
}
634+
case XQ.VariableRef -> {
635+
final Object varKey = current.getValue();
636+
if (varKey == null || !resolvingVars.add(varKey)) {
637+
return SourceRef.unknown(); // unresolved or cyclic — cannot prove a single document
638+
}
639+
final AST resolved = variableBindings.get(varKey);
640+
if (resolved == null) {
641+
return SourceRef.unknown(); // a for-loop / outer variable, not a document binding
642+
}
643+
current = resolved;
644+
}
645+
case XQ.ContextItemExpr -> {
646+
return SourceRef.contextItem(); // the caller's own bound read transaction
647+
}
648+
case XQ.FunctionCall -> {
649+
return functionCallSourceRef(current);
650+
}
651+
default -> {
652+
return SourceRef.unknown();
653+
}
654+
}
655+
}
656+
return SourceRef.unknown();
657+
}
658+
659+
/**
660+
* Classify a {@link XQ#FunctionCall} scan source. A {@code jn:doc}/{@code jn:open} with literal
661+
* database and resource arguments (and, if present, a literal integer revision) yields a concrete
662+
* {@link SourceRef#document}; a dynamic argument, any other {@code jn:} opener (collection /
663+
* multi-revision — it spans more than one resource/revision), or a non-JSON function yields
664+
* {@link SourceRef#unknown()}.
665+
*/
666+
private SourceRef functionCallSourceRef(final AST call) {
667+
if (!(call.getValue() instanceof QNm qnm) || !JSONFun.JSON_NSURI.equals(qnm.getNamespaceURI())) {
668+
return SourceRef.unknown();
669+
}
670+
final String local = qnm.getLocalName();
671+
if (!"doc".equals(local) && !"open".equals(local)) {
672+
return SourceRef.unknown();
673+
}
674+
if (call.getChildCount() < 2) {
675+
return SourceRef.unknown();
676+
}
677+
final String databaseName = stringLiteralValue(call.getChild(0));
678+
final String resourceName = stringLiteralValue(call.getChild(1));
679+
if (databaseName == null || resourceName == null) {
680+
return SourceRef.unknown(); // dynamic (non-literal) database/resource — unprovable
681+
}
682+
if (call.getChildCount() == 2) {
683+
return SourceRef.document(databaseName, resourceName, SourceRef.LATEST_REVISION);
684+
}
685+
final Integer revision = literalRevision(call.getChild(2));
686+
if (revision == null) {
687+
return SourceRef.unknown(); // dynamic revision — unprovable
688+
}
689+
return SourceRef.document(databaseName, resourceName, revision);
690+
}
691+
692+
/** The exact int of a literal integer revision argument; {@code null} for anything non-literal/lossy. */
693+
private static Integer literalRevision(final AST node) {
694+
if (node == null || node.getType() != XQ.Int) {
695+
return null;
696+
}
697+
final Long lv = exactLongOf(node.getValue());
698+
if (lv == null || lv < Integer.MIN_VALUE || lv > Integer.MAX_VALUE) {
699+
return null;
700+
}
701+
return lv.intValue();
702+
}
703+
567704
// ==================== Generic predicate-tree extraction ====================
568705

569706
/**

0 commit comments

Comments
 (0)