ADR-007: Native Connector Architecture, UDF Exposure, and Version Matrix¶
Status and scope¶
A. Native Spark connector — DONE. Catalog/DDL/read/write/row-level ops are native; MERGE matrix verified on Spark 3.5.9 + 4.2.0 with codegen ON. Row-level ops shipped as
SupportsDeltawith primary-key row identity (engine position deletes are a placeholder, sodeleteRows+appendBatchreplaced themergeRowssketch).iceberg-spark-runtimeis gone from Spark 4.x builds and remainsprovidedin the 3.5 profile only (native DSv2CALLlanded in 4.0).B1. Scalar catalog functions — DONE.
FunctionCatalogonBenoStreamCatalogexposesbenostream.system.{cosine_distance, l2_distance, dot_product, vector_distance, sparse_dot_product, hybrid_score, reciprocal_rank_fusion}— verified on 3.5.9 and 4.2.0, no registration, works from PySpark.ScalarFunctionimplementations must overrideproduceResult(InternalRow)(required by Spark 4.x, accepted by 3.4+).B2 — all four items closed (2026-11-10, v0.15.1). (1) pass-through
option("query", sql)reads through the engine DataFusion session (BenoStreamQueryTable). (2)BenoStreamScanBuildernow implementsSupportsPushDownTopN, soORDER BY <distance> LIMIT kwritten in Spark SQL is rendered back into the scan SQL and reaches the engine’s KNN rule; a pushed TopN forces the single SQL scan because a per-file fan-out cannot produce a global top-k. (3) all 21 graph aggregates are reachable through the pass-through reader. (4)getTableFunctions()registersvector_search,keyword_search,hybrid_search,graph_neighbors,graph_shortest_path,graph_subgraph,list_indexes,regional_drift,graph_rag_search.Two scope corrections, recorded so this does not reopen. (a) A Spark
bsdb_query(sql)table function is not implementable: Spark has no public DSv2TableFunctioninterface (itsTableFunctionRegistry/SQLTableFunctionare catalyst-internal, forCREATE FUNCTION … RETURNS TABLEwith a declared output), andBoundFunction.resultType()is fixed at bind time, so a function could not derive a schema from the SQL string.option("query", …)is the supported mechanism and already covers the whole DataFusion surface. (b) Trino cannot host a generic pass-through either (fixed return schema), so of the 21 graph aggregates it exposes the four that have table-function shapes (neighbors, shortest path, subgraph, regional drift). Exposing the remaining 17 requires one fixed-schema TVF per algorithm, a deliberate follow-up rather than a gap in this plan.B2. Original scope from this milestone: (1) pass-through
option("query", sql)reader (full DataFusion surface incl. pgvector operators + KNN optimizer); (2) index-accelerated KNN for Spark 4.x viaSupportsTopNPushDownrenderingORDER BY <distance fn> LIMIT kback into engine SQL; (3) all 21 graph UDAFs (src/core/sql/graph_udf/mod.rs::all_graph_aggregates: pagerank, personalized_pagerank, connected_components, scc, louvain, leiden, modularity, betweenness/closeness/degree centrality, jaccard, triangle_count, shortest_path, all_shortest_paths, label_propagation, subgraph, connecting_paths, graph_neighbors, drift_search, regional_drift, preferential_attachment) + keyword/hybrid search — these are set-returning aggregates, so they surface through the pass-through reader (Spark has no public set-returning catalog TVF API in 3.5 or 4.x); (4) TrinoConnectorTableFunctionset.Dialect decision: pgvector operators (
<->) are engine/pass-through only. Spark SQL exposes snake_case function names matching Trino/DataFusion.
Problem statement¶
Three interlocking requirements emerged while testing the Spark connector:
Remove the Iceberg Java dependency. The Spark connector currently wraps
IcebergSource/SparkTable/SparkCatalog. This (a) couples the connector to a per-Spark-minoriceberg-spark-runtimejar, and (b) is a dead end: the row-level writer (BenoStreamPositionDeltaWriter) has stubbedinsert()/update(), so MERGE INSERT/UPDATE silently drops rows. Reads delegate to Iceberg and use JNI only as a discarded hint.Spark version matrix. Environment has Spark 3.5.9 (Scala 2.12) and 4.2.0 (Scala 2.13). Support full 3.5 and 4 series.
Expose the engine’s UDFs in both Trino and Spark — at minimum the vector distance UDFs and the graph UDAFs (and ideally the full DataFusion surface).
Key facts¶
The native engine already reads/writes Iceberg-format tables itself (Rust), with no Java. The Trino connector proves the native model: it reads via JNI sessions + Arrow C Data, writes via
appendBatch/deleteRows/mergeRows, and runs arbitrary DataFusion SQL viaTable::sql()(JNIopenQuery).The engine’s DataFusion session registers:
all_vector_udfs()(dist_* +<->/<=>operators + KNN optimizer rule),all_json_udfs(), BM25 pushdown, andall_graph_aggregates()(~18 graph UDAFs).Spark JNI natives already exist (
listDataFiles,PartitionReader openSession/readBatch); Spark-named mirrors for the Trino surface were just added insrc/core/ffi.rs(getTableSchema/appendBatch/mergeRows/deleteRows/ createTable/openQuery/readQueryBatch/closeQuery/list*/dropTable/closeSession).Trino has no generic pass-through query mechanism and connectors cannot register arbitrary scalar UDFs; it does support connector table functions (
ConnectorTableFunction, SPI 482+).Spark 3.4+/4.x support DSv2
FunctionCatalogscalar functions (ScalarFunction/UnboundFunction); set-returning catalog TVFs are not public API in either line (4.xTableFunctionis row-level LATERAL only). Relation options (option("query", ...)) work everywhere.
Recommended architecture¶
A. Native Spark connector (Iceberg-free)¶
Rewrite the Spark connector to mirror the Trino connector’s native model:
BenoStreamTable: holdstableUri+StructType(fromgetTableSchema); implementsSupportsRead,SupportsWrite,SupportsRowLevelOperations.Read:
BenoStreamScanBuildertranslates required columns + Spark filters → a DataFusion SQL string;BenoStreamScan→ oneInputPartition;BenoStreamPartitionReadercallsopenQuery/readQueryBatchand decodes Arrow→catalyst (newBenoStreamArrowUtils, already scaffolded).Write:
BenoStreamWriteBuilder→BatchWrite→DataWriterbuffers rows, converts to Arrow, callsappendBatch(commit already done engine-side per batch).Row-level ops: DELETE →
deleteRows(filter); UPDATE/MERGE →mergeRows(engine upsert-by-PK). Replaces the stubbed DeltaWrite.BenoStreamCatalogimplementsTableCatalog/SupportsNamespacesvialistSchemas/listTables/getTableSchema/createTable/dropTable(noSparkCatalog).DefaultSourcereturns the native table.Drop
iceberg-spark-runtimefrom the pom; remove allorg.apache.iceberg.*imports from the connector.
B. UDF exposure¶
Two complementary mechanisms:
Pass-through native SQL (full DataFusion surface) — Spark first.
spark.read.format("benostream").option("query", "SELECT id, cosine_distance(...) FROM t ORDER BY embedding <-> [..] LIMIT 10").load()runs engine SQL and returns rows. This gives all UDFs + operators + KNN/graph optimizer rules for free.Typed table functions (both engines) — the named UDFs. Define fixed-schema table functions so users get IntelliSense-style calls without raw SQL:
vector_search(table, column, query_vector, k, metric)→(row_id, score)graph_neighbors(table, node, hops, graph_column)→(node, hop, ...)drift_search/regional_drift,keyword_search(BM25),hybrid_searchTrino:
ConnectorTableFunctionregistered on the connector. Spark 4: DSv2TableFunction; Spark 3.5: DataFrame API + registered scalar UDFs (BenoStreamFunctions) as the fallback.
Trino cannot do generic pass-through, so its UDF story is the typed table functions (+ scalar distance operators where feasible). Spark gets both.
C. Version matrix (answer to the question)¶
Once Iceberg is gone, the only real axis is Scala:
Spark 3.5.x (incl. 3.5.9): one Scala 2.12 artifact (
spark-3.5profile). Patch releases are binary-compatible; no per-patch builds.Spark 4.0 / 4.1 / 4.2: one Scala 2.13 artifact covers all of them (DSv2 APIs are stable across 4.x; we no longer touch version-specific Iceberg). Add the
spark-4.2profile (done) for compile/CI targeting.Trino: two SPI builds, band
d(482-483) and bande(484+); seedocs/integrations/TRINO_VERSION_BANDS.md. Net: 2 Spark artifacts (2.12, 2.13) + 1 Trino, not per-minor.
Sequenced milestones¶
[Native read] Scan builder + partition reader + Arrow→catalyst; verify reads work with no Iceberg on the classpath.
[Native write] DataWriter → Arrow → appendBatch; verify INSERT/CTAS/overwrite.
[Native row-level] DELETE→deleteRows; UPDATE/MERGE→mergeRows; verify with value assertions (the thing the Iceberg wrapper silently broke).
[Native catalog/DDL] BenoStreamCatalog + DefaultSource; drop Iceberg dep.
[Spark UDFs] Pass-through
queryoption + Spark 4bsdb_querytable functiontyped table functions (vector_search, graph_neighbors, drift, keyword, hybrid).
[Trino UDFs]
ConnectorTableFunctionset (vector_search, graph_neighbors, …).[Matrix/CI] Build 2.12 + 2.13 artifacts; add spark-4.2 to build matrix; docs.
[Regression] Re-run full surface suites for both connectors.
Risks / open questions¶
Native write must preserve Iceberg-format correctness (snapshots/manifests) — the engine already does this (Trino writes prove it), but Spark’s partitioning/ bucketing semantics need parity work.
Trino table functions have fixed return schemas; vector/graph outputs must be modeled accordingly (row_id + score / node columns).
libbenostreamdb.somust be onjava.library.pathon every Spark executor / Trino worker (already true for Trino).Scope is large; ordering below is a recommendation, not a commitment.