diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/EngineBridge.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/EngineBridge.java new file mode 100644 index 0000000000000..4318374c1efe7 --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/EngineBridge.java @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.backend; + +/** + * JNI boundary interface between the query planner (Java) and a native + * execution engine (e.g., DataFusion/Rust). + * + *

The bridge has two responsibilities: + *

    + *
  1. {@link #convertFragment} — serialise a logical plan fragment into + * the engine's wire format (e.g., Substrait bytes).
  2. + *
  3. {@link #execute} — hand the serialised plan to the native engine + * and obtain an opaque handle to the result stream that lives + * entirely in native memory.
  4. + *
+ * + *

Arrow data never crosses the JNI boundary into the JVM heap. + * Consumers read from the native stream via Arrow Flight or + * direct native-memory access using the returned handle. + * + * @param serialised plan type (e.g., {@code byte[]} for Substrait) + * @param result stream handle + * @param > logical plan type (e.g., Calcite {@code RelNode}) + * @opensearch.internal + */ +public interface EngineBridge { + + /** + * Initializes this bridge with shard-level execution context. + * + *

Called once per shard before any {@link #execute} calls. Back-end + * plugins that need shard-level resources (Lucene readers, query + * contexts, etc.) should acquire and cache them here for reuse + * across multiple {@code execute} calls within the same shard. + * + *

The default implementation is a no-op, suitable for engines + * that do not need shard-level initialization (e.g., native engines + * with their own resource management). + * + * @param context shard execution context provided by the analytics engine + */ + default void initialize(ShardExecutionContext context) {} + + /** + * Converts a logical plan fragment into the native engine's serialised + * format. + * + * @param fragment the logical plan subtree to serialise + * @return the serialised plan in the engine's wire format + */ + Fragment convertFragment(LogicalPlan fragment); + + /** + * Submits the serialised plan to the native engine for execution and + * returns an opaque handle to the result stream. + * + *

The returned handle is a pointer into native memory (e.g., a + * {@code long} address of a Rust {@code RecordBatchStream}). The + * caller must eventually close the stream through a corresponding + * native call to avoid leaking resources. + * + * @param fragment the serialised plan produced by {@link #convertFragment} + * @return an opaque handle to the native result stream + */ + Stream execute(Fragment fragment); + + /** + * Releases shard-level resources acquired during {@link #initialize}. + * + *

Called once after all {@code execute} calls for a shard are + * complete. Back-end plugins should release any cached readers, + * searchers, or contexts here. + * + *

The default implementation is a no-op. + */ + default void close() {} +} diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ShardExecutionContext.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ShardExecutionContext.java new file mode 100644 index 0000000000000..1b00daf881f8f --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ShardExecutionContext.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.backend; + +/** + * Opaque context representing a shard-level execution environment. + * + *

The analytics engine creates an instance of this context for each + * shard being queried and passes it to back-end plugins via + * {@link EngineBridge#initialize(ShardExecutionContext)}. Back-end + * plugins that need shard-level resources (e.g., Lucene readers, + * query contexts) downcast to the concrete implementation provided + * by the engine. + * + *

This interface lives in {@code analytics-framework} (a {@code libs} + * module) and intentionally does not reference server types like + * {@code IndexShard} or {@code QueryShardContext}. Concrete + * implementations that wrap those types live in the engine plugin. + * + * @opensearch.internal + */ +public interface ShardExecutionContext { + + /** + * Returns the shard identifier (index name + shard number) for + * logging and diagnostics. + */ + String shardId(); +} diff --git a/sandbox/plugins/analytics-backend-lucene/DESIGN.md b/sandbox/plugins/analytics-backend-lucene/DESIGN.md new file mode 100644 index 0000000000000..9810c43072a6b --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/DESIGN.md @@ -0,0 +1,493 @@ +# Lucene Backend Plugin — Design + +## Scope + +This document covers the design of the Lucene Backend Plugin (`analytics-backend-lucene`) for the analytics query engine. In scope: + +- Plugin registration and SPI integration with the analytics engine +- Planner-side predicate identification, validation, and conversion (RexNode → QueryBuilder → byte[]) +- Executor-side shard initialization, Lucene query execution, and BitSet result packaging +- Segment-level and batch-level execution aligned with DataFusion's Parquet processing +- Extensible `PredicateHandler` registry for adding new predicate types +- Weight caching, resource lifecycle management, and fail-fast validation +- Custom `LUCENE_DELEGATED` operator for in-place plan annotation (design only, not yet implemented) + +Out of scope (upstream dependencies): + +- `DefaultPlanExecutor` predicate splitting and lifecycle orchestration +- `LUCENE_DELEGATED` custom SqlOperator implementation +- BitSet handoff mechanism to DataFusion +- Multi-shard coordination and fan-out from coordinator +- Wiring `ClusterService`/`IndicesService` into the executor + +## Overview + +DataFusion handles columnar scans, numeric filtering, and aggregations over Parquet DocValues. Lucene handles text-search predicates (EQUALS, LIKE on keyword fields) against inverted indices. The coordinator identifies Lucene-compatible predicates and serializes them. The data node executes them against local shards and returns a BitSet for DataFusion to intersect with its scan results. + +--- + +## Planner vs Executor + +The Lucene Backend Plugin participates in two distinct phases with different roles: + +- **Planner phase** (coordinator): identifies Lucene-eligible predicates, converts RexNode → QueryBuilder → serialized bytes. No shard context. Pure logical transformation. This is `convertFragment`. +- **Executor phase** (data node): receives serialized bytes, initializes shard resources, executes Lucene queries, returns BitSet. Requires IndexShard, Engine.Searcher, QueryShardContext. This is `initialize` / `execute` / `close`. + +Both phases use `LuceneEngineBridge` but through different methods: + +| Phase | Role | Bridge Method | Runs On | Needs Shard Context | +|----------|-----------------------------------------------|---------------------|-------------|---------------------| +| Planner | Identify eligible predicates | `canHandle()` | Coordinator | No (uses cluster state + Calcite types) | +| Planner | Convert RexNode → QueryBuilder → byte[] | `convertFragment()` | Coordinator | No | +| Executor | Acquire shard resources (searcher, QSC) | `initialize()` | Data Node | Yes | +| Executor | Deserialize → Lucene Query → search → BitSet | `execute()` | Data Node | Yes (cached) | +| Executor | Release searcher, allocator | `close()` | Data Node | Yes (cleanup) | + +### Planner Validation: Fail Fast + +The planner runs on the coordinator which has access to `ClusterState` → `IndexMetadata` → `MappingMetadata`. This means the planner can validate at planning time — before any bytes are sent to data nodes: + +- **Field existence**: does the field exist in the index mapping? +- **Field type**: is it `keyword` or `text` (has Lucene inverted index) vs `integer`/`long`/`date` (no inverted index for text search)? +- **Index existence**: does the target index exist at all? + +Currently `canHandle` checks the Calcite `SqlTypeName` (VARCHAR vs INTEGER) as a proxy. This catches obvious mismatches (numeric field routed to Lucene) but can't distinguish `keyword` from `text` from `ip` since all map to VARCHAR. + +For full fail-fast validation, the planner should check the actual OpenSearch field type from cluster state before routing to LBP. The recommended approach: + +``` +Planner validation chain (coordinator): + 1. Planner checks ClusterState → MappingMetadata → field type + → "verb" is "keyword" → has inverted index → Lucene-eligible + → "status" is "integer" → no inverted index → DataFusion only + 2. PredicateHandlerRegistry.canHandle(call, rowType) + → secondary check: operand shape, Calcite type + 3. luceneBridge.convertFragment(...) + → conversion only happens after both checks pass +``` + +This ensures errors like "field doesn't exist" or "field is numeric, not text" are caught at planning time on the coordinator, not at execution time on the data node. + +**Current state of upstream access:** `AnalyticsPlugin` has `ClusterService` and passes it to `DefaultEngineContext`. However, `DefaultPlanExecutor` does not currently receive `ClusterService` or `EngineContext` — it only gets `List`. For the planner to do mapping-level validation, `DefaultPlanExecutor` needs `ClusterService` (or `MappingMetadata`) wired into it. This is an upstream change in `analytics-engine`. + +--- + +## Example: Plan Transformation and Execution + +### Query + +```sql +SELECT verb, status FROM logs WHERE verb = 'GET' AND status > 200 AND path LIKE '/api%' +``` + +### Step 1: Calcite Logical Plan (before planner traversal) + +``` +LogicalProject(verb=[$0], status=[$2]) + LogicalFilter(condition=[AND( + =($0, 'GET'), ← RexCall(EQUALS, [RexInputRef#0, RexLiteral('GET')]) + >($2, 200), ← RexCall(GREATER_THAN, [RexInputRef#2, RexLiteral(200)]) + LIKE($1, '/api%') ← RexCall(LIKE, [RexInputRef#1, RexLiteral('/api%')]) + )]) + LogicalTableScan(table=[[logs]]) + +Field types from schema (via OpenSearchSchemaBuilder): + $0: verb → VARCHAR (keyword in OS) + $1: path → VARCHAR (keyword in OS) + $2: status → INTEGER (integer in OS) +``` + +### Step 2: Planner Traversal (coordinator) + +The planner walks each RexNode child of the AND condition. For each, it checks the cluster state mapping first, then `canHandle`: + +``` +RexCall(EQUALS, verb, 'GET'): + ClusterState → MappingMetadata → verb is "keyword" → has inverted index ✓ + PredicateHandlerRegistry.canHandle(call, rowType)? + → EqualsPredicateHandler: SqlKind=EQUALS ✓, operands=FieldRef+Literal ✓, field type=VARCHAR ✓ + → YES → Lucene + +RexCall(GREATER_THAN, status, 200): + ClusterState → MappingMetadata → status is "integer" → no inverted index ✗ + → NO → DataFusion (skips canHandle entirely) + +RexCall(LIKE, path, '/api%'): + ClusterState → MappingMetadata → path is "keyword" → has inverted index ✓ + PredicateHandlerRegistry.canHandle(call, rowType)? + → LikePredicateHandler: SqlKind=LIKE ✓, operands=FieldRef+Literal ✓, field type=VARCHAR ✓ + → YES → Lucene +``` + +### Step 3: Annotated Plan (after planner traversal) + +The planner replaces Lucene-eligible RexNodes in-place with a custom `LUCENE_DELEGATED` operator. This keeps the plan self-contained — no side-channel fragment lists. The custom operator carries the original operands (for explain/debugging) plus the serialized `byte[]` payload. + +``` +LogicalProject(verb=[$0], status=[$2]) + LogicalFilter(condition=[AND( + LUCENE_DELEGATED($0, 'GET', byte[]{TermQueryBuilder("verb","GET")}), + >($2, 200), + LUCENE_DELEGATED($1, '/api%', byte[]{PrefixQueryBuilder("path","/api")}) + )]) + LogicalTableScan(table=[[logs]]) +``` + +This is still a valid Calcite plan — `LUCENE_DELEGATED` is a custom `SqlOperator` registered in the framework. The plan can be serialized, transported, and explained like any Calcite plan. The `byte[]` payload is the output of `luceneBridge.convertFragment`. + +Why in-place replacement over a side-channel: +- Plan is self-contained — no separate `delegated_fragments` list to keep in sync +- Boolean structure preserved — `AND(LUCENE_DELEGATED, DF_predicate)` keeps correlation +- Calcite plan serialization/transport works as-is +- Plan explain/toString shows exactly what was delegated and where +- Executor walks the RexNode tree naturally — `LUCENE_DELEGATED` → execute in Lucene, regular operator → DataFusion + +Note: `LUCENE_DELEGATED` is an upstream concept — it would live in `analytics-framework` or `analytics-engine`, not in LBP. LBP's role stays the same: `convertFragment` produces the `byte[]` payload, `execute` consumes it. + +### Step 4: Data Node Execution + +``` +Executor receives the plan with LUCENE_DELEGATED operators in-place. + +1. Initialize Lucene bridge: + luceneBridge.initialize(shardCtx) + → acquires Engine.Searcher (cached) + → creates QueryShardContext (cached) + +2. DataFusion processes Parquet DocValues segment-by-segment. + For each segment (segOrd), Parquet is decompressed in batches (startDocId → endDocId). + For each batch, the executor calls Lucene for the matching doc range: + + Segment 0 (maxDoc=5000): + Batch 0: startDocId=0, endDocId=1024 + luceneBridge.executeForSegment(termQB_bytes, segOrd=0, start=0, end=1024) + → BitSet for docs 0-1023 in segment 0 + luceneBridge.executeForSegment(prefixQB_bytes, segOrd=0, start=0, end=1024) + → BitSet for docs 0-1023 in segment 0 + Intersect → pass to DataFusion for status > 200 on this batch + + Batch 1: startDocId=1024, endDocId=2048 + luceneBridge.executeForSegment(termQB_bytes, segOrd=0, start=1024, end=2048) + ... + + Segment 1 (maxDoc=3000): + Batch 0: startDocId=0, endDocId=1024 + luceneBridge.executeForSegment(termQB_bytes, segOrd=1, start=0, end=1024) + ... + +3. Helper methods for DataFusion to drive iteration: + luceneBridge.getSegmentCount() → number of segments + luceneBridge.getSegmentMaxDoc(segOrd) → maxDoc for batch boundary calculation + +4. Close: + luceneBridge.close() + → releases Engine.Searcher, Arrow allocator +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ COORDINATOR NODE — PLANNER PHASE │ +│ │ +│ Front-end (PPL/SQL) → Calcite RelNode tree │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Planner / Delegation Decider │ │ +│ │ │ │ +│ │ Walk RexNode condition tree │ │ +│ │ │ │ │ +│ │ ├── For each predicate: │ │ +│ │ │ 1. Check ClusterState → MappingMetadata → OS field type │ │ +│ │ │ 2. Check PredicateHandlerRegistry.canHandle(call, rowType, mapperService) +│ │ │ → validates SqlKind + operand shape + SqlTypeName │ │ +│ │ │ │ │ +│ │ ├── Text field + supported operator → Lucene-eligible │ │ +│ │ └── Non-text field or unsupported operator → DataFusion │ │ +│ │ │ │ +│ │ luceneBridge.convertFragment(LogicalFilter wrapping EQUALS) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ LuceneEngineBridge.convertFragment (PLANNER) │ │ │ +│ │ │ │ │ │ +│ │ │ RexToQueryBuilderConverter(rowType, mapperService) │ │ │ +│ │ │ → PredicateHandlerRegistry.getHandler(EQUALS) │ │ │ +│ │ │ → handler.canHandle(call, rowType, mapperService) │ │ │ +│ │ │ → handler.convert(call, rowType, mapperService) │ │ │ +│ │ │ → QueryBuilder │ │ │ +│ │ │ │ │ │ +│ │ │ QueryBuilderSerializer.serialize() │ │ │ +│ │ │ → byte[] (NamedWriteable format) │ │ │ +│ │ │ │ │ │ +│ │ │ MapperService available on coordinator via │ │ │ +│ │ │ IndicesService → IndexService → mapperService(). │ │ │ +│ │ │ No IndexShard or Engine.Searcher needed. │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Planner output: single Calcite plan with LUCENE_DELEGATED custom │ +│ RexNodes replacing Lucene-eligible predicates → sent to data node │ +└────────────────────────────┬────────────────────────────────────────────┘ + │ Modified Calcite Plan + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ DATA NODE — EXECUTOR PHASE │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ DefaultPlanExecutor (analytics-engine) │ │ +│ │ │ │ +│ │ shardCtx = new DefaultShardExecutionContext(indexShard, idxSvc) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ luceneBridge.initialize(shardCtx) │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ LuceneEngineBridge.initialize (EXECUTOR) │ │ │ +│ │ │ │ │ │ +│ │ │ engineSearcher = indexShard.acquireSearcher(...) │ │ │ +│ │ │ queryShardContext = indexService.newQueryShardContext(...) │ │ │ +│ │ │ (both cached for lifetime of shard execution) │ │ │ +│ │ │ (on partial failure: searcher closed before re-throw) │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Walk plan RexNode tree, for each LUCENE_DELEGATED node: │ │ +│ │ luceneBridge.execute(byte[]) ← full shard, or: │ │ +│ │ luceneBridge.executeForSegment(byte[], segOrd, start, end) │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ LuceneEngineBridge.execute / executeForSegment │ │ │ +│ │ │ │ │ │ +│ │ │ getOrCreateWeight(bytes): (cached per unique fragment) │ │ │ +│ │ │ first call: deserialize → toQuery → rewrite → Weight │ │ │ +│ │ │ subsequent: returns cached Weight │ │ │ +│ │ │ │ │ │ +│ │ │ execute(): all segments → global FixedBitSet │ │ │ +│ │ │ executeForSegment(): single segment + doc range │ │ │ +│ │ │ → scorer.advance(startDocId) │ │ │ +│ │ │ → iterate until endDocId → scoped FixedBitSet │ │ │ +│ │ │ │ │ │ +│ │ │ FixedBitSet → Arrow BitVector → VectorSchemaRoot │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Intersect Lucene BitSets, pass to DataFusion with remaining │ │ +│ │ predicates for Parquet scan │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ luceneBridge.close() │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ LuceneEngineBridge.close (EXECUTOR — releases resources) │ │ │ +│ │ │ engineSearcher.close(), allocator.close() │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────┐ ┌──────────────────────┐ │ +│ │ Lucene Indices │ │ Parquet DocValues │ │ +│ │ (inverted idx) │ │ (columnar storage) │ │ +│ └────────────────┘ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Components + +### Framework (`analytics-framework`) — shared contracts, no server dependency + +| Interface | Role | +|----------------------------------------------------|-------------------------------------------------------------------------------------| +| `SearchExecEngine` | `prepare(ctx)` → `execute(ctx)` → `close()`. Shard-level execution engine. | +| `ExecutionContext` | Carries reader, task, and table name through execution lifecycle. | +| `EngineResultStream` / `EngineResultBatch` | Result stream of record batches returned by engine execution. | +| `EngineBridge` | Planner-side: `convertFragment(plan)`. Executor-side: `initialize`/`execute`/`close`.| +| `ShardExecutionContext` | Opaque shard context marker. Backends downcast to concrete type. | +| `AnalyticsSearchBackendPlugin` | SPI: extends `SearchExecEngineProvider`. `name()`, `createSearchExecEngine(ctx)`. | + +### Analytics Engine (`analytics-engine`) — orchestrator + +| Component | Role | +|--------------------------------|-----------------------------------------------------------------------------------------| +| `AnalyticsPlugin` | Discovers backends via SPI, aggregates operator tables, creates executor | +| `DefaultPlanExecutor` | Receives RelNode + context. Splits predicates, delegates to backends. (Currently stub.) | +| `DefaultShardExecutionContext` | Wraps `IndexShard` + `IndexService`. Provides `acquireSearcher()` and `createQueryShardContext()`. | + +### Lucene Backend (`analytics-backend-lucene`) + +The plugin has two SPI entry points and is organized into three layers: plugin registration, query execution infrastructure, and predicate conversion. + +#### SPI Entry Points + +The plugin registers through two separate SPI interfaces serving different upstream consumers: + +| Entry Point | SPI Interface | Purpose | +|------------------------------|----------------------------------|------------------------------------------------------------| +| `LuceneSearchEnginePlugin` | `SearchBackEndPlugin` | Reader management. Creates `LuceneReaderManager` for `DirectoryReader` lifecycle per `CatalogSnapshot`. Used by the shard engine for index reader acquisition. | +| `LuceneBackendPlugin` | `AnalyticsSearchBackendPlugin` | Analytics query execution. Creates `LuceneSearchExecEngine` via `createSearchExecEngine(ctx)`. Used by the analytics engine for predicate delegation. | + +#### Query Execution Infrastructure (data node) + +These components handle shard-level Lucene query execution. `LuceneSearchExecEngine` orchestrates through `LuceneEngineBridge`, which delegates segment-level scoring to `LuceneIndexFilterProvider`. + +| Component | Owner | Role | +|------------------------------|----------|------------------------------------------------------------------------------------------| +| `LuceneSearchExecEngine` | Ours | Single entry point for analytics search. Implements `SearchExecEngine`. Orchestrates bridge lifecycle. | +| `LuceneEngineBridge` | Ours | Core bridge. Planner: `convertFragment` (RexNode → byte[]). Executor: `initialize`/`execute`/`executeForSegment`/`close`. Delegates scoring to `LuceneIndexFilterProvider`. Packages results as Arrow BitVector. | +| `LuceneIndexFilterProvider` | Upstream | Per-segment scoring: Weight → Scorer → `DocIdSetIterator` → `long[]` bitset. Manages collector lifecycle. Our bridge delegates here. | +| `LuceneIndexFilterContext` | Upstream | Per-(query, reader) context. Caches `Weight`, exposes segment count/maxDoc. | +| `LuceneSearchEnginePlugin` | Upstream | `SearchBackEndPlugin` SPI entry point. Creates `LuceneReaderManager` for `DirectoryReader` lifecycle. | +| `LuceneReaderManager` | Upstream | Manages `DirectoryReader` per `CatalogSnapshot`. Used by shard engine for reader acquisition. | + +#### Predicate Conversion (coordinator + data node) + +These components handle RexNode → QueryBuilder conversion and serialization. All ours — upstream doesn't have Calcite integration. + +| Component | Role | +|------------------------------|------------------------------------------------------------------------------------------| +| `PredicateHandler` | Extension point interface: SqlKind + SqlOperator + `canHandle` + `convert` + serialization entries. | +| `PredicateHandlerRegistry` | Single registration point for all handlers. Drives converter and serializer. | +| `EqualsPredicateHandler` | `field = 'val'` → `TermQueryBuilder`. Validates VARCHAR/CHAR field type via `canHandle`. | +| `LikePredicateHandler` | `field LIKE 'pat%'` → `PrefixQueryBuilder` / `WildcardQueryBuilder`. Validates VARCHAR/CHAR. | +| `RexToQueryBuilderConverter` | Dispatches `RexCall` to handler via registry. Validates via `canHandle` before `convert`. Accepts optional `MapperService` for field type validation. | +| `QueryBuilderSerializer` | Round-trip `QueryBuilder` ↔ `byte[]` via `NamedWriteableRegistry`. Registry entries derived from handlers. This is the transport format between coordinator and data node. | + +### DataFusion Backend (`analytics-backend-datafusion`) + +| Component | Role | +|--------------------|------------------------------------------------------------------------------------------| +| `DataFusionBridge` | `EngineBridge`. Substrait → native Rust. Ignores `initialize`/`close`. | + +--- + +## Upstream Dependencies (What LBP Needs) + +| From | What | Phase | Used In | +|--------------------------------|-------------------------------------------------------------|----------|----------------------------------------| +| `ClusterState` (via planner) | `IndexMetadata` → `MappingMetadata` → OS field types | Planner | Fail-fast validation (field exists, is keyword/text) | +| `IndicesService` (via planner) | `IndexService` → `MapperService` → `MappedFieldType` | Planner | Field type validation in `canHandle`/`convert` | +| Planner | Predicate splitting + `canHandle` checks | Planner | Before `convertFragment()` | +| `DefaultShardExecutionContext` | `IndexShard.acquireSearcher()` → `Engine.Searcher` | Executor | `initialize()` | +| `DefaultShardExecutionContext` | `IndexService.newQueryShardContext()` → `QueryShardContext` | Executor | `initialize()` | +| `QueryShardContext` | `fieldMapper(name)` → `MappedFieldType` | Executor | `execute()` via `toQuery()` | +| `DefaultPlanExecutor` | Lifecycle orchestration: `initialize` → N × `execute` → `close` | Executor | Per shard | + +## Downstream Output (What LBP Provides) + +| To | What | Format | +|-----------------------|-----------------------|----------------------------------------------------------------------------------| +| DataFusion / Executor | Matching doc IDs | `Iterator` with `doc_ids` BitVector. Bit i=1 if doc i matched. | +| Planner | Predicate eligibility | `PredicateHandlerRegistry.canHandle(call, rowType, mapperService)` → boolean | + +--- + +## Extensibility: Adding New Predicate Types + +Each `PredicateHandler` bundles: what SQL operator it handles, how to convert RexCall → QueryBuilder, and what serialization entries are needed. Adding a new predicate = one new class + one registry line. + +``` +PredicateHandlerRegistry + │ + ├── RexToQueryBuilderConverter: registry.getHandler(kind).convert(call, rowType, mapperService) + └── QueryBuilderSerializer: new NamedWriteableRegistry(registry.allNamedWriteableEntries()) +``` + +Example — adding range queries: + +1. Create `RangePredicateHandler implements PredicateHandler` +2. Add to `PredicateHandlerRegistry.HANDLERS` +3. Done. No other files change. + +Current handlers: + +| Handler | SQL | QueryBuilder | +|--------------------------|---------------------|-----------------------------------------------| +| `EqualsPredicateHandler` | `field = 'val'` | `TermQueryBuilder` | +| `LikePredicateHandler` | `field LIKE 'pat%'` | `PrefixQueryBuilder` / `WildcardQueryBuilder` | + +--- + +## Supported Predicates + +The plugin currently supports text-search predicates on keyword/text fields. Each predicate shape maps to a specific Lucene QueryBuilder type. New shapes are added by implementing `PredicateHandler` — see the Extensibility section. + +| SQL Shape | Field Type | Lucene QueryBuilder | Status | +|------------------------------|------------------|--------------------------|-------------| +| `field = 'value'` | keyword, text | `TermQueryBuilder` | Implemented | +| `field LIKE 'prefix%'` | keyword, text | `PrefixQueryBuilder` | Implemented | +| `field LIKE '%pattern%'` | keyword, text | `WildcardQueryBuilder` | Implemented | +| `field LIKE '_attern'` | keyword, text | `WildcardQueryBuilder` | Implemented | +| `A AND B` (both Lucene) | keyword, text | `BoolQueryBuilder(must)` | Planned | +| `A OR B` (both Lucene) | keyword, text | `BoolQueryBuilder(should)` | Planned | +| `NOT A` | keyword, text | `BoolQueryBuilder(mustNot)` | Planned | +| `field > value` (range) | keyword, numeric | `RangeQueryBuilder` | Planned | +| `field RLIKE 'pattern'` | keyword, text | `RegexpQueryBuilder` | Planned | +| `field IN ('a','b','c')` | keyword, text | `TermsQueryBuilder` | Planned | + +Planned predicates will be added incrementally as new `PredicateHandler` implementations. The `PredicateHandlerRegistry` pattern ensures each addition is a single-file change with no modifications to existing components. + +--- + +## Boolean Expression Delegation + +For compound RexNode predicates mixing Lucene and DataFusion expressions like `(L1 AND D1) OR (L2 AND D2)`: + +- **Option A**: Push entire boolean to Lucene if all leaves are Lucene-compatible. Produces a single `BoolQueryBuilder`, single BitSet. Most efficient when applicable. +- **Option B**: Execute per branch — `Lucene(L1) ∩ DF(D1)` ∪ `Lucene(L2) ∩ DF(D2)`. Correct for all cases including OR-of-ANDs, uses multiple `execute()` calls (supported by cached searcher). + +For simple AND (`L1 AND D1`), splitting into separate Lucene/DF groups and intersecting is correct and sufficient. For OR-of-ANDs, per-branch execution (Option B) is needed to preserve the AND correlation between Lucene and DataFusion predicates within each branch. The cached searcher design (acquire once, execute N times) supports this naturally. + +--- + +## Relationship to Core Search Path + +The plugin is a thin bridge over core — it reuses, not duplicates: + +| Plugin Owns (new code) | Reuses from Core (no duplication) | +|------------------------------------|--------------------------------------------------------| +| RexNode → QueryBuilder conversion | QueryBuilder hierarchy (TermQB, WildcardQB, etc.) | +| PredicateHandler registry | `QueryBuilder.toQuery(QueryShardContext)` | +| BitSet → Arrow BitVector packaging | `Engine.Searcher` / `IndexSearcher` | +| EngineBridge lifecycle | `QueryShardContext`, `MapperService`, `NamedWriteable` | + +### Migration Path + +``` +Phase 1 (now): Text predicates (EQUALS, LIKE). Minimal overlap with core. +Phase 2 (next): Range, regex, bool. Each = new PredicateHandler. No structural changes. +Phase 3: Aggregations. Lucene collectors via EngineBridge. Parallels AggregationPhase. +Phase 4: Full query execution. Plugin = primary Lucene path for analytics. + SearchService remains for REST _search backward compat. +``` + +### Future Evolution Areas + +| Area | Current | Future | +|---------------|----------------------|------------------------------------------| +| Result format | BitVector only | Full Arrow RecordBatch with field values | +| Aggregations | Not supported | Collector-based execution | +| Scoring | `COMPLETE_NO_SCORES` | Relevance scores when needed | +| Caching | No query cache | Participate in `IndicesQueryCache` | + +All additive — current design doesn't preclude any of them. + +--- + +## Design Constraints + +- **Single-threaded per bridge instance.** `engineSearcher` and `queryShardContext` are mutable fields with no synchronization. One bridge per shard, single-threaded access. +- **`convertFragment` expects `LogicalFilter`.** The planner must wrap Lucene-eligible RexNode predicates in a `LogicalFilter` before calling `convertFragment`. +- **`canHandle` gates `convert`.** Handlers validate field types via Calcite `SqlTypeName` (VARCHAR/CHAR only) and accept `MapperService` (nullable) for future OpenSearch-level validation. Always check `canHandle` before `convert`. +- **Weight is cached per query fragment.** `toQuery` → `rewrite` → `createWeight` runs once per unique `byte[]` fragment. Subsequent `executeForSegment` calls for the same query reuse the cached `Weight`. Cache is cleared on `close()`. +- **Lucene predicates must have literal values known at planning time.** `convertFragment` runs on the coordinator and converts `field = 'literal'` into a serialized `QueryBuilder`. If the comparison value comes from a subquery or join (e.g., `verb = (SELECT ...)` or `logs.verb = users.name`), it's not available at planning time and cannot be delegated to Lucene. Late-binding values would require `convertFragment` to run at execution time on the data node after the dependent value is resolved — a pattern the current design does not support. For now, only predicates with constant literals are Lucene-eligible. +- **`LUCENE_DELEGATED` operators are resolved by the executor, not DataFusion.** The executor walks the plan, executes all `LUCENE_DELEGATED` nodes via `luceneBridge.execute`, and replaces them with BitSet results before passing the plan to DataFusion. DataFusion never sees the custom operator — it only receives a pre-computed BitSet filter alongside its own predicates. + +--- + +## Open Items + +1. **DefaultPlanExecutor** — stub. Needs predicate splitting and lifecycle orchestration. +2. **BitSet handoff to DataFusion** — mechanism for passing BitVector for intersection TBD. +3. **Multi-shard coordination** — current design is per-shard. +4. **BoolQueryBuilder** — AND/OR/NOT handler not yet implemented. diff --git a/sandbox/plugins/analytics-backend-lucene/build.gradle b/sandbox/plugins/analytics-backend-lucene/build.gradle index 42426fb8888e7..8ab72a98731c5 100644 --- a/sandbox/plugins/analytics-backend-lucene/build.gradle +++ b/sandbox/plugins/analytics-backend-lucene/build.gradle @@ -6,25 +6,87 @@ * compatible open source license. */ +def calciteVersion = '1.41.0' + apply plugin: 'opensearch.internal-cluster-test' opensearchplugin { - description = 'OpenSearch plugin providing Lucene-based search execution engine' - classname = 'org.opensearch.lucene.LuceneSearchEnginePlugin' + description = 'OpenSearch plugin providing Lucene-based search execution engine' + classname = 'org.opensearch.be.lucene.LuceneSearchEnginePlugin' + extendedPlugins = ['analytics-engine'] } dependencies { - // Shared types and SPI interfaces (EngineBridge, AnalyticsBackEndPlugin, etc.) - // Also provides calcite-core transitively via api. - api project(':sandbox:libs:analytics-framework') + // Shared types and SPI interfaces (EngineBridge, AnalyticsBackEndPlugin, etc.) + // Also provides calcite-core transitively via api. + api project(':sandbox:libs:analytics-framework') + // Analytics engine plugin (for DefaultShardExecutionContext at compile time; available at runtime via extendedPlugins) + compileOnly project(':sandbox:plugins:analytics-engine') + // Arrow Flight streaming transport (for BufferAllocator, VectorSchemaRoot) + api project(':plugins:arrow-flight-rpc') + + // Calcite (needed for RexNode, RelNode, SqlStdOperatorTable at compile time) + compileOnly "org.apache.calcite:calcite-core:${calciteVersion}" + + // Logging + implementation "org.apache.logging.log4j:log4j-api:${versions.log4j}" + implementation "org.apache.logging.log4j:log4j-core:${versions.log4j}" + + // Annotation dependencies (needed to suppress -Werror on Arrow/Calcite annotation warnings) + compileOnly 'org.checkerframework:checker-qual:3.43.0' + compileOnly 'org.apiguardian:apiguardian-api:1.1.2' + + // jqwik for property-based tests + testImplementation 'net.jqwik:jqwik-api:1.9.1' + testRuntimeOnly 'net.jqwik:jqwik-engine:1.9.1' + + // JUnit Platform launcher (required by Gradle for useJUnitPlatform()) + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.1' + + // AssertJ for fluent assertions in property tests + testImplementation 'org.assertj:assertj-core:3.26.3' - implementation "org.apache.logging.log4j:log4j-api:${versions.log4j}" - implementation "org.apache.logging.log4j:log4j-core:${versions.log4j}" + // Arrow memory implementation needed at test runtime for RootAllocator + testRuntimeOnly "org.apache.arrow:arrow-memory-unsafe:${versions.arrow}" + + // Calcite at test time for building RexNode/RelNode fixtures + testImplementation "org.apache.calcite:calcite-core:${calciteVersion}" + // Calcite transitive deps needed at test runtime + testImplementation "org.apache.calcite:calcite-linq4j:${calciteVersion}" + testImplementation "org.apache.calcite.avatica:avatica-core:1.27.0" + testRuntimeOnly "org.jooq:joou-java-6:0.9.4" + testRuntimeOnly "com.google.guava:guava:${versions.guava}" + testRuntimeOnly "com.google.guava:failureaccess:1.0.2" + testRuntimeOnly "com.jayway.jsonpath:json-path:2.9.0" + testRuntimeOnly "org.apache.commons:commons-math3:3.6.1" + testRuntimeOnly "commons-codec:commons-codec:${versions.commonscodec}" + + // Annotation dependencies for test compilation + testCompileOnly 'org.checkerframework:checker-qual:3.43.0' + testCompileOnly 'org.apiguardian:apiguardian-api:1.1.2' + + // Mockito for mocking IndexShard, QueryShardContext, etc. + testImplementation "org.mockito:mockito-core:${versions.mockito}" + testRuntimeOnly "net.bytebuddy:byte-buddy:${versions.bytebuddy}" + testRuntimeOnly "org.objenesis:objenesis:${versions.objenesis}" } -test { - systemProperty 'tests.security.manager', 'false' +configurations.all { + resolutionStrategy { + force 'com.google.guava:guava:33.4.0-jre' + force 'com.google.guava:failureaccess:1.0.2' + force 'com.google.errorprone:error_prone_annotations:2.36.0' + force 'org.checkerframework:checker-qual:3.43.0' + force "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" + force "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" + force "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson_annotations}" + force "org.slf4j:slf4j-api:${versions.slf4j}" + } } -// TODO: Remove once back-end is built out with test suite -testingConventions.enabled = false +test { + useJUnitPlatform() + systemProperty 'tests.security.manager', 'false' + jvmArgs '--add-opens=java.base/java.nio=ALL-UNNAMED' + jvmArgs '-Darrow.memory.debug.allocator=false' +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneBackendPlugin.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneBackendPlugin.java new file mode 100644 index 0000000000000..50cbf9abf2766 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneBackendPlugin.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import org.opensearch.analytics.backend.EngineResultStream; +import org.opensearch.analytics.backend.ExecutionContext; +import org.opensearch.analytics.backend.SearchExecEngine; +import org.opensearch.analytics.spi.AnalyticsSearchBackendPlugin; +import org.opensearch.be.lucene.predicate.PredicateHandlerRegistry; +import org.opensearch.plugins.Plugin; + +/** + * Lucene text-search backend plugin for the analytics query engine. + * + *

Registers as an {@link AnalyticsSearchBackendPlugin} providing Lucene-based + * predicate execution. Supported operators are derived from {@link PredicateHandlerRegistry}. + */ +public class LuceneBackendPlugin extends Plugin implements AnalyticsSearchBackendPlugin { + + @Override + public String name() { + return "lucene"; + } + + @Override + public SearchExecEngine createSearchExecEngine(ExecutionContext ctx) { + return new LuceneSearchExecEngine(); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneEngineSearcher.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneEngineSearcher.java deleted file mode 100644 index ed3c792be16af..0000000000000 --- a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneEngineSearcher.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - */ - -package org.opensearch.be.lucene; - -import org.apache.lucene.index.DirectoryReader; -import org.apache.lucene.index.LeafReaderContext; -import org.apache.lucene.search.IndexSearcher; -import org.apache.lucene.search.Query; -import org.apache.lucene.search.ScoreMode; -import org.apache.lucene.search.Weight; -import org.opensearch.common.annotation.ExperimentalApi; -import org.opensearch.index.engine.exec.EngineSearcher; - -import java.io.IOException; -import java.util.List; - -/** - * Lucene-backed engine searcher. - *

- * This class is stateless with respect to active queries - * - * @opensearch.experimental - */ -@ExperimentalApi -public class LuceneEngineSearcher implements EngineSearcher { - - private final IndexSearcher indexSearcher; - private final DirectoryReader directoryReader; - - /** - * Creates a new LuceneEngineSearcher. - * - * @param indexSearcher the Lucene index searcher - * @param directoryReader the Lucene directory reader - */ - public LuceneEngineSearcher(IndexSearcher indexSearcher, DirectoryReader directoryReader) { - this.indexSearcher = indexSearcher; - this.directoryReader = directoryReader; - } - - /** - * Execute: create a Weight from the query, register it on the - * context's lifecycle manager, and store the key + segment metadata - * on the context for JNI callbacks. - * - * @param context the search context containing the query to execute - */ - @Override - public void search(LuceneSearchContext context) throws IOException { - Query query = context.getQuery(); - if (query == null) { - throw new IllegalStateException("No query set on LuceneSearchContext"); - } - Query rewritten = indexSearcher.rewrite(query); - Weight weight = indexSearcher.createWeight(rewritten, ScoreMode.COMPLETE_NO_SCORES, 1.0f); - List leaves = directoryReader.leaves(); - // TODO : Complete the wiring for search execution - - } - - /** Returns the underlying IndexSearcher. */ - public IndexSearcher getIndexSearcher() { - return indexSearcher; - } - - /** Returns the underlying DirectoryReader. */ - public DirectoryReader getDirectoryReader() { - return directoryReader; - } - - @Override - public void close() {} -} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFilterExecutor.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFilterExecutor.java new file mode 100644 index 0000000000000..0db77e067280a --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFilterExecutor.java @@ -0,0 +1,281 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BitVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexNode; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.search.Query; +import org.opensearch.analytics.backend.EngineBridge; +import org.opensearch.analytics.backend.ShardExecutionContext; +import org.opensearch.analytics.exec.DefaultShardExecutionContext; +import org.opensearch.be.lucene.predicate.QueryBuilderSerializer; +import org.opensearch.be.lucene.predicate.RexToQueryBuilderConverter; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.mapper.MapperService; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.QueryShardContext; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Lucene EngineBridge implementation. + *

+ * Planner side ({@link #convertFragment}): RexNode → QueryBuilder → byte[]. + * Executor side: delegates Lucene query execution to {@link LuceneIndexFilterProvider} + * for Weight creation, per-segment scoring, and doc range collection. + * Packages results as Arrow BitVector. + */ +public class LuceneFilterExecutor implements EngineBridge, RelNode> { + + /** Column name for the document ID bitset in the result schema. */ + public static final String DOC_IDS_COLUMN = "doc_ids"; + + private static final Schema DOC_IDS_SCHEMA = new Schema( + List.of(Field.nullable(DOC_IDS_COLUMN, new ArrowType.Bool())) + ); + + // Cached shard-level resources + private Engine.Searcher engineSearcher; + private QueryShardContext queryShardContext; + private BufferAllocator allocator; + + // Upstream execution provider — handles Weight, Scorer, doc collection + private final LuceneIndexFilterProvider filterProvider = new LuceneIndexFilterProvider(); + // Cached filter contexts per query fragment + private final Map contextCache = new HashMap<>(); + + @Override + public void initialize(ShardExecutionContext context) { + if (context instanceof DefaultShardExecutionContext shardCtx == false) { + throw new IllegalArgumentException( + "LuceneFilterExecutor requires DefaultShardExecutionContext, got: " + + (context == null ? "null" : context.getClass().getSimpleName()) + ); + } + DefaultShardExecutionContext shardCtx = (DefaultShardExecutionContext) context; + Engine.Searcher searcher = shardCtx.indexShard().acquireSearcher("lucene-analytics"); + try { + this.queryShardContext = shardCtx.createQueryShardContext(searcher); + this.engineSearcher = searcher; + } catch (Exception e) { + searcher.close(); + throw e; + } + } + + @Override + public void close() { + // Release cached collectors + for (var entry : contextCache.entrySet()) { + LuceneIndexFilterContext ctx = entry.getValue(); + ctx.close(); // releases all collectors via CollectorQueryLifecycleManager + } + collectorCache.clear(); + contextCache.clear(); + if (engineSearcher != null) { + engineSearcher.close(); + engineSearcher = null; + queryShardContext = null; + } + if (allocator != null) { + allocator.close(); + allocator = null; + } + } + + private BufferAllocator getAllocator() { + if (allocator == null) { + allocator = new RootAllocator(); + } + return allocator; + } + + /** + * Gets or creates a LuceneIndexFilterContext for the given fragment. + * Converts byte[] → QueryBuilder → Lucene Query, then delegates to + * upstream LuceneIndexFilterProvider for Weight creation. + */ + private LuceneIndexFilterContext getOrCreateContext(byte[] fragment) throws IOException { + ByteBuffer key = ByteBuffer.wrap(fragment); + LuceneIndexFilterContext cached = contextCache.get(key); + if (cached != null) { + return cached; + } + + QueryBuilder queryBuilder = QueryBuilderSerializer.deserialize(fragment); + Query query = queryBuilder.toQuery(queryShardContext); + DirectoryReader directoryReader = engineSearcher.getDirectoryReader(); + LuceneIndexFilterContext ctx = filterProvider.createContext(query, directoryReader); + contextCache.put(key, ctx); + return ctx; + } + + // --- Planner side --- + + @Override + public byte[] convertFragment(RelNode fragment) { + return convertFragment(fragment, null); + } + + public byte[] convertFragment(RelNode fragment, MapperService mapperService) { + Objects.requireNonNull(fragment, "RelNode fragment must not be null"); + if (!(fragment instanceof LogicalFilter)) { + throw new IllegalArgumentException( + "Lucene backend expects a LogicalFilter, got: " + fragment.getClass().getSimpleName() + ); + } + LogicalFilter filter = (LogicalFilter) fragment; + RexNode condition = filter.getCondition(); + RelDataType inputRowType = filter.getInput().getRowType(); + RexToQueryBuilderConverter converter = new RexToQueryBuilderConverter(inputRowType, mapperService); + QueryBuilder queryBuilder = converter.convert(condition); + return QueryBuilderSerializer.serialize(queryBuilder); + } + + // --- Executor side --- + + @Override + public Iterator execute(byte[] fragment) { + if (fragment == null || fragment.length == 0) { + throw new IllegalArgumentException("Fragment byte array must not be null or empty"); + } + // Validate bytes are well-formed + QueryBuilderSerializer.deserialize(fragment); + if (engineSearcher == null) { + return createEmptyResult(); + } + return executeAllSegments(fragment); + } + + // Cached collector keys per (fragment, segmentOrd) — reuses Scorer across batch calls + private final Map collectorCache = new HashMap<>(); + + private static long collectorCacheKey(ByteBuffer fragmentKey, int segmentOrd) { + return ((long) fragmentKey.hashCode() << 32) | (segmentOrd & 0xFFFFFFFFL); + } + + /** + * Executes for a specific segment and doc ID range. + * Reuses the Scorer across sequential batch calls within the same segment. + */ + public Iterator executeForSegment(byte[] fragment, int segmentOrd, int startDocId, int endDocId) { + if (fragment == null || fragment.length == 0) { + throw new IllegalArgumentException("Fragment byte array must not be null or empty"); + } + if (engineSearcher == null) { + return createEmptyResult(); + } + try { + LuceneIndexFilterContext ctx = getOrCreateContext(fragment); + ByteBuffer fragmentKey = ByteBuffer.wrap(fragment); + long cacheKey = collectorCacheKey(fragmentKey, segmentOrd); + + Integer collectorKey = collectorCache.get(cacheKey); + if (collectorKey == null) { + collectorKey = filterProvider.createCollector(ctx, segmentOrd, startDocId, endDocId); + collectorCache.put(cacheKey, collectorKey); + } + + long[] bits = filterProvider.collectDocs(ctx, collectorKey, startDocId, endDocId); + return createResultFromLongArray(bits, endDocId - startDocId); + } catch (IOException e) { + throw new IllegalArgumentException("Failed during Lucene segment query execution: " + e.getMessage(), e); + } + } + + public int getSegmentCount() { + if (engineSearcher == null) return 0; + return engineSearcher.getIndexReader().leaves().size(); + } + + public int getSegmentMaxDoc(int segmentOrd) { + if (engineSearcher == null) return 0; + var leaves = engineSearcher.getIndexReader().leaves(); + if (segmentOrd < 0 || segmentOrd >= leaves.size()) { + throw new IllegalArgumentException("Invalid segment ordinal: " + segmentOrd); + } + return leaves.get(segmentOrd).reader().maxDoc(); + } + + // --- Result packaging --- + + private Iterator executeAllSegments(byte[] fragment) { + try { + LuceneIndexFilterContext ctx = getOrCreateContext(fragment); + int totalMaxDoc = 0; + for (int i = 0; i < ctx.segmentCount(); i++) { + totalMaxDoc += ctx.segmentMaxDoc(i); + } + + java.util.BitSet globalBitSet = new java.util.BitSet(totalMaxDoc); + int docBase = 0; + for (int seg = 0; seg < ctx.segmentCount(); seg++) { + int segMaxDoc = ctx.segmentMaxDoc(seg); + int collectorKey = filterProvider.createCollector(ctx, seg, 0, segMaxDoc); + long[] bits = filterProvider.collectDocs(ctx, collectorKey, 0, segMaxDoc); + filterProvider.releaseCollector(ctx, collectorKey); + + java.util.BitSet segBits = java.util.BitSet.valueOf(bits); + for (int doc = segBits.nextSetBit(0); doc >= 0; doc = segBits.nextSetBit(doc + 1)) { + globalBitSet.set(docBase + doc); + } + docBase += segMaxDoc; + } + + return createResultFromJavaBitSet(globalBitSet, totalMaxDoc); + } catch (IOException e) { + throw new IllegalArgumentException("Failed during Lucene query execution: " + e.getMessage(), e); + } + } + + private Iterator createEmptyResult() { + BufferAllocator alloc = getAllocator(); + VectorSchemaRoot root = VectorSchemaRoot.create(DOC_IDS_SCHEMA, alloc); + BitVector docIds = (BitVector) root.getVector(DOC_IDS_COLUMN); + docIds.allocateNew(0); + docIds.setValueCount(0); + root.setRowCount(0); + return Collections.singletonList(root).iterator(); + } + + private Iterator createResultFromLongArray(long[] bits, int rangeSize) { + java.util.BitSet bitSet = java.util.BitSet.valueOf(bits); + return createResultFromJavaBitSet(bitSet, rangeSize); + } + + private Iterator createResultFromJavaBitSet(java.util.BitSet bitSet, int totalDocs) { + BufferAllocator alloc = getAllocator(); + VectorSchemaRoot root = VectorSchemaRoot.create(DOC_IDS_SCHEMA, alloc); + BitVector docIds = (BitVector) root.getVector(DOC_IDS_COLUMN); + docIds.allocateNew(totalDocs); + for (int i = 0; i < totalDocs; i++) { + docIds.setSafe(i, bitSet.get(i) ? 1 : 0); + } + docIds.setValueCount(totalDocs); + root.setRowCount(totalDocs); + return Collections.singletonList(root).iterator(); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneSearchContext.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneSearchContext.java deleted file mode 100644 index 2dee8508d3ee5..0000000000000 --- a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneSearchContext.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - */ - -package org.opensearch.be.lucene; - -import org.apache.lucene.index.DirectoryReader; -import org.apache.lucene.search.IndexSearcher; -import org.apache.lucene.search.Query; -import org.opensearch.action.search.SearchShardTask; -import org.opensearch.common.annotation.ExperimentalApi; -import org.opensearch.search.SearchExecutionContext; - -import java.io.IOException; - -/** - * Lucene-specific search execution context. - * - * @opensearch.experimental - */ -@ExperimentalApi -public class LuceneSearchContext implements SearchExecutionContext { - - private final SearchShardTask task; - private final DirectoryReader reader; - private final LuceneEngineSearcher searcher; - private Query query; - - /** - * Creates a new LuceneSearchContext. - * - * @param task the search shard task - * @param reader the directory reader over the index - * @param query the Lucene query to execute - */ - public LuceneSearchContext(SearchShardTask task, DirectoryReader reader, Query query) throws IOException { - this.reader = reader; - IndexSearcher indexSearcher = new IndexSearcher(reader); - this.searcher = new LuceneEngineSearcher(indexSearcher, reader); - this.task = task; - this.query = query; - } - - /** Returns the current query. */ - public Query getQuery() { - return query; - } - - @Override - public SearchShardTask task() { - return task; - } - - @Override - public LuceneEngineSearcher getSearcher() { - return searcher; - } - - /** - * Sets the query for this context. - * - * @param query the Lucene query to set - */ - public void setQuery(Query query) { - this.query = query; - } - - @Override - public void close() throws IOException { - searcher.close(); - } -} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneSearchExecEngine.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneSearchExecEngine.java new file mode 100644 index 0000000000000..dfb1e4b446792 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneSearchExecEngine.java @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import org.opensearch.analytics.backend.EngineResultStream; +import org.opensearch.analytics.backend.ExecutionContext; +import org.opensearch.analytics.backend.SearchExecEngine; + +import java.io.IOException; + +/** + * Lucene implementation of {@link SearchExecEngine}. + * + *

Bridges the upstream execution interface to the Lucene search path: + *

+ * + *

This is the single search execution path for Lucene in the analytics engine. + * {@link LuceneIndexFilterProvider} handles per-segment collection; + * this class orchestrates them through {@link LuceneFilterExecutor}. + */ +public class LuceneSearchExecEngine implements SearchExecEngine { + + private final LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + + /** + * Returns the bridge for direct access to planner-side methods + * ({@link LuceneFilterExecutor#convertFragment}) and segment-level + * execution ({@link LuceneFilterExecutor#executeForSegment}). + */ + public LuceneFilterExecutor getBridge() { + return bridge; + } + + @Override + public void prepare(ExecutionContext context) { + // The DirectoryReader will be extracted from ExecutionContext.getReader() + // using the Lucene DataFormat when the full execution path is wired. + // For now, the bridge can be initialized separately via getBridge().initialize() + // when a DefaultShardExecutionContext is available. + } + + @Override + public EngineResultStream execute(ExecutionContext context) throws IOException { + // TODO: extract delegated Lucene predicates (LUCENE_DELEGATED byte[] payloads) + // from the execution context, run them through the bridge using the + // directoryReader acquired in prepare(), return as EngineResultStream. + // Blocked on: QueryShardContext availability + predicate payload in context. + return null; + } + + @Override + public void close() throws IOException { + bridge.close(); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/EqualsPredicateHandler.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/EqualsPredicateHandler.java new file mode 100644 index 0000000000000..dd594853593e3 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/EqualsPredicateHandler.java @@ -0,0 +1,94 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene.predicate; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexInputRef; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.index.mapper.MapperService; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermQueryBuilder; + +import java.util.List; + +/** + * Handles text equality predicates: {@code field = 'value'} → {@link TermQueryBuilder}. + */ +public class EqualsPredicateHandler implements PredicateHandler { + + @Override + public SqlKind sqlKind() { + return SqlKind.EQUALS; + } + + @Override + public SqlOperator sqlOperator() { + return SqlStdOperatorTable.EQUALS; + } + + @Override + public boolean canHandle(RexCall call, RelDataType inputRowType, MapperService mapperService) { + if (call.getKind() != SqlKind.EQUALS) { + return false; + } + RexNode left = call.getOperands().get(0); + RexNode right = call.getOperands().get(1); + + RexInputRef fieldRef; + if (left instanceof RexInputRef && right instanceof RexLiteral) { + fieldRef = (RexInputRef) left; + } else if (left instanceof RexLiteral && right instanceof RexInputRef) { + fieldRef = (RexInputRef) right; + } else { + return false; + } + + SqlTypeName fieldType = inputRowType.getFieldList().get(fieldRef.getIndex()).getType().getSqlTypeName(); + return fieldType == SqlTypeName.VARCHAR || fieldType == SqlTypeName.CHAR; + } + + @Override + public QueryBuilder convert(RexCall call, RelDataType inputRowType, MapperService mapperService) { + RexNode left = call.getOperands().get(0); + RexNode right = call.getOperands().get(1); + + RexInputRef fieldRef; + RexLiteral literal; + if (left instanceof RexInputRef && right instanceof RexLiteral) { + fieldRef = (RexInputRef) left; + literal = (RexLiteral) right; + } else if (left instanceof RexLiteral && right instanceof RexInputRef) { + fieldRef = (RexInputRef) right; + literal = (RexLiteral) left; + } else { + throw new IllegalArgumentException( + "EQUALS operands must be a field reference and a literal, got: " + + left.getClass().getSimpleName() + ", " + right.getClass().getSimpleName() + ); + } + + String fieldName = inputRowType.getFieldList().get(fieldRef.getIndex()).getName(); + String value = literal.getValueAs(String.class); + return new TermQueryBuilder(fieldName, value); + } + + @Override + public List namedWriteableEntries() { + return List.of( + new NamedWriteableRegistry.Entry(QueryBuilder.class, TermQueryBuilder.NAME, TermQueryBuilder::new) + ); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/LikePredicateHandler.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/LikePredicateHandler.java new file mode 100644 index 0000000000000..4b4cd3103f56d --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/LikePredicateHandler.java @@ -0,0 +1,114 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene.predicate; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexInputRef; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.index.mapper.MapperService; +import org.opensearch.index.query.PrefixQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.WildcardQueryBuilder; + +import java.util.List; + +/** + * Handles LIKE predicates: {@code field LIKE 'pattern%'} → + * {@link PrefixQueryBuilder} (trailing % only) or {@link WildcardQueryBuilder}. + */ +public class LikePredicateHandler implements PredicateHandler { + + @Override + public SqlKind sqlKind() { + return SqlKind.LIKE; + } + + @Override + public SqlOperator sqlOperator() { + return SqlStdOperatorTable.LIKE; + } + + @Override + public boolean canHandle(RexCall call, RelDataType inputRowType, MapperService mapperService) { + if (call.getKind() != SqlKind.LIKE) { + return false; + } + RexNode left = call.getOperands().get(0); + RexNode right = call.getOperands().get(1); + if (!(left instanceof RexInputRef fieldRef) || !(right instanceof RexLiteral)) { + return false; + } + + // Only handle VARCHAR fields (keyword, text, ip in OpenSearch) + SqlTypeName fieldType = inputRowType.getFieldList().get(fieldRef.getIndex()).getType().getSqlTypeName(); + return fieldType == SqlTypeName.VARCHAR || fieldType == SqlTypeName.CHAR; + } + + @Override + public QueryBuilder convert(RexCall call, RelDataType inputRowType, MapperService mapperService) { + RexNode left = call.getOperands().get(0); + RexNode right = call.getOperands().get(1); + + if (!(left instanceof RexInputRef fieldRef) || !(right instanceof RexLiteral literal)) { + throw new IllegalArgumentException( + "LIKE operands must be a field reference and a literal, got: " + + left.getClass().getSimpleName() + ", " + right.getClass().getSimpleName() + ); + } + + String fieldName = inputRowType.getFieldList().get(fieldRef.getIndex()).getName(); + String pattern = literal.getValueAs(String.class); + + if (isTrailingPercentOnly(pattern)) { + String prefix = pattern.substring(0, pattern.length() - 1); + return new PrefixQueryBuilder(fieldName, prefix); + } + + String lucenePattern = translateSqlWildcards(pattern); + return new WildcardQueryBuilder(fieldName, lucenePattern); + } + + @Override + public List namedWriteableEntries() { + return List.of( + new NamedWriteableRegistry.Entry(QueryBuilder.class, PrefixQueryBuilder.NAME, PrefixQueryBuilder::new), + new NamedWriteableRegistry.Entry(QueryBuilder.class, WildcardQueryBuilder.NAME, WildcardQueryBuilder::new) + ); + } + + public static boolean isTrailingPercentOnly(String pattern) { + if (pattern.endsWith("%") == false) { + return false; + } + String body = pattern.substring(0, pattern.length() - 1); + return body.indexOf('%') < 0 && body.indexOf('_') < 0; + } + + public static String translateSqlWildcards(String sqlPattern) { + StringBuilder sb = new StringBuilder(sqlPattern.length()); + for (int i = 0; i < sqlPattern.length(); i++) { + char c = sqlPattern.charAt(i); + if (c == '%') { + sb.append('*'); + } else if (c == '_') { + sb.append('?'); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/PredicateHandler.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/PredicateHandler.java new file mode 100644 index 0000000000000..8e30668fc4737 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/PredicateHandler.java @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene.predicate; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.index.mapper.MapperService; +import org.opensearch.index.query.QueryBuilder; + +import java.util.List; + +/** + * Extension point for adding new Lucene predicate types. + * + *

Each handler bundles three concerns that must stay in sync: + *

    + *
  1. What SQL operator it handles (for planner capability advertisement)
  2. + *
  3. How to convert a Calcite {@link RexCall} into an OpenSearch {@link QueryBuilder}
  4. + *
  5. What {@link NamedWriteableRegistry} entries are needed for serialization
  6. + *
+ * + *

To add a new Lucene predicate type (e.g., range, regex, fuzzy): + * implement this interface and register it in {@link PredicateHandlerRegistry}. + */ +public interface PredicateHandler { + + /** The Calcite {@link SqlKind} this handler processes (e.g., EQUALS, LIKE). */ + SqlKind sqlKind(); + + /** The Calcite {@link SqlOperator} to advertise in the plugin's operator table. */ + SqlOperator sqlOperator(); + + /** + * Converts a {@link RexCall} of the matching {@link #sqlKind()} into a {@link QueryBuilder}. + * + * @param call the Calcite function call to convert + * @param inputRowType the row type of the input relation (for resolving field names) + * @param mapperService the index mapper service for field type validation, or null if unavailable + * @return the corresponding QueryBuilder + * @throws IllegalArgumentException if the call has unsupported operand types + */ + QueryBuilder convert(RexCall call, RelDataType inputRowType, MapperService mapperService); + + /** + * Returns true if this handler can process the given {@link RexCall}. + * Used by the coordinator to validate predicates before conversion. + * + *

The default implementation returns true for any call matching {@link #sqlKind()}. + * Handlers that need field-type-aware validation (e.g., only keyword fields) + * should override this. + * + * @param call the Calcite function call to check + * @param inputRowType the row type of the input relation + * @param mapperService the index mapper service for field type validation, or null if unavailable + * @return true if this handler can convert the call + */ + default boolean canHandle(RexCall call, RelDataType inputRowType, MapperService mapperService) { + return call.getKind() == sqlKind(); + } + + /** + * Returns the {@link NamedWriteableRegistry} entries needed to serialize/deserialize + * the {@link QueryBuilder} types this handler produces. + */ + List namedWriteableEntries(); +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/PredicateHandlerRegistry.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/PredicateHandlerRegistry.java new file mode 100644 index 0000000000000..12704692c25f9 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/PredicateHandlerRegistry.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene.predicate; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.opensearch.be.lucene.LuceneBackendPlugin; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.index.mapper.MapperService; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Central registry of all supported {@link PredicateHandler} implementations. + * + *

This is the single place to register new Lucene predicate types. + * All downstream components ({@link RexToQueryBuilderConverter}, + * {@link QueryBuilderSerializer}, {@link LuceneBackendPlugin}) derive + * their behavior from this registry. + */ +public final class PredicateHandlerRegistry { + + private static final List HANDLERS = List.of( + new EqualsPredicateHandler(), + new LikePredicateHandler() + // To add a new predicate: implement PredicateHandler and add it here. + // Example: new RangePredicateHandler(), new RegexPredicateHandler() + ); + + private static final Map BY_KIND; + + static { + Map map = new EnumMap<>(SqlKind.class); + for (PredicateHandler handler : HANDLERS) { + PredicateHandler prev = map.put(handler.sqlKind(), handler); + if (prev != null) { + throw new IllegalStateException( + "Duplicate PredicateHandler for SqlKind." + handler.sqlKind() + ": " + prev.getClass().getSimpleName() + + " and " + handler.getClass().getSimpleName() + ); + } + } + BY_KIND = Collections.unmodifiableMap(map); + } + + private PredicateHandlerRegistry() {} + + /** Returns the handler for the given SqlKind, or null if unsupported. */ + public static PredicateHandler getHandler(SqlKind kind) { + return BY_KIND.get(kind); + } + + /** + * Returns true if a registered handler can process the given RexCall. + * Checks both SqlKind match and handler-specific validation via {@link PredicateHandler#canHandle}. + */ + public static boolean canHandle(RexCall call, RelDataType inputRowType, MapperService mapperService) { + PredicateHandler handler = BY_KIND.get(call.getKind()); + return handler != null && handler.canHandle(call, inputRowType, mapperService); + } + + /** Returns all registered handlers. */ + public static List allHandlers() { + return HANDLERS; + } + + /** Returns all SqlOperators supported by registered handlers. */ + public static List allOperators() { + return HANDLERS.stream().map(PredicateHandler::sqlOperator).collect(Collectors.toList()); + } + + /** Returns all NamedWriteableRegistry entries needed by registered handlers. */ + public static List allNamedWriteableEntries() { + return HANDLERS.stream().flatMap(h -> h.namedWriteableEntries().stream()).collect(Collectors.toList()); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/QueryBuilderSerializer.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/QueryBuilderSerializer.java new file mode 100644 index 0000000000000..6c5b22adfc254 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/QueryBuilderSerializer.java @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene.predicate; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.NamedWriteableAwareStreamInput; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.index.query.QueryBuilder; + +import java.io.IOException; + +/** + * Handles round-trip serialization of {@link QueryBuilder} to/from {@code byte[]} + * using OpenSearch's {@link NamedWriteableRegistry} infrastructure. + * + *

The registry entries are derived from {@link PredicateHandlerRegistry}, + * so adding a new predicate handler automatically makes its QueryBuilder + * types serializable — no changes needed here. + */ +public class QueryBuilderSerializer { + + private static final NamedWriteableRegistry REGISTRY = new NamedWriteableRegistry( + PredicateHandlerRegistry.allNamedWriteableEntries() + ); + + /** + * Serializes a {@link QueryBuilder} into a byte array. + * + * @param queryBuilder the query builder to serialize + * @return the serialized byte array + * @throws IllegalArgumentException if serialization fails + */ + public static byte[] serialize(QueryBuilder queryBuilder) { + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeNamedWriteable(queryBuilder); + return BytesReference.toBytes(out.bytes()); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to serialize QueryBuilder: " + e.getMessage(), e); + } + } + + /** + * Deserializes a byte array back into a {@link QueryBuilder}. + * + * @param data the byte array to deserialize + * @return the deserialized QueryBuilder + * @throws IllegalArgumentException if the byte array is null, empty, or corrupted + */ + public static QueryBuilder deserialize(byte[] data) { + if (data == null || data.length == 0) { + throw new IllegalArgumentException("Fragment byte array must not be null or empty"); + } + try (StreamInput in = new NamedWriteableAwareStreamInput(StreamInput.wrap(data), REGISTRY)) { + return in.readNamedWriteable(QueryBuilder.class); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to deserialize QueryBuilder: " + e.getMessage(), e); + } + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/RexToQueryBuilderConverter.java b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/RexToQueryBuilderConverter.java new file mode 100644 index 0000000000000..4e41091f12fa5 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/predicate/RexToQueryBuilderConverter.java @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene.predicate; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.rex.RexVisitorImpl; +import org.apache.calcite.sql.SqlKind; +import org.opensearch.index.mapper.MapperService; +import org.opensearch.index.query.QueryBuilder; + +/** + * Converts Calcite {@link RexNode} expressions into OpenSearch {@link QueryBuilder} instances. + * + *

Delegates to {@link PredicateHandler} implementations registered in + * {@link PredicateHandlerRegistry}. When a {@link MapperService} is available + * (data node, post-initialize), field type validation uses actual OpenSearch + * mappings. When null (coordinator), falls back to Calcite type checks. + */ +public class RexToQueryBuilderConverter extends RexVisitorImpl { + + private final RelDataType inputRowType; + private final MapperService mapperService; + + /** + * Coordinator-mode constructor (no MapperService available). + */ + public RexToQueryBuilderConverter(RelDataType inputRowType) { + this(inputRowType, null); + } + + /** + * Data-node-mode constructor with MapperService for field type validation. + */ + public RexToQueryBuilderConverter(RelDataType inputRowType, MapperService mapperService) { + super(true); + this.inputRowType = inputRowType; + this.mapperService = mapperService; + } + + /** + * Entry point: converts a {@link RexNode} into a {@link QueryBuilder}. + */ + public QueryBuilder convert(RexNode node) { + QueryBuilder result = node.accept(this); + if (result == null) { + throw new IllegalArgumentException("Unsupported RexNode type: " + node.getClass().getSimpleName()); + } + return result; + } + + @Override + public QueryBuilder visitCall(RexCall call) { + SqlKind kind = call.getKind(); + PredicateHandler handler = PredicateHandlerRegistry.getHandler(kind); + if (handler == null) { + throw new IllegalArgumentException("Unsupported operator: " + call.getOperator().getName()); + } + if (handler.canHandle(call, inputRowType, mapperService) == false) { + throw new IllegalArgumentException( + "Operator " + call.getOperator().getName() + " is not supported for the given field types" + ); + } + return handler.convert(call, inputRowType, mapperService); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/main/resources/META-INF/services/org.opensearch.analytics.spi.AnalyticsBackEndPlugin b/sandbox/plugins/analytics-backend-lucene/src/main/resources/META-INF/services/org.opensearch.analytics.spi.AnalyticsBackEndPlugin new file mode 100644 index 0000000000000..ab7cfc08461f9 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/main/resources/META-INF/services/org.opensearch.analytics.spi.AnalyticsBackEndPlugin @@ -0,0 +1 @@ +org.opensearch.be.lucene.LuceneBackendPlugin diff --git a/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneExecutionWiringTests.java b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneExecutionWiringTests.java new file mode 100644 index 0000000000000..3f95a2486ed47 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneExecutionWiringTests.java @@ -0,0 +1,243 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import net.jqwik.api.Example; + +import org.apache.arrow.vector.BitVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.KeywordField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.QueryCachingPolicy; +import org.apache.lucene.search.similarities.BM25Similarity; +import org.apache.lucene.store.ByteBuffersDirectory; +import org.apache.lucene.store.Directory; +import org.opensearch.analytics.exec.DefaultShardExecutionContext; +import org.opensearch.be.lucene.predicate.QueryBuilderSerializer; +import org.opensearch.index.IndexService; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.mapper.KeywordFieldMapper; +import org.opensearch.index.query.QueryShardContext; +import org.opensearch.index.query.TermQueryBuilder; +import org.opensearch.index.shard.IndexShard; +import org.opensearch.core.index.shard.ShardId; + +import java.io.IOException; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for the Lucene query execution wiring via initialize/execute/close lifecycle. + */ +class LuceneExecutionWiringTests { + + // --- Helper: in-memory Lucene index --- + + private static class InMemoryIndex implements AutoCloseable { + final Directory directory; + final DirectoryReader reader; + + InMemoryIndex(String fieldName, String... values) throws IOException { + directory = new ByteBuffersDirectory(); + IndexWriterConfig config = new IndexWriterConfig(); + try (IndexWriter writer = new IndexWriter(directory, config)) { + for (String value : values) { + Document doc = new Document(); + doc.add(new KeywordField(fieldName, value, Field.Store.NO)); + writer.addDocument(doc); + } + writer.commit(); + } + reader = DirectoryReader.open(directory); + } + + @Override + public void close() throws IOException { + reader.close(); + directory.close(); + } + } + + // --- Helper: create Engine.Searcher wrapping a DirectoryReader --- + + private static Engine.Searcher createEngineSearcher(DirectoryReader reader, AtomicBoolean closed) { + return new Engine.Searcher( + "lucene-analytics", + reader, + new BM25Similarity(), + null, + mock(QueryCachingPolicy.class), + () -> closed.set(true) + ); + } + + // --- Helper: create mocked DefaultShardExecutionContext --- + + private static DefaultShardExecutionContext createMockShardContext( + DirectoryReader reader, + AtomicBoolean searcherClosed, + String fieldName + ) { + // Mock QueryShardContext with keyword field mapping + QueryShardContext qsc = mock(QueryShardContext.class); + KeywordFieldMapper.KeywordFieldType keywordFieldType = + new KeywordFieldMapper.KeywordFieldType(fieldName); + when(qsc.fieldMapper(fieldName)).thenReturn(keywordFieldType); + + // Mock IndexShard to return our Engine.Searcher + IndexShard mockShard = mock(IndexShard.class); + when(mockShard.acquireSearcher("lucene-analytics")) + .thenReturn(createEngineSearcher(reader, searcherClosed)); + when(mockShard.shardId()).thenReturn(new ShardId("test-index", "_na_", 0)); + + // Mock IndexService to return our QueryShardContext + IndexService mockIndexService = mock(IndexService.class); + when(mockIndexService.newQueryShardContext(anyInt(), any(IndexSearcher.class), any(), nullable(String.class))) + .thenReturn(qsc); + + return new DefaultShardExecutionContext(mockShard, mockIndexService); + } + + // --- Test: execute without initialize returns empty result --- + + @Example + void executeWithoutInitializeReturnsEmptyResult() { + LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + try { + byte[] serialized = QueryBuilderSerializer.serialize(new TermQueryBuilder("verb", "GET")); + + Iterator results = bridge.execute(serialized); + + assertThat(results.hasNext()).isTrue(); + VectorSchemaRoot root = results.next(); + assertThat(root.getRowCount()).isEqualTo(0); + root.close(); + } finally { + bridge.close(); + } + } + + // --- Test: matching doc IDs appear as set bits in BitVector --- + + @Example + void executeAfterInitializeReturnsMatchingDocIds() throws IOException { + try (InMemoryIndex idx = new InMemoryIndex("verb", "GET", "POST", "GET", "PUT")) { + AtomicBoolean searcherClosed = new AtomicBoolean(false); + DefaultShardExecutionContext ctx = createMockShardContext(idx.reader, searcherClosed, "verb"); + + LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + bridge.initialize(ctx); + try { + byte[] serialized = QueryBuilderSerializer.serialize(new TermQueryBuilder("verb", "GET")); + Iterator results = bridge.execute(serialized); + + assertThat(results.hasNext()).isTrue(); + VectorSchemaRoot root = results.next(); + assertThat(root.getRowCount()).isEqualTo(4); + + BitVector docIds = (BitVector) root.getVector(LuceneFilterExecutor.DOC_IDS_COLUMN); + assertThat(docIds.get(0)).isEqualTo(1); // GET + assertThat(docIds.get(1)).isEqualTo(0); // POST + assertThat(docIds.get(2)).isEqualTo(1); // GET + assertThat(docIds.get(3)).isEqualTo(0); // PUT + root.close(); + } finally { + bridge.close(); + } + assertThat(searcherClosed.get()).isTrue(); + } + } + + // --- Test: zero-match query returns all-zeros BitVector --- + + @Example + void executeWithZeroMatchReturnsAllZeroBitVector() throws IOException { + try (InMemoryIndex idx = new InMemoryIndex("verb", "GET", "POST", "PUT")) { + AtomicBoolean searcherClosed = new AtomicBoolean(false); + DefaultShardExecutionContext ctx = createMockShardContext(idx.reader, searcherClosed, "verb"); + + LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + bridge.initialize(ctx); + try { + byte[] serialized = QueryBuilderSerializer.serialize(new TermQueryBuilder("verb", "DELETE")); + Iterator results = bridge.execute(serialized); + + VectorSchemaRoot root = results.next(); + assertThat(root.getRowCount()).isEqualTo(3); + BitVector docIds = (BitVector) root.getVector(LuceneFilterExecutor.DOC_IDS_COLUMN); + for (int i = 0; i < 3; i++) { + assertThat(docIds.get(i)).as("doc %d should not match", i).isEqualTo(0); + } + root.close(); + } finally { + bridge.close(); + } + assertThat(searcherClosed.get()).isTrue(); + } + } + + // --- Test: close releases the Engine.Searcher --- + + @Example + void closeReleasesEngineSearcher() throws IOException { + try (InMemoryIndex idx = new InMemoryIndex("verb", "GET")) { + AtomicBoolean searcherClosed = new AtomicBoolean(false); + DefaultShardExecutionContext ctx = createMockShardContext(idx.reader, searcherClosed, "verb"); + + LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + bridge.initialize(ctx); + assertThat(searcherClosed.get()).isFalse(); + + bridge.close(); + assertThat(searcherClosed.get()) + .as("Engine.Searcher should be closed after bridge.close()") + .isTrue(); + } + } + + // --- Test: all documents match returns all-ones BitVector --- + + @Example + void executeWithAllMatchReturnsAllOnesBitVector() throws IOException { + try (InMemoryIndex idx = new InMemoryIndex("verb", "GET", "GET", "GET")) { + AtomicBoolean searcherClosed = new AtomicBoolean(false); + DefaultShardExecutionContext ctx = createMockShardContext(idx.reader, searcherClosed, "verb"); + + LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + bridge.initialize(ctx); + try { + byte[] serialized = QueryBuilderSerializer.serialize(new TermQueryBuilder("verb", "GET")); + Iterator results = bridge.execute(serialized); + + VectorSchemaRoot root = results.next(); + assertThat(root.getRowCount()).isEqualTo(3); + BitVector docIds = (BitVector) root.getVector(LuceneFilterExecutor.DOC_IDS_COLUMN); + for (int i = 0; i < 3; i++) { + assertThat(docIds.get(i)).as("doc %d should match", i).isEqualTo(1); + } + root.close(); + } finally { + bridge.close(); + } + } + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneFilterExecutorTests.java b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneFilterExecutorTests.java new file mode 100644 index 0000000000000..0970650aeb0ee --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/LuceneFilterExecutorTests.java @@ -0,0 +1,190 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import net.jqwik.api.Example; + +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.calcite.jdbc.CalciteSchema; +import org.apache.calcite.jdbc.JavaTypeFactoryImpl; +import org.apache.calcite.plan.ConventionTraitDef; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.plan.volcano.VolcanoPlanner; +import org.apache.calcite.prepare.CalciteCatalogReader; +import org.apache.calcite.config.CalciteConnectionConfig; +import org.apache.calcite.config.CalciteConnectionConfigImpl; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.impl.AbstractTable; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.be.lucene.predicate.QueryBuilderSerializer; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermQueryBuilder; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link LuceneFilterExecutor}. + */ +class LuceneFilterExecutorTests { + + private final LuceneFilterExecutor bridge = new LuceneFilterExecutor(); + private final JavaTypeFactoryImpl typeFactory = new JavaTypeFactoryImpl(); + private final RexBuilder rexBuilder = new RexBuilder(typeFactory); + + private RelOptCluster createCluster() { + VolcanoPlanner planner = new VolcanoPlanner(); + planner.addRelTraitDef(ConventionTraitDef.INSTANCE); + return RelOptCluster.create(planner, rexBuilder); + } + + private RelOptTable createTable(RelOptCluster cluster) { + CalciteSchema rootSchema = CalciteSchema.createRootSchema(true); + SchemaPlus schemaPlus = rootSchema.plus(); + schemaPlus.add("test_table", new AbstractTable() { + @Override + public RelDataType getRowType(RelDataTypeFactory tf) { + return tf.builder() + .add("verb", tf.createSqlType(SqlTypeName.VARCHAR)) + .add("path", tf.createSqlType(SqlTypeName.VARCHAR)) + .build(); + } + }); + Properties props = new Properties(); + CalciteConnectionConfig config = new CalciteConnectionConfigImpl(props); + CalciteCatalogReader catalogReader = new CalciteCatalogReader( + rootSchema, Collections.singletonList(""), typeFactory, config + ); + return catalogReader.getTable(List.of("test_table")); + } + + private LogicalFilter buildEqualityFilter(String value) { + RelOptCluster cluster = createCluster(); + RelOptTable table = createTable(cluster); + LogicalTableScan scan = LogicalTableScan.create(cluster, table, List.of()); + RexNode fieldRef = rexBuilder.makeInputRef(typeFactory.createSqlType(SqlTypeName.VARCHAR), 0); + RexNode literal = rexBuilder.makeLiteral(value); + RexNode condition = rexBuilder.makeCall(SqlStdOperatorTable.EQUALS, fieldRef, literal); + return LogicalFilter.create(scan, condition); + } + + // --- convertFragment tests --- + + @Example + void convertFragmentWithNullThrowsNullPointerException() { + assertThatThrownBy(() -> bridge.convertFragment(null)) + .isInstanceOf(NullPointerException.class); + } + + @Example + void convertFragmentWithEqualityProducesSerializedTermQuery() { + LogicalFilter filter = buildEqualityFilter("GET"); + + byte[] result = bridge.convertFragment(filter); + + assertThat(result).isNotNull().isNotEmpty(); + + QueryBuilder deserialized = QueryBuilderSerializer.deserialize(result); + assertThat(deserialized).isInstanceOf(TermQueryBuilder.class); + TermQueryBuilder termQuery = (TermQueryBuilder) deserialized; + assertThat(termQuery.fieldName()).isEqualTo("verb"); + assertThat(termQuery.value()).isEqualTo("GET"); + } + + @Example + void convertFragmentWithNonFilterThrowsIllegalArgument() { + RelOptCluster cluster = createCluster(); + RelOptTable table = createTable(cluster); + LogicalTableScan scan = LogicalTableScan.create(cluster, table, List.of()); + + assertThatThrownBy(() -> bridge.convertFragment(scan)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("LogicalFilter"); + } + + // --- execute tests --- + + @Example + void executeWithNullThrowsIllegalArgumentException() { + assertThatThrownBy(() -> bridge.execute(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null or empty"); + } + + @Example + void executeWithEmptyArrayThrowsIllegalArgumentException() { + assertThatThrownBy(() -> bridge.execute(new byte[0])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null or empty"); + } + + @Example + void executeWithCorruptedBytesThrowsIllegalArgumentException() { + byte[] garbage = new byte[] { 0x00, 0x01, 0x02, 0x03 }; + assertThatThrownBy(() -> bridge.execute(garbage)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --- end-to-end test --- + + @Example + void endToEndEqualityConvertAndExecuteReturnsResult() { + LogicalFilter filter = buildEqualityFilter("GET"); + + // Coordinator side: convert RelNode → byte[] + byte[] serialized = bridge.convertFragment(filter); + assertThat(serialized).isNotNull().isNotEmpty(); + + // Data node side: byte[] → Iterator + Iterator results = bridge.execute(serialized); + assertThat(results).isNotNull(); + assertThat(results.hasNext()).isTrue(); + + VectorSchemaRoot root = results.next(); + assertThat(root).isNotNull(); + assertThat(root.getSchema().getFields()).hasSize(1); + assertThat(root.getSchema().getFields().get(0).getName()).isEqualTo(LuceneFilterExecutor.DOC_IDS_COLUMN); + // Empty result (no shard context available outside a running node) + assertThat(root.getRowCount()).isEqualTo(0); + + root.close(); + } + + // --- backward compatibility: no-arg constructor returns empty result from execute --- + + @Example + void noArgConstructorExecuteReturnsEmptyDocIdsBitSet() { + LuceneFilterExecutor noArgBridge = new LuceneFilterExecutor(); + byte[] serialized = QueryBuilderSerializer.serialize(new TermQueryBuilder("verb", "GET")); + + Iterator results = noArgBridge.execute(serialized); + + assertThat(results).isNotNull(); + assertThat(results.hasNext()).isTrue(); + + VectorSchemaRoot root = results.next(); + assertThat(root.getRowCount()).isEqualTo(0); + assertThat(root.getSchema().getFields().get(0).getName()).isEqualTo(LuceneFilterExecutor.DOC_IDS_COLUMN); + + root.close(); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/QueryBuilderSerializerPropertyTests.java b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/QueryBuilderSerializerPropertyTests.java new file mode 100644 index 0000000000000..a897dde46bb66 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/QueryBuilderSerializerPropertyTests.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.constraints.AlphaChars; +import net.jqwik.api.constraints.StringLength; + +import org.opensearch.be.lucene.predicate.QueryBuilderSerializer; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermQueryBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Property-based tests for {@link QueryBuilderSerializer}. + */ +class QueryBuilderSerializerPropertyTests { + + @Property(tries = 100) + // Feature: lucene-backend-plugin, Property 5: QueryBuilder serialization round-trip + void serializationRoundTrip( + @ForAll @AlphaChars @StringLength(min = 1, max = 50) String fieldName, + @ForAll @StringLength(min = 0, max = 100) String value + ) { + TermQueryBuilder original = new TermQueryBuilder(fieldName, value); + + byte[] serialized = QueryBuilderSerializer.serialize(original); + QueryBuilder deserialized = QueryBuilderSerializer.deserialize(serialized); + + assertThat(deserialized).isInstanceOf(TermQueryBuilder.class); + TermQueryBuilder roundTripped = (TermQueryBuilder) deserialized; + assertThat(roundTripped.fieldName()).isEqualTo(original.fieldName()); + assertThat(roundTripped.value()).isEqualTo(original.value()); + } +} diff --git a/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/RexToQueryBuilderConverterPropertyTests.java b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/RexToQueryBuilderConverterPropertyTests.java new file mode 100644 index 0000000000000..c99cb1dd26189 --- /dev/null +++ b/sandbox/plugins/analytics-backend-lucene/src/test/java/org/opensearch/be/lucene/RexToQueryBuilderConverterPropertyTests.java @@ -0,0 +1,161 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.lucene; + +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import net.jqwik.api.constraints.AlphaChars; +import net.jqwik.api.constraints.StringLength; + +import org.apache.calcite.jdbc.JavaTypeFactoryImpl; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.be.lucene.predicate.LikePredicateHandler; +import org.opensearch.be.lucene.predicate.RexToQueryBuilderConverter; +import org.opensearch.index.query.PrefixQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermQueryBuilder; +import org.opensearch.index.query.WildcardQueryBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Property-based tests for {@link RexToQueryBuilderConverter}. + */ +class RexToQueryBuilderConverterPropertyTests { + + private final JavaTypeFactoryImpl typeFactory = new JavaTypeFactoryImpl(); + private final RexBuilder rexBuilder = new RexBuilder(typeFactory); + + /** + * Builds a single-column VARCHAR row type with the given field name. + */ + private RelDataType buildRowType(String fieldName) { + return typeFactory.builder() + .add(fieldName, typeFactory.createSqlType(SqlTypeName.VARCHAR)) + .build(); + } + + /** + * Builds an EQUALS RexCall: field(index=0) = 'value'. + */ + private RexNode buildEqualsCall(String value) { + RexNode fieldRef = rexBuilder.makeInputRef(typeFactory.createSqlType(SqlTypeName.VARCHAR), 0); + RexNode literal = rexBuilder.makeLiteral(value); + return rexBuilder.makeCall(SqlStdOperatorTable.EQUALS, fieldRef, literal); + } + /** + * Generates ASCII-only string values (printable range 0x20–0x7E) to stay within + * Calcite's ISO-8859-1 character set constraint for RexBuilder.makeLiteral. + */ + @Provide + Arbitrary asciiValues() { + return Arbitraries.strings().ascii().ofMinLength(0).ofMaxLength(100); + } + + + @Property(tries = 100) + // Feature: lucene-backend-plugin, Property 1: Text equality produces TermQueryBuilder + void textEqualityProducesTermQueryBuilder( + @ForAll @AlphaChars @StringLength(min = 1, max = 50) String fieldName, + @ForAll("asciiValues") String value + ) { + RelDataType rowType = buildRowType(fieldName); + RexNode equalsCall = buildEqualsCall(value); + + RexToQueryBuilderConverter converter = new RexToQueryBuilderConverter(rowType); + QueryBuilder result = converter.convert(equalsCall); + + assertThat(result).isInstanceOf(TermQueryBuilder.class); + TermQueryBuilder termQuery = (TermQueryBuilder) result; + assertThat(termQuery.fieldName()).isEqualTo(fieldName); + assertThat(termQuery.value()).isEqualTo(value); + } + + /** + * Builds a LIKE RexCall: field(index=0) LIKE 'pattern'. + */ + private RexNode buildLikeCall(String pattern) { + RexNode fieldRef = rexBuilder.makeInputRef(typeFactory.createSqlType(SqlTypeName.VARCHAR), 0); + RexNode literal = rexBuilder.makeLiteral(pattern); + return rexBuilder.makeCall(SqlStdOperatorTable.LIKE, fieldRef, literal); + } + + /** + * Generates a LIKE pattern that ends with '%' and has no other wildcards (trailing-percent-only). + * The body is 0–50 alphanumeric characters followed by a single '%'. + */ + @Provide + Arbitrary trailingPercentPatterns() { + return Arbitraries.strings().alpha().numeric().ofMinLength(0).ofMaxLength(50) + .map(body -> body + "%"); + } + + /** + * Generates a LIKE pattern that contains '%' or '_' in non-trailing positions, + * ensuring it does NOT qualify as trailing-percent-only. + */ + @Provide + Arbitrary generalWildcardPatterns() { + // Patterns like: "%abc", "a%b", "a_b", "_abc%", "%a%b" + return Arbitraries.of("%", "_").flatMap(wildcard -> + Arbitraries.strings().alpha().numeric().ofMinLength(1).ofMaxLength(20).flatMap(prefix -> + Arbitraries.strings().alpha().numeric().ofMinLength(1).ofMaxLength(20).map(suffix -> + prefix + wildcard + suffix + ) + ) + ); + } + + @Property(tries = 100) + // Feature: lucene-backend-plugin, Property 2: LIKE trailing-percent-only produces PrefixQueryBuilder + void likeTrailingPercentProducesPrefixQueryBuilder( + @ForAll @AlphaChars @StringLength(min = 1, max = 50) String fieldName, + @ForAll("trailingPercentPatterns") String pattern + ) { + RelDataType rowType = buildRowType(fieldName); + RexNode likeCall = buildLikeCall(pattern); + + RexToQueryBuilderConverter converter = new RexToQueryBuilderConverter(rowType); + QueryBuilder result = converter.convert(likeCall); + + assertThat(result).isInstanceOf(PrefixQueryBuilder.class); + PrefixQueryBuilder prefixQuery = (PrefixQueryBuilder) result; + assertThat(prefixQuery.fieldName()).isEqualTo(fieldName); + // The prefix is the pattern without the trailing '%' + String expectedPrefix = pattern.substring(0, pattern.length() - 1); + assertThat(prefixQuery.value()).isEqualTo(expectedPrefix); + } + + @Property(tries = 100) + // Feature: lucene-backend-plugin, Property 2: LIKE general wildcard produces WildcardQueryBuilder + void likeGeneralWildcardProducesWildcardQueryBuilder( + @ForAll @AlphaChars @StringLength(min = 1, max = 50) String fieldName, + @ForAll("generalWildcardPatterns") String pattern + ) { + RelDataType rowType = buildRowType(fieldName); + RexNode likeCall = buildLikeCall(pattern); + + RexToQueryBuilderConverter converter = new RexToQueryBuilderConverter(rowType); + QueryBuilder result = converter.convert(likeCall); + + assertThat(result).isInstanceOf(WildcardQueryBuilder.class); + WildcardQueryBuilder wildcardQuery = (WildcardQueryBuilder) result; + assertThat(wildcardQuery.fieldName()).isEqualTo(fieldName); + // Verify SQL wildcards are translated: '%' → '*', '_' → '?' + String expectedLucenePattern = LikePredicateHandler.translateSqlWildcards(pattern); + assertThat(wildcardQuery.value()).isEqualTo(expectedLucenePattern); + } +} diff --git a/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultShardExecutionContext.java b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultShardExecutionContext.java new file mode 100644 index 0000000000000..9dfdae0343f40 --- /dev/null +++ b/sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/DefaultShardExecutionContext.java @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.exec; + +import org.apache.lucene.search.IndexSearcher; +import org.opensearch.analytics.backend.ShardExecutionContext; +import org.opensearch.index.IndexService; +import org.opensearch.index.query.QueryShardContext; +import org.opensearch.index.shard.IndexShard; + +/** + * Concrete {@link ShardExecutionContext} that wraps an {@link IndexShard} + * and its parent {@link IndexService}. + * + *

Back-end plugins that need shard-level resources (e.g., the Lucene + * backend) downcast the opaque {@link ShardExecutionContext} to this type + * to access {@link IndexShard} for acquiring searchers and + * {@link IndexService} for creating {@link QueryShardContext} instances. + * + * @opensearch.internal + */ +public class DefaultShardExecutionContext implements ShardExecutionContext { + + private final IndexShard indexShard; + private final IndexService indexService; + + public DefaultShardExecutionContext(IndexShard indexShard, IndexService indexService) { + this.indexShard = indexShard; + this.indexService = indexService; + } + + @Override + public String shardId() { + return indexShard.shardId().toString(); + } + + /** Returns the {@link IndexShard} for acquiring searchers. */ + public IndexShard indexShard() { + return indexShard; + } + + /** Returns the {@link IndexService} for creating query contexts. */ + public IndexService indexService() { + return indexService; + } + + /** + * Creates a {@link QueryShardContext} bound to the given searcher. + * + * @param searcher the index searcher to bind + * @return a new QueryShardContext + */ + public QueryShardContext createQueryShardContext(IndexSearcher searcher) { + return indexService.newQueryShardContext( + indexShard.shardId().id(), + searcher, + System::currentTimeMillis, + null + ); + } +}