datafusion-opensearch
Query OpenSearch (or Elasticsearch) indices with SQL and the DataFrame API through Apache DataFusion.
An index becomes a DataFusion table. Your WHERE is translated into the OpenSearch query DSL, the
projection into _source, the LIMIT into the page size, and the result streams back page by page
through a scroll cursor — so a scan is complete at any index size and memory stays bounded. Anything
DataFusion asks for that cannot be translated exactly is applied by DataFusion in memory, so results
are always correct: pushdown only ever narrows the request, never widens it.
Install
Add --features udf for OpenSearch-native full-text and geo predicates as SQL functions.
Quick start
SQL only — register the factory once, then point CREATE EXTERNAL TABLE at <cluster>/<index>:
use SessionContext;
use OpenSearchTableProviderFactory;
async
The column list is optional: without one the Arrow schema is derived from the index _mapping.
Every Rust example in this README is compiled by CI, so they are safe to copy.
Guide
Three ways to register an index
From SQL, as above. LOCATION is <base url>/<index>; OPTIONS accepts page_size,
max_rows, sort ('ts:desc,id:asc') and base_filter (a JSON array of query-DSL clauses).
Credentials set on the factory apply to every table it creates:
use SessionContext;
use OpenSearchTableProviderFactory;
async
A whole cluster — every non-system index becomes a table, listed by SHOW TABLES (enable
DataFusion's information_schema for that) and built lazily from its _mapping the first time it
is queried:
use Arc;
use ;
use ;
async
One index, from Rust — with the schema inferred, or declared so only the listed columns are read (and an index that does not exist yet reads as empty):
use Arc;
use ;
use SessionContext;
use ;
async
What is pushed down
The provider translates these into the query DSL and tells DataFusion the filter is fully handled:
col = v,col != v→term/must_not termcol > v,>=,<,<=,BETWEEN→range(a literal on the left,40 < col, is normalised)col IN (…),NOT IN→termscol IS NULL,IS NOT NULL→existscol LIKE 'abc%'→prefix; any otherLIKE/ILIKE→wildcard(case-insensitive whenILIKE)AND,OR,NOT→bool.filter/bool.should/bool.must_not, all-or-nothing per branch- comparisons on timestamp columns → epoch milliseconds, which OpenSearch's default
dateformat accepts - the projection →
_source;LIMIT→ the page size and a stop after that many rows
Everything else — a function or cast on a column, col = col, = NULL — stays in DataFusion and is
evaluated on the rows that come back. An OR with one untranslatable side is not pushed at all,
because pushing half of it would widen the result.
EXPLAIN shows the operator (OpenSearchExec) and, in verbose mode, the exact _search body that was sent.
Native predicates (feature udf)
Some of the most useful OpenSearch queries have no SQL spelling. With the udf feature they are
scalar functions the provider pushes down as native queries:
os_match(field, 'query')— the analysed full-textmatchos_geo_distance(field, lon, lat, radius_metres)—geo_distanceos_geo_bbox(field, top_left_lon, top_left_lat, bottom_right_lon, bottom_right_lat)—geo_bounding_boxos_geo_polygon(field, '[[lon,lat],[lon,lat],…]')—geo_polygon
use SessionContext;
use OpenSearchTableProviderFactory;
async
Pushdown recognises the functions by name, so in a distributed setup every node that plans or executes must register them.
Streaming, limits and pagination
- A scan streams the whole result through a scroll cursor, one
RecordBatchper page. Set the page size withwith_page_size(default 5 000; the index'smax_result_window, 10 000 by default, is the ceiling). - A
LIMITstops the scan after that many rows.with_max_rowscaps LIMIT-less scans if you need a safety net. with_sortpushes an order into_search, which makesLIMIT nmean "the first n by that order".- For caller-driven cursor pagination,
with_search_afterreads exactly one page; feed the last row's sort values back in for the next one. with_partitions(n)(orOPTIONS ('partitions' '4')) reads throughnDataFusion partitions in parallel, each an OpenSearch sliced scroll — worthwhile for large scans over multi-shard indices. A query with aLIMITalways uses one partition so the limit is exact.EXPLAIN ANALYZEreports the operator's output rows and elapsed time per partition.
use Arc;
use SessionContext;
use ;
async
Always-on constraints
with_base_filter ANDs query-DSL clauses into every scan, beneath whatever the query asks for.
Use it for row-level security, tenancy or soft-delete predicates the caller must never be able to
opt out of. The clauses are opaque OpenSearch DSL, so anything the engine can express works:
use Arc;
use SessionContext;
use ;
use json;
async
Authentication and the HTTP client
use OpenSearchClient;
let with_password = new.with_basic_auth;
let with_token = new.with_bearer_token;
// Or bring your own reqwest client for timeouts, TLS roots and custom headers.
let http = builder.timeout.build.unwrap;
let tuned = new.with_client;
The client is rustls-only: no OpenSSL to link.
How mapping types become Arrow types
keyword,text,wildcard,constant_keyword,ip→Utf8long,integer,short,byte,unsigned_long→Int64(a whole-valued float such as106.0is accepted)double,float,half_float,scaled_float→Float64boolean→Booleandate,date_nanos→Timestamp(Millisecond, UTC), read from ISO 8601 strings or epoch millisecondsobject,nested,geo_point,geo_shapeand anything else →Utf8holding the value's JSON text, to parse downstream
Every column is nullable; a field that is absent, null or of the wrong shape becomes null. When you
declare the schema yourself (in Rust or in CREATE EXTERNAL TABLE), any string flavour (Utf8,
Utf8View, LargeUtf8 — SQL VARCHAR), any integer or float width (INT, BIGINT, SMALLINT,
FLOAT, DOUBLE, …), BOOLEAN and TIMESTAMP are read; integers out of the declared range
become null rather than wrapping.
Errors
Anything the cluster returns other than success surfaces as a DataFusionError::External wrapping
this crate's Error, which carries the URL, the HTTP status and the response body so the cause is
in the message. Two deliberate exceptions: searching an index that does not exist reads as empty
(a table can be registered before its index is created), while asking for the _mapping of a
missing index is an error (there is no schema to fall back to).
Compatibility
Built against DataFusion 54; the crate tracks the DataFusion major line, so a DataFusion major
bump is a minor bump here. Tested end to end against OpenSearch 2.x; the query DSL, scroll API and
_cat/indices used here are shared with Elasticsearch 7 and 8.
Develop
OS_URL=http://localhost:9200 OS_INDEX=my-index
CI runs all of the above, including the end-to-end test, on every change, plus a build on the declared minimum Rust version.
Versioning and license
Plain semver on the DataFusion major line; tags vX.Y.Z at the published commit; see
CHANGELOG.md. Apache-2.0, see LICENSE.