From 4e5aa8e6cbc1998afee2163785b27606c0d591da Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Sat, 16 May 2026 15:01:00 +0800 Subject: [PATCH 01/11] commit --- Cargo.lock | 15 + Cargo.toml | 2 + nodedb-cluster/src/calvin/types/primitives.rs | 169 +----- nodedb-graph/src/lib.rs | 4 + nodedb-graph/src/params.rs | 275 +++++++++ nodedb-graph/src/traversal_options.rs | 236 ++++++++ nodedb-physical/Cargo.toml | 21 + nodedb-physical/src/convert_context.rs | 44 ++ nodedb-physical/src/error.rs | 43 ++ nodedb-physical/src/lib.rs | 22 + .../src}/physical_plan/array.rs | 0 .../src}/physical_plan/cluster_array.rs | 0 .../src}/physical_plan/columnar.rs | 0 .../src}/physical_plan/crdt.rs | 0 .../document/enforcement_types.rs | 95 +++ .../physical_plan/document/merge_types.rs | 0 .../src}/physical_plan/document/mod.rs | 8 +- .../src}/physical_plan/document/op.rs | 0 .../src}/physical_plan/document/types.rs | 62 +- .../physical_plan/document/update_value.rs | 51 ++ .../src}/physical_plan/graph.rs | 12 +- .../src}/physical_plan/kv.rs | 0 .../src}/physical_plan/meta.rs | 12 +- .../src}/physical_plan/mod.rs | 0 .../src}/physical_plan/query.rs | 14 +- .../src}/physical_plan/spatial.rs | 0 .../src}/physical_plan/text.rs | 0 .../src}/physical_plan/timeseries.rs | 0 .../src}/physical_plan/vector.rs | 2 +- nodedb-physical/src/physical_plan/wire.rs | 60 ++ .../src/physical_task.rs | 6 +- nodedb-physical/src/surrogate.rs | 45 ++ nodedb-physical/src/visitor.rs | 68 +++ nodedb-query/src/lib.rs | 5 +- nodedb-query/src/window/aggregate.rs | 8 +- nodedb-query/src/window/eval.rs | 16 +- nodedb-query/src/window/helpers.rs | 36 +- nodedb-query/src/window/mod.rs | 3 + nodedb-query/src/window/ranking.rs | 8 +- nodedb-query/src/window/spec.rs | 10 +- nodedb-query/src/window/value_agg.rs | 412 +++++++++++++ nodedb-query/src/window/value_eval.rs | 458 +++++++++++++++ nodedb-sql/src/lib.rs | 3 + nodedb-sql/src/visitor/mod.rs | 6 + .../src/visitor/plan_visitor/dispatch.rs | 492 ++++++++++++++++ nodedb-sql/src/visitor/plan_visitor/mod.rs | 7 + .../src/visitor/plan_visitor/trait_def.rs | 482 +++++++++++++++ nodedb-types/src/calvin.rs | 167 ++++++ nodedb-types/src/filter.rs | 10 +- nodedb-types/src/graph.rs | 24 - nodedb-types/src/id/mod.rs | 2 + nodedb-types/src/id/request.rs | 36 ++ nodedb-types/src/lib.rs | 1 + nodedb-vector/src/hnsw/graph.rs | 7 + nodedb/Cargo.toml | 1 + nodedb/src/bridge/dispatch.rs | 2 +- nodedb/src/bridge/envelope.rs | 4 +- nodedb/src/bridge/mod.rs | 1 - nodedb/src/bridge/physical_plan/wire.rs | 332 ----------- .../src/control/array_sync/inbound_propose.rs | 2 +- nodedb/src/control/array_sync/raft_apply.rs | 4 +- nodedb/src/control/backup/orchestrator.rs | 2 +- nodedb/src/control/backup/restore/mod.rs | 2 +- nodedb/src/control/backup/restore/remote.rs | 2 +- .../async_dispatch/continuous_aggregate.rs | 2 +- .../async_dispatch/materialized_view.rs | 2 +- .../catalog_entry/tests/invalidation.rs | 2 +- nodedb/src/control/checkpoint_manager.rs | 2 +- nodedb/src/control/clone/copyup.rs | 2 +- nodedb/src/control/clone/resolver/resolve.rs | 4 +- nodedb/src/control/clone/resolver/rewrite.rs | 2 +- .../src/control/cluster/array_cluster_exec.rs | 4 +- .../control/cluster/array_cluster_helpers.rs | 4 +- nodedb/src/control/cluster/array_executor.rs | 2 +- .../calvin/scheduler/driver/barrier.rs | 2 +- .../calvin/scheduler/driver/core/dispatch.rs | 10 +- .../calvin/scheduler/driver/helpers.rs | 4 +- .../control/distributed_applier/apply_loop.rs | 2 +- nodedb/src/control/exec_receiver.rs | 2 +- nodedb/src/control/gateway/cache_miss.rs | 2 +- nodedb/src/control/gateway/core.rs | 4 +- nodedb/src/control/gateway/dispatcher.rs | 4 +- nodedb/src/control/gateway/invalidation.rs | 2 +- nodedb/src/control/gateway/plan_cache.rs | 4 +- nodedb/src/control/gateway/route.rs | 4 +- nodedb/src/control/gateway/router.rs | 4 +- nodedb/src/control/gateway/version_set.rs | 6 +- .../clone_materializer/columnar.rs | 6 +- .../clone_materializer/dispatch.rs | 2 +- .../clone_materializer/document.rs | 2 +- .../maintenance/clone_materializer/kv.rs | 2 +- nodedb/src/control/otel/receiver.rs | 2 +- nodedb/src/control/planner/auto_tier.rs | 4 +- nodedb/src/control/planner/calvin/dispatch.rs | 14 +- .../control/planner/calvin/dispatch_tests.rs | 4 +- nodedb/src/control/planner/calvin/explain.rs | 6 +- nodedb/src/control/planner/calvin/preexec.rs | 2 +- nodedb/src/control/planner/context.rs | 12 +- nodedb/src/control/planner/mod.rs | 1 - .../procedural/executor/core/dispatch.rs | 2 +- .../procedural/executor/transaction.rs | 6 +- nodedb/src/control/planner/rls_injection.rs | 8 +- .../planner/sql_plan_convert/aggregate.rs | 20 +- .../sql_plan_convert/array_alter_convert.rs | 4 +- .../sql_plan_convert/array_convert/ddl.rs | 4 +- .../sql_plan_convert/array_convert/dml.rs | 4 +- .../array_fn_convert/aggregate.rs | 4 +- .../array_fn_convert/elementwise.rs | 4 +- .../array_fn_convert/helpers.rs | 2 +- .../array_fn_convert/maint.rs | 4 +- .../array_fn_convert/project.rs | 4 +- .../array_fn_convert/slice.rs | 4 +- .../planner/sql_plan_convert/convert.rs | 550 +----------------- .../sql_plan_convert/convert_array_arms.rs | 2 +- .../planner/sql_plan_convert/dml/insert.rs | 6 +- .../sql_plan_convert/dml/kv_and_vector.rs | 4 +- .../planner/sql_plan_convert/dml/merge.rs | 8 +- .../sql_plan_convert/dml/update_delete.rs | 6 +- .../planner/sql_plan_convert/lateral.rs | 4 +- .../control/planner/sql_plan_convert/mod.rs | 1 + .../planner/sql_plan_convert/scan/core.rs | 4 +- .../planner/sql_plan_convert/scan/join.rs | 4 +- .../sql_plan_convert/scan/recursive.rs | 4 +- .../planner/sql_plan_convert/scan/search.rs | 6 +- .../planner/sql_plan_convert/scan/spatial.rs | 4 +- .../sql_plan_convert/scan/timeseries.rs | 4 +- .../planner/sql_plan_convert/set_ops.rs | 4 +- .../sql_plan_convert/value/assignments.rs | 2 +- .../sql_plan_convert/visitor/adapter.rs | 35 ++ .../visitor/arms_aggregate_lateral.rs | 88 +++ .../sql_plan_convert/visitor/arms_array.rs | 169 ++++++ .../sql_plan_convert/visitor/arms_dml.rs | 178 ++++++ .../visitor/arms_scan_read.rs | 165 ++++++ .../visitor/arms_scan_search.rs | 192 ++++++ .../sql_plan_convert/visitor/arms_set_ops.rs | 78 +++ .../planner/sql_plan_convert/visitor/mod.rs | 12 + .../visitor/unsupported_arms.rs | 34 ++ .../catalog/collection_constraints.rs | 25 +- .../security/identity/plan_permission.rs | 10 +- nodedb/src/control/server/broadcast.rs | 2 +- nodedb/src/control/server/dispatch_utils.rs | 2 +- .../src/control/server/graph_dispatch/hop.rs | 2 +- .../server/graph_dispatch/shortest_path.rs | 2 +- nodedb/src/control/server/http/routes/crdt.rs | 2 +- .../server/http/routes/promql/remote.rs | 2 +- .../src/control/server/http/routes/query.rs | 2 +- nodedb/src/control/server/ilp_batch.rs | 2 +- .../native/dispatch/plan_builder/columnar.rs | 2 +- .../native/dispatch/plan_builder/crdt.rs | 2 +- .../native/dispatch/plan_builder/document.rs | 18 +- .../native/dispatch/plan_builder/graph.rs | 2 +- .../server/native/dispatch/plan_builder/kv.rs | 2 +- .../native/dispatch/plan_builder/query.rs | 2 +- .../native/dispatch/plan_builder/spatial.rs | 2 +- .../native/dispatch/plan_builder/text.rs | 2 +- .../dispatch/plan_builder/timeseries.rs | 2 +- .../native/dispatch/plan_builder/vector.rs | 2 +- .../src/control/server/native/dispatch/sql.rs | 8 +- .../server/native/dispatch/sql_gateway.rs | 2 +- .../server/native/dispatch/transaction.rs | 4 +- .../ddl/collection/create/enforcement.rs | 10 +- .../pgwire/ddl/collection/create/register.rs | 30 +- .../server/pgwire/ddl/collection/index.rs | 4 +- .../pgwire/ddl/collection/index_fanout.rs | 4 +- .../server/pgwire/ddl/collection/insert.rs | 2 +- .../pgwire/ddl/collection/purge/dispatch.rs | 2 +- .../server/pgwire/ddl/conflict_policy.rs | 2 +- .../pgwire/ddl/continuous_agg/create.rs | 2 +- .../server/pgwire/ddl/continuous_agg/drop.rs | 2 +- .../pgwire/ddl/continuous_agg/register.rs | 2 +- .../server/pgwire/ddl/continuous_agg/show.rs | 2 +- .../src/control/server/pgwire/ddl/convert.rs | 2 +- .../src/control/server/pgwire/ddl/crdt_ops.rs | 2 +- .../server/pgwire/ddl/dsl/crdt_merge.rs | 2 +- .../server/pgwire/ddl/dsl/vector_index.rs | 2 +- .../server/pgwire/ddl/graph_ops/algo.rs | 2 +- .../server/pgwire/ddl/graph_ops/edge.rs | 2 +- .../server/pgwire/ddl/graph_ops/rag_fusion.rs | 2 +- .../server/pgwire/ddl/graph_ops/stats.rs | 2 +- .../server/pgwire/ddl/graph_ops/traverse.rs | 2 +- .../control/server/pgwire/ddl/kv_atomic.rs | 2 +- .../server/pgwire/ddl/kv_sorted_index.rs | 2 +- .../control/server/pgwire/ddl/last_value.rs | 2 +- .../server/pgwire/ddl/maintenance/compact.rs | 2 +- .../pgwire/ddl/maintenance/distributed.rs | 2 +- .../server/pgwire/ddl/maintenance/reindex.rs | 2 +- .../pgwire/ddl/maintenance/vector_index.rs | 2 +- .../control/server/pgwire/ddl/match_ops.rs | 2 +- .../ddl/query_functions/balance_as_of.rs | 4 +- .../convert_currency_lookup.rs | 2 +- .../ddl/query_functions/temporal_lookup.rs | 2 +- .../ddl/query_functions/verify_balance.rs | 4 +- .../ddl/query_functions/verify_hash_chain.rs | 2 +- .../control/server/pgwire/ddl/rate_gate.rs | 2 +- .../control/server/pgwire/ddl/router/dsl.rs | 2 +- .../server/pgwire/ddl/synonym_group.rs | 2 +- .../pgwire/ddl/tenant/move_tenant/cutover.rs | 2 +- .../pgwire/ddl/tenant/move_tenant/snapshot.rs | 2 +- .../control/server/pgwire/ddl/tenant/purge.rs | 2 +- .../src/control/server/pgwire/ddl/transfer.rs | 2 +- .../pgwire/ddl/tree_ops/create_index.rs | 4 +- .../control/server/pgwire/ddl/tree_ops/sum.rs | 2 +- .../pgwire/ddl/version_history/at_version.rs | 2 +- .../pgwire/ddl/version_history/checkpoint.rs | 2 +- .../pgwire/ddl/version_history/compact.rs | 2 +- .../server/pgwire/ddl/version_history/diff.rs | 2 +- .../pgwire/ddl/version_history/restore.rs | 2 +- .../server/pgwire/ddl/weighted_pick.rs | 2 +- .../control/server/pgwire/handler/dispatch.rs | 28 +- .../control/server/pgwire/handler/facet.rs | 4 +- .../src/control/server/pgwire/handler/plan.rs | 24 +- .../pgwire/handler/prepared/parser_schema.rs | 4 +- .../pgwire/handler/prepared/plan_cache.rs | 6 +- .../server/pgwire/handler/returning.rs | 2 +- .../pgwire/handler/routing/calvin_dispatch.rs | 2 +- .../pgwire/handler/routing/clone_dispatch.rs | 18 +- .../handler/routing/clone_write_dispatch.rs | 4 +- .../server/pgwire/handler/routing/execute.rs | 10 +- .../handler/routing/gateway_dispatch.rs | 2 +- .../pgwire/handler/routing/kv_wrapping.rs | 2 +- .../pgwire/handler/routing/ollp_helpers.rs | 2 +- .../server/pgwire/handler/routing/planning.rs | 6 +- .../server/pgwire/handler/routing/set_ops.rs | 2 +- .../server/pgwire/handler/transaction_cmds.rs | 12 +- .../pgwire/pg_catalog/dropped_collections.rs | 2 +- .../control/server/pgwire/session/state.rs | 2 +- .../control/server/pgwire/session/store.rs | 4 +- .../server/pgwire/session/transaction.rs | 2 +- nodedb/src/control/server/resp/handler.rs | 2 +- .../src/control/server/resp/handler_hash.rs | 2 +- nodedb/src/control/server/resp/handler_kv.rs | 2 +- .../src/control/server/resp/handler_sorted.rs | 2 +- .../server/response_translate/vector.rs | 2 +- nodedb/src/control/server/session.rs | 2 +- .../src/control/server/sync/async_dispatch.rs | 4 +- .../control/server/sync/columnar_handler.rs | 2 +- nodedb/src/control/server/sync/fts_handler.rs | 4 +- .../control/server/sync/spatial_handler.rs | 4 +- .../control/server/sync/timeseries_handler.rs | 2 +- .../src/control/server/sync/vector_handler.rs | 4 +- nodedb/src/control/server/wal_dispatch.rs | 8 +- nodedb/src/control/surrogate/mod.rs | 1 + nodedb/src/control/surrogate/physical_impl.rs | 24 + nodedb/src/control/trigger/dml_hook.rs | 6 +- nodedb/src/control/wal_catchup.rs | 2 +- nodedb/src/control/wal_replication/decode.rs | 2 +- nodedb/src/control/wal_replication/encode.rs | 2 +- nodedb/src/control/wal_replication/tests.rs | 2 +- nodedb/src/control/wal_replication/types.rs | 2 +- .../src/data/executor/core_loop/accessors.rs | 4 +- .../src/data/executor/core_loop/event_emit.rs | 2 +- .../src/data/executor/core_loop/pressure.rs | 2 +- .../executor/core_loop/priority_queues.rs | 4 +- nodedb/src/data/executor/core_loop/tests.rs | 2 +- .../data/executor/dispatch/array/aggregate.rs | 2 +- .../dispatch/array/aggregate_helpers.rs | 2 +- .../executor/dispatch/array/elementwise.rs | 2 +- .../src/data/executor/dispatch/array/entry.rs | 2 +- .../data/executor/dispatch/array/mutate.rs | 2 +- .../executor/dispatch/array/tests_dispatch.rs | 4 +- .../dispatch/bitmap/hashjoin_inline.rs | 2 +- nodedb/src/data/executor/dispatch/columnar.rs | 2 +- nodedb/src/data/executor/dispatch/crdt.rs | 2 +- nodedb/src/data/executor/dispatch/document.rs | 2 +- nodedb/src/data/executor/dispatch/graph.rs | 2 +- nodedb/src/data/executor/dispatch/kv.rs | 2 +- nodedb/src/data/executor/dispatch/meta.rs | 2 +- .../dispatch/meta_retention/handlers.rs | 2 +- nodedb/src/data/executor/dispatch/mod.rs | 30 +- nodedb/src/data/executor/dispatch/query.rs | 2 +- nodedb/src/data/executor/dispatch/spatial.rs | 2 +- nodedb/src/data/executor/dispatch/text.rs | 2 +- .../src/data/executor/dispatch/timeseries.rs | 2 +- nodedb/src/data/executor/dispatch/vector.rs | 2 +- nodedb/src/data/executor/dispatch/visitor.rs | 84 +++ .../data/executor/enforcement/append_only.rs | 2 +- .../src/data/executor/enforcement/balanced.rs | 2 +- .../executor/enforcement/materialized_sum.rs | 2 +- .../data/executor/enforcement/period_lock.rs | 2 +- .../data/executor/enforcement/retention.rs | 42 +- .../src/data/executor/handlers/accum/feed.rs | 2 +- .../data/executor/handlers/accum/finalize.rs | 2 +- .../src/data/executor/handlers/accum/new.rs | 2 +- .../src/data/executor/handlers/accum/state.rs | 2 +- .../src/data/executor/handlers/accum/tests.rs | 2 +- .../src/data/executor/handlers/aggregate.rs | 2 +- nodedb/src/data/executor/handlers/bulk_dml.rs | 14 +- .../handlers/columnar_write/insert.rs | 4 +- .../data/executor/handlers/control/calvin.rs | 4 +- .../executor/handlers/document/read/scan.rs | 3 +- .../data/executor/handlers/document/write.rs | 13 +- .../src/data/executor/handlers/generated.rs | 2 +- nodedb/src/data/executor/handlers/graph.rs | 4 +- .../executor/handlers/grouping_sets_exec.rs | 2 +- .../handlers/join/lateral/loop_handler.rs | 2 +- .../executor/handlers/join/lateral/shared.rs | 2 +- .../executor/handlers/join/lateral/top_k.rs | 2 +- nodedb/src/data/executor/handlers/join/mod.rs | 12 +- .../src/data/executor/handlers/join/params.rs | 2 +- nodedb/src/data/executor/handlers/kv/crud.rs | 2 +- .../src/data/executor/handlers/kv/dispatch.rs | 2 +- nodedb/src/data/executor/handlers/merge.rs | 12 +- .../data/executor/handlers/merge_helpers.rs | 8 +- .../data/executor/handlers/point/apply_put.rs | 4 +- .../data/executor/handlers/point/delete.rs | 2 +- .../src/data/executor/handlers/point/get.rs | 3 +- .../data/executor/handlers/point/update.rs | 14 +- .../src/data/executor/handlers/recursive.rs | 3 +- .../data/executor/handlers/returning_rows.rs | 2 +- nodedb/src/data/executor/handlers/spatial.rs | 8 +- .../data/executor/handlers/spill/groupby.rs | 4 +- .../src/data/executor/handlers/text_search.rs | 6 +- .../data/executor/handlers/timeseries_wal.rs | 2 +- .../executor/handlers/transaction/batch.rs | 2 +- .../executor/handlers/transaction/sub_plan.rs | 6 +- .../handlers/transaction/sub_plan_doc.rs | 2 +- .../handlers/transaction/sub_plan_kv.rs | 4 +- .../handlers/transaction/sub_plan_kv_ops.rs | 2 +- .../executor/handlers/update_from_join.rs | 8 +- nodedb/src/data/executor/handlers/upsert.rs | 10 +- .../src/data/executor/handlers/write_batch.rs | 2 +- nodedb/src/data/executor/scan_normalize.rs | 3 +- nodedb/src/engine/bitemporal/enforcement.rs | 2 +- nodedb/src/engine/document/predicate.rs | 2 +- nodedb/src/engine/document/store/config.rs | 10 +- .../src/engine/document/store/index_path.rs | 4 +- nodedb/src/engine/graph/algo/params.rs | 276 +-------- nodedb/src/engine/graph/traversal_options.rs | 239 +------- .../timeseries/retention_policy/autowire.rs | 2 +- .../retention_policy/enforcement.rs | 2 +- nodedb/src/error.rs | 35 ++ nodedb/src/event/alert/executor.rs | 2 +- nodedb/src/types/id.rs | 37 +- 333 files changed, 4782 insertions(+), 2276 deletions(-) create mode 100644 nodedb-graph/src/params.rs create mode 100644 nodedb-graph/src/traversal_options.rs create mode 100644 nodedb-physical/Cargo.toml create mode 100644 nodedb-physical/src/convert_context.rs create mode 100644 nodedb-physical/src/error.rs create mode 100644 nodedb-physical/src/lib.rs rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/array.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/cluster_array.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/columnar.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/crdt.rs (100%) create mode 100644 nodedb-physical/src/physical_plan/document/enforcement_types.rs rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/document/merge_types.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/document/mod.rs (68%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/document/op.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/document/types.rs (79%) create mode 100644 nodedb-physical/src/physical_plan/document/update_value.rs rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/graph.rs (94%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/kv.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/meta.rs (98%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/mod.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/query.rs (96%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/spatial.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/text.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/timeseries.rs (100%) rename {nodedb/src/bridge => nodedb-physical/src}/physical_plan/vector.rs (99%) create mode 100644 nodedb-physical/src/physical_plan/wire.rs rename nodedb/src/control/planner/physical.rs => nodedb-physical/src/physical_task.rs (92%) create mode 100644 nodedb-physical/src/surrogate.rs create mode 100644 nodedb-physical/src/visitor.rs create mode 100644 nodedb-query/src/window/value_agg.rs create mode 100644 nodedb-query/src/window/value_eval.rs create mode 100644 nodedb-sql/src/visitor/mod.rs create mode 100644 nodedb-sql/src/visitor/plan_visitor/dispatch.rs create mode 100644 nodedb-sql/src/visitor/plan_visitor/mod.rs create mode 100644 nodedb-sql/src/visitor/plan_visitor/trait_def.rs create mode 100644 nodedb-types/src/calvin.rs create mode 100644 nodedb-types/src/id/request.rs delete mode 100644 nodedb/src/bridge/physical_plan/wire.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/adapter.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/arms_aggregate_lateral.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/arms_array.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_read.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_search.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/arms_set_ops.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/mod.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/visitor/unsupported_arms.rs create mode 100644 nodedb/src/control/surrogate/physical_impl.rs create mode 100644 nodedb/src/data/executor/dispatch/visitor.rs diff --git a/Cargo.lock b/Cargo.lock index 8cc63002e..c2b1dd249 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4130,6 +4130,7 @@ dependencies = [ "nodedb-fts", "nodedb-graph", "nodedb-mem", + "nodedb-physical", "nodedb-query", "nodedb-raft", "nodedb-spatial", @@ -4413,6 +4414,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "nodedb-physical" +version = "0.2.1" +dependencies = [ + "nodedb-array", + "nodedb-graph", + "nodedb-query", + "nodedb-sql", + "nodedb-types", + "serde", + "thiserror 2.0.18", + "zerompk", +] + [[package]] name = "nodedb-query" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index aaac9fc72..b2af3cd7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "nodedb-columnar", "nodedb-array", "nodedb-sql", + "nodedb-physical", "nodedb-test-support", "nodedb-cluster-tests", "nodedb-client-tests", @@ -58,6 +59,7 @@ nodedb-strict = { path = "nodedb-strict", version = "0.2" } nodedb-columnar = { path = "nodedb-columnar", version = "0.2" } nodedb-array = { path = "nodedb-array", version = "0.2" } nodedb-sql = { path = "nodedb-sql", version = "0.2" } +nodedb-physical = { path = "nodedb-physical", version = "0.2" } nodedb-test-support = { path = "nodedb-test-support" } # Async runtimes diff --git a/nodedb-cluster/src/calvin/types/primitives.rs b/nodedb-cluster/src/calvin/types/primitives.rs index 8b823696c..12590dfc2 100644 --- a/nodedb-cluster/src/calvin/types/primitives.rs +++ b/nodedb-cluster/src/calvin/types/primitives.rs @@ -2,175 +2,16 @@ //! Primitive Calvin type definitions. //! -//! Provides [`SortedVec`], [`EngineKeySet`], [`PassiveReadKey`], and -//! [`DependentReadSpec`] — the building blocks of Calvin read/write sets. +//! [`SortedVec`], [`EngineKeySet`], and [`PassiveReadKey`] live in +//! `nodedb-types` so the physical-plan IR can reference them without +//! pulling in the distributed scheduler. [`DependentReadSpec`] stays +//! here because it is scheduler-internal. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -// ── SortedVec ──────────────────────────────────────────────────────────────── - -/// A newtype over `Vec` that guarantees sorted, deduplicated contents. -/// -/// Constructed via [`SortedVec::new`], which sorts and deduplicates at -/// construction time. This property is load-bearing for byte-determinism: -/// two `SortedVec`s built from the same logical set (in any insertion order) -/// produce identical serialized bytes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SortedVec(Vec); - -impl zerompk::ToMessagePack for SortedVec { - fn write(&self, writer: &mut W) -> zerompk::Result<()> { - self.0.write(writer) - } -} - -impl<'de, T> zerompk::FromMessagePack<'de> for SortedVec -where - T: zerompk::FromMessagePack<'de> + Ord + Clone, -{ - fn read>(reader: &mut R) -> zerompk::Result { - let v = Vec::::read(reader)?; - Ok(Self::new(v)) - } -} - -impl SortedVec { - /// Build from any slice. Sorts and deduplicates in place. - pub fn new(mut items: Vec) -> Self { - items.sort(); - items.dedup(); - Self(items) - } - - pub fn as_slice(&self) -> &[T] { - &self.0 - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - pub fn len(&self) -> usize { - self.0.len() - } - - pub fn iter(&self) -> std::slice::Iter<'_, T> { - self.0.iter() - } -} - -impl From> for SortedVec { - fn from(v: Vec) -> Self { - Self::new(v) - } -} - -// ── EngineKeySet ────────────────────────────────────────────────────────────── - -/// A typed key set for one engine within a read or write set. -/// -/// Keys are normalized to surrogates (or byte keys for KV) at admission, so -/// all engine-specific naming is resolved upstream of the sequencer. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub enum EngineKeySet { - /// Document engine (schemaless or strict): identified by surrogate. - Document { - collection: String, - surrogates: SortedVec, - }, - /// Vector engine: identified by surrogate. - Vector { - collection: String, - surrogates: SortedVec, - }, - /// Key-Value engine: identified by raw byte keys. - Kv { - collection: String, - keys: SortedVec>, - }, - /// Graph edge engine: identified by (src_surrogate, dst_surrogate) pairs. - Edge { - collection: String, - edges: SortedVec<(u32, u32)>, - }, -} - -impl EngineKeySet { - /// O(1) estimate of the serialized byte size of this key set. - /// - /// Used by the dependent-read cap check at sequencer admission to bound - /// the total bytes that would be Raft-replicated in a `CalvinReadResult` - /// entry. This is an estimate, not an exact count; do NOT use it as a - /// correctness check — only as a pre-flight guard. - pub fn serialized_size_hint(&self) -> usize { - match self { - // u32 surrogates: 4 bytes each. - Self::Document { surrogates, .. } | Self::Vector { surrogates, .. } => { - surrogates.len() * 4 - } - // KV keys: sum of key byte lengths. - Self::Kv { keys, .. } => keys.iter().map(|k| k.len()).sum(), - // Edge: two u32 per edge = 8 bytes each. - Self::Edge { edges, .. } => edges.len() * 8, - } - } - - /// The collection this key set belongs to. - pub fn collection(&self) -> &str { - match self { - Self::Document { collection, .. } - | Self::Vector { collection, .. } - | Self::Kv { collection, .. } - | Self::Edge { collection, .. } => collection, - } - } - - /// Returns `true` if this key set contains no keys. - pub fn is_empty(&self) -> bool { - match self { - Self::Document { surrogates, .. } => surrogates.is_empty(), - Self::Vector { surrogates, .. } => surrogates.is_empty(), - Self::Kv { keys, .. } => keys.is_empty(), - Self::Edge { edges, .. } => edges.is_empty(), - } - } -} - -// ── PassiveReadKey ──────────────────────────────────────────────────────────── - -/// A single key that a passive participant must read and broadcast. -/// -/// Wraps an [`EngineKeySet`]; per the dependent-read protocol each -/// `PassiveReadKey` contains a single-element (or small) key set. The -/// sequencer does not enforce single-element sets; the scheduler enforces the -/// total byte budget via `DependentReadSpec::total_bytes()`. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub struct PassiveReadKey { - /// The engine key set to read on the passive vshard. - pub engine_key: EngineKeySet, -} - -// ── DependentReadSpec ───────────────────────────────────────────────────────── +pub use nodedb_types::calvin::{EngineKeySet, PassiveReadKey, SortedVec}; /// Describes the passive-read participants for a dependent-read Calvin txn. /// diff --git a/nodedb-graph/src/lib.rs b/nodedb-graph/src/lib.rs index 4b761ad2b..eb8a79561 100644 --- a/nodedb-graph/src/lib.rs +++ b/nodedb-graph/src/lib.rs @@ -12,11 +12,15 @@ pub mod csr; pub mod error; +pub mod params; pub mod sharded; pub mod traversal; +pub mod traversal_options; pub use csr::extract_weight_from_properties; pub use csr::{CsrIndex, Direction, LocalNodeId}; pub use csr::{DegreeHistogram, GraphStatistics, LabelStats}; pub use error::{GraphError, MAX_EDGE_LABELS, MAX_NODES_PER_CSR}; +pub use params::{AlgoColumnType, AlgoParams, GraphAlgorithm}; pub use sharded::ShardedCsrIndex; +pub use traversal_options::{GraphResponseMeta, GraphTraversalOptions, MAX_GRAPH_TRAVERSAL_DEPTH}; diff --git a/nodedb-graph/src/params.rs b/nodedb-graph/src/params.rs new file mode 100644 index 000000000..11d93f2e0 --- /dev/null +++ b/nodedb-graph/src/params.rs @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Graph algorithm enum and parameter bag. +//! +//! `GraphAlgorithm` identifies which algorithm to run. +//! `AlgoParams` carries the union of all algorithm parameters — each +//! algorithm validates and extracts what it needs. + +use serde::{Deserialize, Serialize}; + +/// Supported graph algorithms. +/// +/// Each variant maps to a standalone algorithm implementation under +/// `src/engine/graph/algo/`. Used by `PhysicalPlan::GraphAlgo` to +/// identify which algorithm to dispatch. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +#[msgpack(c_enum)] +pub enum GraphAlgorithm { + /// PageRank — link analysis (power iteration). + PageRank, + /// Weakly Connected Components — union-find. + Wcc, + /// Community Detection — label propagation. + LabelPropagation, + /// Local Clustering Coefficient — per-node triangle density. + Lcc, + /// Single-Source Shortest Path — weighted Dijkstra. + Sssp, + /// Betweenness Centrality — Brandes' algorithm. + Betweenness, + /// Closeness Centrality — inverse distance sum. + Closeness, + /// Harmonic Centrality — inverse distance harmonic mean. + Harmonic, + /// Degree Centrality — normalized degree. + Degree, + /// Louvain Community Detection — modularity optimization. + Louvain, + /// Triangle Counting — global or per-node. + Triangles, + /// Graph Diameter / Eccentricity. + Diameter, + /// k-Core Decomposition — peeling algorithm. + KCore, +} + +impl GraphAlgorithm { + /// Human-readable name for progress reporting and result column headers. + pub fn name(&self) -> &'static str { + match self { + Self::PageRank => "pagerank", + Self::Wcc => "wcc", + Self::LabelPropagation => "label_propagation", + Self::Lcc => "lcc", + Self::Sssp => "sssp", + Self::Betweenness => "betweenness", + Self::Closeness => "closeness", + Self::Harmonic => "harmonic", + Self::Degree => "degree", + Self::Louvain => "louvain", + Self::Triangles => "triangles", + Self::Diameter => "diameter", + Self::KCore => "kcore", + } + } + + /// Whether this algorithm is iterative (emits progress per iteration). + pub fn is_iterative(&self) -> bool { + matches!( + self, + Self::PageRank | Self::LabelPropagation | Self::Louvain + ) + } + + /// Result column schema: `(column_name, column_type)`. + /// + /// Used by the Arrow result builder to construct RecordBatches and by + /// the DDL layer to advertise result columns. + pub fn result_schema(&self) -> &'static [(&'static str, AlgoColumnType)] { + use AlgoColumnType::*; + match self { + Self::PageRank => &[("node_id", Text), ("rank", Float64)], + Self::Wcc => &[("node_id", Text), ("component_id", Int64)], + Self::LabelPropagation => &[("node_id", Text), ("community_id", Int64)], + Self::Lcc => &[("node_id", Text), ("coefficient", Float64)], + Self::Sssp => &[("node_id", Text), ("distance", Float64)], + Self::Betweenness => &[("node_id", Text), ("centrality", Float64)], + Self::Closeness => &[("node_id", Text), ("centrality", Float64)], + Self::Harmonic => &[("node_id", Text), ("centrality", Float64)], + Self::Degree => &[("node_id", Text), ("centrality", Float64)], + Self::Louvain => &[ + ("node_id", Text), + ("community_id", Int64), + ("modularity", Float64), + ], + Self::Triangles => &[("node_id", Text), ("triangles", Int64)], + Self::Diameter => &[("diameter", Int64), ("radius", Int64)], + Self::KCore => &[("node_id", Text), ("coreness", Int64)], + } + } +} + +/// Column type for algorithm result schemas. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlgoColumnType { + Text, + Float64, + Int64, +} + +/// Generic parameter bag for all graph algorithms. +/// +/// Each algorithm validates and extracts the parameters it needs, +/// ignoring the rest. Unknown parameters are silently ignored rather +/// than rejected — this allows forward-compatible DDL extensions. +#[derive( + Debug, + Clone, + Default, + PartialEq, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct AlgoParams { + /// Target collection name. + pub collection: String, + + /// Optional edge label filter — only edges with this label are traversed. + pub edge_label: Option, + + /// PageRank damping factor (default: 0.85). + pub damping: Option, + + /// Maximum iterations for iterative algorithms (PageRank, LabelProp, Louvain). + pub max_iterations: Option, + + /// Convergence tolerance for PageRank (default: 1e-7). + pub tolerance: Option, + + /// Source node for SSSP. + pub source_node: Option, + + /// Sample size for approximate centrality (betweenness, closeness). + /// `None` = exact computation. + pub sample_size: Option, + + /// Direction for degree centrality: "in", "out", "both". + pub direction: Option, + + /// Resolution parameter for Louvain (default: 1.0). + pub resolution: Option, + + /// Mode for triangle counting / diameter: "global", "per_node", "exact", "approximate". + pub mode: Option, +} + +impl AlgoParams { + /// PageRank damping factor, validated to (0.0, 1.0). + pub fn damping_factor(&self) -> f64 { + self.damping.unwrap_or(0.85).clamp(0.01, 0.99) + } + + /// Max iterations with sensible default per algorithm. + /// + /// Defence-in-depth: the pgwire handler clamps `ITERATIONS` to + /// `MAX_ITERATIONS_CAP` before dispatch, but any alternate entry + /// point (native protocol, internal dispatch) also lands here, so + /// we enforce the ceiling at the engine boundary too. + pub fn iterations(&self, default: usize) -> usize { + const ITERATIONS_HARD_CAP: usize = 1_000; + self.max_iterations + .unwrap_or(default) + .clamp(1, ITERATIONS_HARD_CAP) + } + + /// Convergence tolerance, validated to positive. + pub fn convergence_tolerance(&self) -> f64 { + let t = self.tolerance.unwrap_or(1e-7); + if t > 0.0 { t } else { 1e-7 } + } + + /// Louvain resolution parameter, validated to positive. + pub fn louvain_resolution(&self) -> f64 { + let r = self.resolution.unwrap_or(1.0); + if r > 0.0 { r } else { 1.0 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sonic_rs; + + #[test] + fn algorithm_names() { + assert_eq!(GraphAlgorithm::PageRank.name(), "pagerank"); + assert_eq!(GraphAlgorithm::Wcc.name(), "wcc"); + assert_eq!(GraphAlgorithm::KCore.name(), "kcore"); + } + + #[test] + fn iterative_algorithms() { + assert!(GraphAlgorithm::PageRank.is_iterative()); + assert!(GraphAlgorithm::LabelPropagation.is_iterative()); + assert!(GraphAlgorithm::Louvain.is_iterative()); + assert!(!GraphAlgorithm::Wcc.is_iterative()); + assert!(!GraphAlgorithm::Sssp.is_iterative()); + } + + #[test] + fn result_schema_columns() { + let schema = GraphAlgorithm::PageRank.result_schema(); + assert_eq!(schema.len(), 2); + assert_eq!(schema[0], ("node_id", AlgoColumnType::Text)); + assert_eq!(schema[1], ("rank", AlgoColumnType::Float64)); + } + + #[test] + fn louvain_schema_has_three_columns() { + let schema = GraphAlgorithm::Louvain.result_schema(); + assert_eq!(schema.len(), 3); + } + + #[test] + fn params_defaults() { + let p = AlgoParams::default(); + assert_eq!(p.damping_factor(), 0.85); + assert_eq!(p.iterations(20), 20); + assert_eq!(p.convergence_tolerance(), 1e-7); + assert_eq!(p.louvain_resolution(), 1.0); + } + + #[test] + fn params_clamping() { + let p = AlgoParams { + damping: Some(2.0), + tolerance: Some(-1.0), + resolution: Some(0.0), + ..Default::default() + }; + assert_eq!(p.damping_factor(), 0.99); + assert_eq!(p.convergence_tolerance(), 1e-7); + assert_eq!(p.louvain_resolution(), 1.0); + } + + #[test] + fn params_serde_roundtrip() { + let p = AlgoParams { + collection: "users".into(), + damping: Some(0.9), + max_iterations: Some(30), + source_node: Some("alice".into()), + ..Default::default() + }; + let json = sonic_rs::to_string(&p).unwrap(); + let p2: AlgoParams = sonic_rs::from_str(&json).unwrap(); + assert_eq!(p2.collection, "users"); + assert_eq!(p2.damping, Some(0.9)); + assert_eq!(p2.max_iterations, Some(30)); + assert_eq!(p2.source_node, Some("alice".into())); + } +} diff --git a/nodedb-graph/src/traversal_options.rs b/nodedb-graph/src/traversal_options.rs new file mode 100644 index 000000000..f3f3e8b2b --- /dev/null +++ b/nodedb-graph/src/traversal_options.rs @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Per-query graph traversal configuration. +//! +//! Adaptive fan-out uses a two-tier limit with optional graceful +//! degradation instead of a hard kill. + +/// Largest accepted value for any graph-DSL depth parameter +/// (`DEPTH`, `MAX_DEPTH`, `EXPANSION_DEPTH`). +/// +/// Enforced at every ingress (pgwire, native protocol) and at the +/// engine boundary as defence-in-depth so a single statement cannot +/// saturate `cross_core_bfs`, `csr.shortest_path`, or the subgraph +/// materializer with an unbounded fan-out per hop. +pub const MAX_GRAPH_TRAVERSAL_DEPTH: usize = 64; + +use serde::{Deserialize, Serialize}; + +/// Per-query graph traversal configuration. +/// +/// Controls fan-out limits, partial result handling, and visited node caps +/// for scatter-gather graph queries across shards. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct GraphTraversalOptions { + /// Soft warning threshold (shards per hop). + /// + /// When the number of shards reached in a single hop exceeds this value, + /// a fan-out warning is emitted but execution continues. + /// Default: 12 + pub fan_out_soft: u16, + + /// Hard limit (shards per hop). + /// + /// Maximum number of shards that can be queried in a single hop. + /// If exceeded and `fan_out_partial` is false, returns FAN_OUT_EXCEEDED error. + /// Default: 16 + pub fan_out_hard: u16, + + /// If true, return partial results instead of FAN_OUT_EXCEEDED error. + /// + /// When the hard limit is exceeded, instead of failing with FAN_OUT_EXCEEDED, + /// this flag allows the response to be marked as truncated with partial results. + /// Default: false + pub fan_out_partial: bool, + + /// Cap on total visited nodes across all shards. + /// + /// Once this limit is reached, no further node exploration occurs. + /// Default: 100_000 + pub max_visited: usize, +} + +impl Default for GraphTraversalOptions { + fn default() -> Self { + Self { + fan_out_soft: 12, + fan_out_hard: 16, + fan_out_partial: false, + max_visited: 100_000, + } + } +} + +impl GraphTraversalOptions { + /// Create a new `GraphTraversalOptions` with default values. + pub fn new() -> Self { + Self::default() + } +} + +/// Response metadata for scatter-gather graph query results. +/// +/// Tracks how many shards were reached, skipped, and whether results are +/// complete or truncated due to adaptive fan-out limits. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GraphResponseMeta { + /// Number of shards that were queried and returned results. + pub shards_reached: u16, + + /// Number of shards that were skipped due to fan-out limits. + pub shards_skipped: u16, + + /// Whether results are incomplete (true) or complete (false). + pub truncated: bool, + + /// Fan-out warning message if soft limit was exceeded. + /// + /// Format: "X/Y" where X is shards_reached and Y is fan_out_hard. + /// None if no warning. + pub fan_out_warning: Option, + + /// Whether results gathered beyond the soft limit are approximate. + /// + /// Set to true when shards_reached > fan_out_soft. + pub approximate: bool, +} + +impl GraphResponseMeta { + /// Check if this response has no warnings or truncation. + /// + /// Returns true if: + /// - No fan-out warning + /// - Not truncated + /// - Not approximate + pub fn is_clean(&self) -> bool { + self.fan_out_warning.is_none() && !self.truncated && !self.approximate + } + + /// Create response metadata with a fan-out warning. + /// + /// Indicates that the soft limit was exceeded but execution continued. + /// Creates a warning string like "12/16" showing reached vs hard limit. + pub fn with_warning(shards_reached: u16, shards_skipped: u16, fan_out_hard: u16) -> Self { + Self { + shards_reached, + shards_skipped, + truncated: false, + fan_out_warning: Some(format!("{}/{}", shards_reached, fan_out_hard)), + approximate: true, + } + } + + /// Create response metadata for truncated results. + /// + /// Indicates that results were incomplete due to fan-out limits. + pub fn with_truncation(shards_reached: u16, shards_skipped: u16) -> Self { + Self { + shards_reached, + shards_skipped, + truncated: true, + fan_out_warning: None, + approximate: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sonic_rs; + + #[test] + fn default_options_have_expected_values() { + let opts = GraphTraversalOptions::default(); + assert_eq!(opts.fan_out_soft, 12); + assert_eq!(opts.fan_out_hard, 16); + assert!(!opts.fan_out_partial); + assert_eq!(opts.max_visited, 100_000); + } + + #[test] + fn new_returns_defaults() { + let opts = GraphTraversalOptions::new(); + assert_eq!(opts, GraphTraversalOptions::default()); + } + + #[test] + fn default_meta_is_clean() { + let meta = GraphResponseMeta::default(); + assert!(meta.is_clean()); + assert_eq!(meta.shards_reached, 0); + assert_eq!(meta.shards_skipped, 0); + assert!(!meta.truncated); + assert!(meta.fan_out_warning.is_none()); + assert!(!meta.approximate); + } + + #[test] + fn with_warning_generates_correct_string() { + let meta = GraphResponseMeta::with_warning(12, 4, 16); + assert_eq!(meta.shards_reached, 12); + assert_eq!(meta.shards_skipped, 4); + assert!(!meta.truncated); + assert_eq!(meta.fan_out_warning, Some("12/16".to_string())); + assert!(meta.approximate); + } + + #[test] + fn with_truncation_sets_flags() { + let meta = GraphResponseMeta::with_truncation(10, 6); + assert_eq!(meta.shards_reached, 10); + assert_eq!(meta.shards_skipped, 6); + assert!(meta.truncated); + assert!(meta.fan_out_warning.is_none()); + assert!(meta.approximate); + } + + #[test] + fn with_warning_is_not_clean() { + let meta = GraphResponseMeta::with_warning(12, 4, 16); + assert!(!meta.is_clean()); + } + + #[test] + fn with_truncation_is_not_clean() { + let meta = GraphResponseMeta::with_truncation(10, 6); + assert!(!meta.is_clean()); + } + + #[test] + fn serialization_roundtrip() { + let opts = GraphTraversalOptions { + fan_out_soft: 8, + fan_out_hard: 12, + fan_out_partial: true, + max_visited: 50_000, + }; + let json = sonic_rs::to_string(&opts).unwrap(); + let deserialized: GraphTraversalOptions = sonic_rs::from_str(&json).unwrap(); + assert_eq!(opts.fan_out_soft, deserialized.fan_out_soft); + assert_eq!(opts.fan_out_hard, deserialized.fan_out_hard); + assert_eq!(opts.fan_out_partial, deserialized.fan_out_partial); + assert_eq!(opts.max_visited, deserialized.max_visited); + } + + #[test] + fn meta_serialization_roundtrip() { + let meta = GraphResponseMeta::with_warning(15, 1, 16); + let json = sonic_rs::to_string(&meta).unwrap(); + let deserialized: GraphResponseMeta = sonic_rs::from_str(&json).unwrap(); + assert_eq!(meta.shards_reached, deserialized.shards_reached); + assert_eq!(meta.shards_skipped, deserialized.shards_skipped); + assert_eq!(meta.truncated, deserialized.truncated); + assert_eq!(meta.fan_out_warning, deserialized.fan_out_warning); + assert_eq!(meta.approximate, deserialized.approximate); + } +} diff --git a/nodedb-physical/Cargo.toml b/nodedb-physical/Cargo.toml new file mode 100644 index 000000000..95a64b896 --- /dev/null +++ b/nodedb-physical/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "nodedb-physical" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license = "BUSL-1.1" +readme = "README.md" +repository.workspace = true +homepage.workspace = true +description = "Shared PhysicalTask IR and SqlPlan-to-PhysicalPlan converter for NodeDB Origin and Lite" +documentation = "https://docs.rs/nodedb-physical" + +[dependencies] +nodedb-array = { workspace = true } +nodedb-graph = { workspace = true } +nodedb-query = { workspace = true } +nodedb-sql = { workspace = true } +nodedb-types = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +zerompk = { workspace = true } diff --git a/nodedb-physical/src/convert_context.rs b/nodedb-physical/src/convert_context.rs new file mode 100644 index 000000000..cbcb6014d --- /dev/null +++ b/nodedb-physical/src/convert_context.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Deployment-neutral context threaded through the shared `SqlPlan → +//! PhysicalPlan` converter helpers in `crate::convert`. +//! +//! Carries only fields both Origin and Lite can supply. Origin-only state +//! (WAL handle, array catalog, credential store, retention registries) lives +//! on Origin's wrapper context and is consumed by Origin-only converter +//! arms (array DDL/DML, timeseries-retention tier-down) that the shared +//! helpers never touch. + +use std::sync::Arc; + +use nodedb_types::DatabaseId; + +use crate::SurrogateAssigner; + +/// Inputs every shared converter helper needs. +/// +/// Origin and Lite construct this with the same shape; their visitor +/// implementations wrap it (Origin adds catalog/WAL handles, Lite passes +/// it through unchanged). +pub struct SharedConvertContext { + /// Database scope for vShard computation. All + /// `VShardId::from_collection_in_database` calls inside the converter + /// must use this value so collections in different databases route to + /// distinct shards. + pub database_id: DatabaseId, + + /// Per-tenant maximum vector dimension (0 = unlimited). Checked during + /// `VectorPrimaryInsert` lowering. + pub max_vector_dim: u32, + + /// `true` when the node is running in cluster mode with a live + /// topology. Origin's array DML/query converters emit `ClusterArray` + /// variants when set; single-node Origin and Lite leave this `false`. + pub cluster_enabled: bool, + + /// CP-side surrogate assigner. Threaded into INSERT/UPSERT/KV-INSERT + /// helpers to bind `(collection, pk_bytes)` → `Surrogate` before the + /// op crosses any plane boundary. `None` only for sub-planners that + /// never lower to surrogate-bearing variants. + pub surrogate_assigner: Option>, +} diff --git a/nodedb-physical/src/error.rs b/nodedb-physical/src/error.rs new file mode 100644 index 000000000..d5cc78e5f --- /dev/null +++ b/nodedb-physical/src/error.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Error type for the shared SqlPlan → PhysicalPlan converter helpers. +//! +//! The variant set matches what the converter actually produces; deeper +//! engine-specific failures are re-wrapped at the deployment boundary. +//! Origin maps `ConvertError → nodedb::Error`; Lite will map it to its +//! own error type. + +use crate::surrogate::SurrogateAssignError; + +#[derive(Debug, thiserror::Error)] +pub enum ConvertError { + /// The plan shape is invalid (unsupported combination, missing field, etc.). + #[error("plan error: {0}")] + PlanError(String), + + /// A client-facing request is malformed (bad cast, wrong literal type, etc.). + #[error("bad request: {0}")] + BadRequest(String), + + /// A defensive cap was exceeded (max fan-out, depth, columns, etc.). + #[error("{limit_name} exceeded: {value} > {max}")] + LimitExceeded { + limit_name: &'static str, + value: u64, + max: u64, + }, + + /// Surrogate allocation failed. + #[error(transparent)] + Surrogate(#[from] SurrogateAssignError), + + /// Serialization failure (msgpack encoding of filters, projections, etc.). + #[error("serialization: {0}")] + Serialization(String), + + /// Catch-all for converter-internal failures that don't fit the above + /// and that we don't want to leak from the shared crate as untyped strings. + /// Carries a `'static` short reason; full detail propagates via `cause`. + #[error("converter internal: {0}")] + Other(String), +} diff --git a/nodedb-physical/src/lib.rs b/nodedb-physical/src/lib.rs new file mode 100644 index 000000000..81a31cda4 --- /dev/null +++ b/nodedb-physical/src/lib.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Shared physical-plan layer for NodeDB. +//! +//! Owns the `PhysicalTask` IR and the `SqlPlan → PhysicalPlan` converter. +//! Origin (server) and Lite (embedded) both consume the same `PhysicalTask`; +//! per-deployment executors implement `PhysicalTaskVisitor` against their own +//! storage backends. Origin-specific concerns (vShard routing, MessagePack +//! pre-serialisation, cross-plane envelope fields) live in an Origin-side +//! wrapper that contains a `PhysicalTask`, not in this crate. + +pub mod convert_context; +pub mod error; +pub mod physical_plan; +pub mod physical_task; +pub mod surrogate; +pub mod visitor; + +pub use convert_context::SharedConvertContext; +pub use error::ConvertError; +pub use surrogate::{SurrogateAssignError, SurrogateAssigner}; +pub use visitor::{PhysicalTaskVisitor, dispatch}; diff --git a/nodedb/src/bridge/physical_plan/array.rs b/nodedb-physical/src/physical_plan/array.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/array.rs rename to nodedb-physical/src/physical_plan/array.rs diff --git a/nodedb/src/bridge/physical_plan/cluster_array.rs b/nodedb-physical/src/physical_plan/cluster_array.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/cluster_array.rs rename to nodedb-physical/src/physical_plan/cluster_array.rs diff --git a/nodedb/src/bridge/physical_plan/columnar.rs b/nodedb-physical/src/physical_plan/columnar.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/columnar.rs rename to nodedb-physical/src/physical_plan/columnar.rs diff --git a/nodedb/src/bridge/physical_plan/crdt.rs b/nodedb-physical/src/physical_plan/crdt.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/crdt.rs rename to nodedb-physical/src/physical_plan/crdt.rs diff --git a/nodedb-physical/src/physical_plan/document/enforcement_types.rs b/nodedb-physical/src/physical_plan/document/enforcement_types.rs new file mode 100644 index 000000000..15627f052 --- /dev/null +++ b/nodedb-physical/src/physical_plan/document/enforcement_types.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Pure-data types shared by enforcement logic across engines. +//! +//! These types carry no behavior — they are plain data structs that cross +//! the SPSC bridge as part of `EnforcementOptions`. Defined here so that +//! `DocumentOp` (and eventually `EnforcementOptions`) can migrate to this +//! shared crate without pulling in Origin-internal modules. + +/// Parsed retention duration with calendar-accurate units. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct RetentionDuration { + pub count: u32, + pub unit: RetentionUnit, +} + +/// Calendar-accurate duration units. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +#[msgpack(c_enum)] +pub enum RetentionUnit { + Seconds, + Minutes, + Hours, + Days, + Weeks, + Months, + Years, +} + +/// State transition constraint: column value can only change along declared paths. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct StateTransitionDef { + pub name: String, + pub column: String, + pub transitions: Vec, +} + +/// A single allowed state transition, optionally guarded by a role. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct TransitionRule { + pub from: String, + pub to: String, + pub required_role: Option, +} + +/// Transition check predicate: evaluated on UPDATE with OLD and NEW access. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct TransitionCheckDef { + pub name: String, + pub predicate: nodedb_query::expr::SqlExpr, +} diff --git a/nodedb/src/bridge/physical_plan/document/merge_types.rs b/nodedb-physical/src/physical_plan/document/merge_types.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/document/merge_types.rs rename to nodedb-physical/src/physical_plan/document/merge_types.rs diff --git a/nodedb/src/bridge/physical_plan/document/mod.rs b/nodedb-physical/src/physical_plan/document/mod.rs similarity index 68% rename from nodedb/src/bridge/physical_plan/document/mod.rs rename to nodedb-physical/src/physical_plan/document/mod.rs index 094176d6c..7956950f8 100644 --- a/nodedb/src/bridge/physical_plan/document/mod.rs +++ b/nodedb-physical/src/physical_plan/document/mod.rs @@ -2,14 +2,20 @@ //! Document / sparse engine operations dispatched to the Data Plane. +pub mod enforcement_types; pub mod merge_types; pub mod op; pub mod types; +pub mod update_value; +pub use enforcement_types::{ + RetentionDuration, RetentionUnit, StateTransitionDef, TransitionCheckDef, TransitionRule, +}; pub use merge_types::{MergeActionOp, MergeClauseKind as MergeClauseKindOp, MergeClauseOp}; pub use op::DocumentOp; pub use types::{ BalancedDef, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding, PeriodLockConfig, RegisteredIndex, RegisteredIndexState, ReturningColumns, ReturningItem, ReturningSpec, - StorageMode, UpdateValue, + StorageMode, }; +pub use update_value::UpdateValue; diff --git a/nodedb/src/bridge/physical_plan/document/op.rs b/nodedb-physical/src/physical_plan/document/op.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/document/op.rs rename to nodedb-physical/src/physical_plan/document/op.rs diff --git a/nodedb/src/bridge/physical_plan/document/types.rs b/nodedb-physical/src/physical_plan/document/types.rs similarity index 79% rename from nodedb/src/bridge/physical_plan/document/types.rs rename to nodedb-physical/src/physical_plan/document/types.rs index f76404b89..23675149f 100644 --- a/nodedb/src/bridge/physical_plan/document/types.rs +++ b/nodedb-physical/src/physical_plan/document/types.rs @@ -4,53 +4,7 @@ use nodedb_types::columnar::StrictSchema; -/// Right-hand side of an UPDATE ... SET field = <...> assignment. -/// -/// The planner turns each assignment into one of these before it crosses -/// the SPSC bridge: -/// -/// - `Literal` — pre-encoded msgpack bytes for constant RHS. This is the -/// fast path: the Data Plane can merge these at the binary level for -/// non-strict collections without decoding the current row. -/// - `Expr` — a `SqlExpr` that must be evaluated against the *current* -/// document at apply time. Used for arithmetic (`col + 1`), functions -/// (`LOWER(col)`, `NOW()`), `CASE`, concatenation, and anything else -/// whose result depends on the row being updated. -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum UpdateValue { - Literal(Vec), - Expr(crate::bridge::expr_eval::SqlExpr), -} - -impl zerompk::ToMessagePack for UpdateValue { - fn write(&self, writer: &mut W) -> zerompk::Result<()> { - writer.write_array_len(2)?; - match self { - UpdateValue::Literal(bytes) => { - writer.write_u8(0)?; - bytes.write(writer) - } - UpdateValue::Expr(expr) => { - writer.write_u8(1)?; - expr.write(writer) - } - } - } -} - -impl<'a> zerompk::FromMessagePack<'a> for UpdateValue { - fn read>(reader: &mut R) -> zerompk::Result { - reader.check_array_len(2)?; - let tag = reader.read_u8()?; - match tag { - 0 => Ok(UpdateValue::Literal(Vec::::read(reader)?)), - 1 => Ok(UpdateValue::Expr(crate::bridge::expr_eval::SqlExpr::read( - reader, - )?)), - _ => Err(zerompk::Error::InvalidMarker(tag)), - } - } -} +pub use crate::physical_plan::document::UpdateValue; /// Storage encoding mode for a document collection. /// @@ -108,16 +62,16 @@ pub struct EnforcementOptions { /// Data retention duration. DELETE rejected if row age < this. /// Uses calendar-accurate arithmetic (months/years not approximated). #[serde(default)] - pub retention: Option, + pub retention: Option, /// Whether any legal hold is active. DELETE unconditionally rejected. #[serde(default)] pub has_legal_hold: bool, /// State transition constraints: column value transitions must follow declared paths. #[serde(default)] - pub state_constraints: Vec, + pub state_constraints: Vec, /// Transition check predicates: OLD/NEW expressions evaluated on UPDATE. #[serde(default)] - pub transition_checks: Vec, + pub transition_checks: Vec, /// Materialized sum bindings where THIS collection is the source. /// On INSERT, each binding triggers an atomic balance update on the target. #[serde(default)] @@ -143,7 +97,7 @@ pub struct GeneratedColumnSpec { /// Column name for the generated field. pub name: String, /// Expression to evaluate against the document. - pub expr: crate::bridge::expr_eval::SqlExpr, + pub expr: nodedb_query::expr::SqlExpr, /// Column names this expression depends on (for UPDATE recomputation). pub depends_on: Vec, } @@ -168,7 +122,7 @@ pub struct MaterializedSumBinding { /// Column on source row that joins to target's document ID (e.g. `account_id`). pub join_column: String, /// Expression evaluated against the source INSERT row to compute the delta. - pub value_expr: crate::bridge::expr_eval::SqlExpr, + pub value_expr: nodedb_query::expr::SqlExpr, } /// Period lock configuration propagated to Data Plane. @@ -295,8 +249,8 @@ impl ReturningSpec { } /// Build state for a secondary index propagated from catalog to the Data -/// Plane. Mirrors [`crate::control::security::catalog::IndexBuildState`] -/// but lives in the bridge so the Data Plane doesn't depend on catalog types. +/// Plane. Mirrors the catalog `IndexBuildState` but lives in the bridge so +/// the Data Plane doesn't depend on catalog types. #[derive( Debug, Clone, diff --git a/nodedb-physical/src/physical_plan/document/update_value.rs b/nodedb-physical/src/physical_plan/document/update_value.rs new file mode 100644 index 000000000..c620f0b13 --- /dev/null +++ b/nodedb-physical/src/physical_plan/document/update_value.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Right-hand side of an UPDATE ... SET field = <...> assignment. + +/// Right-hand side of an UPDATE ... SET field = <...> assignment. +/// +/// The planner turns each assignment into one of these before it crosses +/// the SPSC bridge: +/// +/// - `Literal` — pre-encoded msgpack bytes for constant RHS. This is the +/// fast path: the Data Plane can merge these at the binary level for +/// non-strict collections without decoding the current row. +/// - `Expr` — a `SqlExpr` that must be evaluated against the *current* +/// document at apply time. Used for arithmetic (`col + 1`), functions +/// (`LOWER(col)`, `NOW()`), `CASE`, concatenation, and anything else +/// whose result depends on the row being updated. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum UpdateValue { + Literal(Vec), + Expr(nodedb_query::expr::SqlExpr), +} + +impl zerompk::ToMessagePack for UpdateValue { + fn write(&self, writer: &mut W) -> zerompk::Result<()> { + writer.write_array_len(2)?; + match self { + UpdateValue::Literal(bytes) => { + writer.write_u8(0)?; + bytes.write(writer) + } + UpdateValue::Expr(expr) => { + writer.write_u8(1)?; + expr.write(writer) + } + } + } +} + +impl<'a> zerompk::FromMessagePack<'a> for UpdateValue { + fn read>(reader: &mut R) -> zerompk::Result { + reader.check_array_len(2)?; + let tag = reader.read_u8()?; + match tag { + 0 => Ok(UpdateValue::Literal(Vec::::read(reader)?)), + 1 => Ok(UpdateValue::Expr(nodedb_query::expr::SqlExpr::read( + reader, + )?)), + _ => Err(zerompk::Error::InvalidMarker(tag)), + } + } +} diff --git a/nodedb/src/bridge/physical_plan/graph.rs b/nodedb-physical/src/physical_plan/graph.rs similarity index 94% rename from nodedb/src/bridge/physical_plan/graph.rs rename to nodedb-physical/src/physical_plan/graph.rs index f9c4ac2c1..2a53bb950 100644 --- a/nodedb/src/bridge/physical_plan/graph.rs +++ b/nodedb-physical/src/physical_plan/graph.rs @@ -2,12 +2,9 @@ //! Graph engine operations dispatched to the Data Plane. +use nodedb_graph::{AlgoParams, Direction, GraphAlgorithm, GraphTraversalOptions}; use nodedb_types::{Surrogate, SurrogateBitmap}; -use crate::engine::graph::algo::params::{AlgoParams, GraphAlgorithm}; -use crate::engine::graph::edge_store::Direction; -use crate::engine::graph::traversal_options::GraphTraversalOptions; - /// One edge in an `EdgePutBatch` / `EdgeDeleteBatch`. /// /// `src_surrogate` / `dst_surrogate` carry the global row identity for the @@ -204,8 +201,7 @@ pub enum GraphOp { /// Resolves edges whose latest version with `system_from <= system_as_of_ms` /// (converted to HLC ordinal) is not a sentinel, optionally also filtering /// by `valid_from_ms <= valid_at_ms < valid_until_ms`. The handler calls - /// [`ceiling_resolve_edge`](crate::engine::graph::edge_store::EdgeStore::ceiling_resolve_edge) - /// per candidate base edge. + /// `ceiling_resolve_edge` per candidate base edge. TemporalNeighbors { /// Edge store is collection-scoped; current-state `Neighbors` reads /// the tenant-wide CSR, but the versioned key layout is @@ -226,8 +222,8 @@ pub enum GraphOp { /// Bitemporal graph algorithm execution. /// /// Identical to `Algo` but builds its CSR snapshot via - /// [`CsrSnapshot::from_edge_store_as_of`](crate::engine::graph::olap::snapshot::CsrSnapshot) - /// at the given system-time cutoff before running the algorithm. + /// `CsrSnapshot::from_edge_store_as_of` at the given system-time cutoff + /// before running the algorithm. TemporalAlgorithm { algorithm: GraphAlgorithm, params: AlgoParams, diff --git a/nodedb/src/bridge/physical_plan/kv.rs b/nodedb-physical/src/physical_plan/kv.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/kv.rs rename to nodedb-physical/src/physical_plan/kv.rs diff --git a/nodedb/src/bridge/physical_plan/meta.rs b/nodedb-physical/src/physical_plan/meta.rs similarity index 98% rename from nodedb/src/bridge/physical_plan/meta.rs rename to nodedb-physical/src/physical_plan/meta.rs index 86ade9012..68037cf22 100644 --- a/nodedb/src/bridge/physical_plan/meta.rs +++ b/nodedb-physical/src/physical_plan/meta.rs @@ -4,11 +4,9 @@ use std::collections::BTreeMap; -use nodedb_cluster::calvin::types::PassiveReadKey; -use nodedb_types::Value; - -use crate::engine::timeseries::continuous_agg::ContinuousAggregateDef; -use crate::types::{RequestId, TenantId}; +use nodedb_types::calvin::PassiveReadKey; +use nodedb_types::timeseries::continuous_agg::ContinuousAggregateDef; +use nodedb_types::{TenantId, Value}; /// Identity of a single key read by a passive Calvin participant. /// @@ -57,7 +55,9 @@ pub enum MetaOp { WalAppend { payload: Vec }, /// Cancellation signal. Data Plane stops the target at next safe point. - Cancel { target_request_id: RequestId }, + Cancel { + target_request_id: nodedb_types::id::RequestId, + }, /// Atomic transaction batch: execute all sub-plans atomically. TransactionBatch { plans: Vec }, diff --git a/nodedb/src/bridge/physical_plan/mod.rs b/nodedb-physical/src/physical_plan/mod.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/mod.rs rename to nodedb-physical/src/physical_plan/mod.rs diff --git a/nodedb/src/bridge/physical_plan/query.rs b/nodedb-physical/src/physical_plan/query.rs similarity index 96% rename from nodedb/src/bridge/physical_plan/query.rs rename to nodedb-physical/src/physical_plan/query.rs index 76a5b9160..115c8f12f 100644 --- a/nodedb/src/bridge/physical_plan/query.rs +++ b/nodedb-physical/src/physical_plan/query.rs @@ -21,7 +21,7 @@ pub struct AggregateSpec { /// Field name for simple field-based aggregates. `"*"` is used for COUNT(*). pub field: String, /// Optional expression to evaluate per-document before aggregating. - pub expr: Option, + pub expr: Option, } #[derive( @@ -101,19 +101,19 @@ pub enum QueryOp { /// Inline left sub-plan for multi-way joins. When set, the executor /// runs this sub-plan first and uses its result as the left side /// instead of scanning `left_collection`. - inline_left: Option>, + inline_left: Option>, /// Inline right sub-plan for scalar subqueries or other materialized /// small-side inputs. The Control Plane executes this plan first, /// merges it if needed, then embeds the result into `BroadcastJoin`. - inline_right: Option>, + inline_right: Option>, /// Bitmap-producer sub-plan for the left side. When set, the executor /// executes this plan first, collects surrogates from all returned rows, /// and injects the resulting bitmap into the probe-side prefilter before /// scanning. `None` = no bitmap pushdown for the left side. - inline_left_bitmap: Option>, + inline_left_bitmap: Option>, /// Bitmap-producer sub-plan for the right side. Same semantics as /// `inline_left_bitmap` but applied to the right (probe) collection. - inline_right_bitmap: Option>, + inline_right_bitmap: Option>, }, /// Inline hash join: both sides are pre-gathered msgpack data. @@ -255,7 +255,7 @@ pub enum QueryOp { /// `correlation_keys` are `(outer_col, inner_col)` equi-join pairs. LateralTopK { /// Sub-plan that produces the outer (driving) rows. - outer_plan: Box, + outer_plan: Box, /// Alias qualifying the outer columns in output rows. outer_alias: String, /// Inner collection to scan per outer row. @@ -286,7 +286,7 @@ pub enum QueryOp { /// `inner_collection`. LateralLoop { /// Sub-plan that produces the outer (driving) rows. - outer_plan: Box, + outer_plan: Box, /// Alias qualifying the outer columns in output rows. outer_alias: String, /// Inner collection to scan per outer row. diff --git a/nodedb/src/bridge/physical_plan/spatial.rs b/nodedb-physical/src/physical_plan/spatial.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/spatial.rs rename to nodedb-physical/src/physical_plan/spatial.rs diff --git a/nodedb/src/bridge/physical_plan/text.rs b/nodedb-physical/src/physical_plan/text.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/text.rs rename to nodedb-physical/src/physical_plan/text.rs diff --git a/nodedb/src/bridge/physical_plan/timeseries.rs b/nodedb-physical/src/physical_plan/timeseries.rs similarity index 100% rename from nodedb/src/bridge/physical_plan/timeseries.rs rename to nodedb-physical/src/physical_plan/timeseries.rs diff --git a/nodedb/src/bridge/physical_plan/vector.rs b/nodedb-physical/src/physical_plan/vector.rs similarity index 99% rename from nodedb/src/bridge/physical_plan/vector.rs rename to nodedb-physical/src/physical_plan/vector.rs index 15a803cbb..b26a017d6 100644 --- a/nodedb/src/bridge/physical_plan/vector.rs +++ b/nodedb-physical/src/physical_plan/vector.rs @@ -4,7 +4,7 @@ use nodedb_types::{Surrogate, SurrogateBitmap, vector_distance::DistanceMetric}; -use crate::bridge::envelope::PhysicalPlan; +use crate::physical_plan::PhysicalPlan; /// Vector engine physical operations. #[derive( diff --git a/nodedb-physical/src/physical_plan/wire.rs b/nodedb-physical/src/physical_plan/wire.rs new file mode 100644 index 000000000..c87f6fd95 --- /dev/null +++ b/nodedb-physical/src/physical_plan/wire.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Wire-format encode/decode helpers for PhysicalPlan. +//! +//! MessagePack encoding via zerompk. Used by the cluster layer to ship +//! physical plans over the wire as part of `ExecuteRequest` RPC. + +use super::PhysicalPlan; + +/// Errors produced by the wire encode/decode helpers. Self-contained so this +/// module can move into the shared `nodedb-physical` crate without dragging +/// Origin's `Error` type along. +#[derive(Debug, thiserror::Error)] +pub enum WireError { + #[error("{0}")] + InvalidPlan(&'static str), + #[error("plan codec: {0}")] + Codec(String), +} + +/// Encode a `PhysicalPlan` to MessagePack bytes. +/// +/// Returns an error for `ClusterArray` variants, which are handled on the +/// Control Plane and must never be shipped over the QUIC wire. +pub fn encode(plan: &PhysicalPlan) -> Result, WireError> { + if matches!(plan, PhysicalPlan::ClusterArray(_)) { + return Err(WireError::InvalidPlan( + "ClusterArray plans must not be sent over the wire", + )); + } + zerompk::to_msgpack_vec(plan).map_err(|e| WireError::Codec(format!("encode: {e}"))) +} + +/// Decode a `PhysicalPlan` from MessagePack bytes. +pub fn decode(bytes: &[u8]) -> Result { + zerompk::from_msgpack(bytes).map_err(|e| WireError::Codec(format!("decode: {e}"))) +} + +/// Encode a `Vec` to MessagePack bytes. +/// +/// Used by the Calvin scheduler when building `TxClass::plans` bytes for a +/// cross-shard transaction that will be shipped through the sequencer. +pub fn encode_batch(plans: &Vec) -> Result, WireError> { + for plan in plans { + if matches!(plan, PhysicalPlan::ClusterArray(_)) { + return Err(WireError::InvalidPlan( + "ClusterArray plans must not be shipped via the sequencer", + )); + } + } + zerompk::to_msgpack_vec(plans).map_err(|e| WireError::Codec(format!("batch encode: {e}"))) +} + +/// Decode a `Vec` from MessagePack bytes. +/// +/// Used by the Calvin scheduler to decode the opaque `TxClass::plans` blob +/// into executable plans for dispatch via `MetaOp::CalvinExecute`. +pub fn decode_batch(bytes: &[u8]) -> Result, WireError> { + zerompk::from_msgpack(bytes).map_err(|e| WireError::Codec(format!("batch decode: {e}"))) +} diff --git a/nodedb/src/control/planner/physical.rs b/nodedb-physical/src/physical_task.rs similarity index 92% rename from nodedb/src/control/planner/physical.rs rename to nodedb-physical/src/physical_task.rs index 8b025f24b..de29d8938 100644 --- a/nodedb/src/control/planner/physical.rs +++ b/nodedb-physical/src/physical_task.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 -use crate::bridge::envelope::PhysicalPlan; -use crate::types::{DatabaseId, TenantId, VShardId}; +use nodedb_types::id::VShardId; +use nodedb_types::{DatabaseId, TenantId}; + +use crate::physical_plan::PhysicalPlan; /// Post-execution set operation for merging multi-task results. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/nodedb-physical/src/surrogate.rs b/nodedb-physical/src/surrogate.rs new file mode 100644 index 000000000..2db1b82af --- /dev/null +++ b/nodedb-physical/src/surrogate.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Surrogate-allocation contract used by the shared SqlPlan → PhysicalPlan +//! converter. Origin's WAL-durable, Raft-replicated allocator implements this +//! trait; Lite supplies its own local-monotonic implementation. +//! +//! Synchronous-only: the converter runs on the Control Plane in `Send + Sync` +//! code paths. Origin's async surrogate-fetch work stays internal to its impl +//! and is hidden behind this sync facade. + +use nodedb_types::Surrogate; + +/// Errors a [`SurrogateAssigner`] may return. +/// +/// The error surface is deliberately narrow — the converter does not need to +/// distinguish more cases. Origin's rich allocator errors collapse to one of +/// these at the trait boundary; the original error is preserved in +/// [`SurrogateAssignError::Backend`]'s message. +#[derive(Debug, thiserror::Error)] +pub enum SurrogateAssignError { + #[error("surrogate registry lock poisoned")] + LockPoisoned, + #[error("surrogate backend: {0}")] + Backend(String), +} + +/// Allocate stable, cross-engine surrogates for `(collection, pk_bytes)`. +/// +/// Implementations must be: +/// - **idempotent**: repeated calls for the same `(collection, pk_bytes)` +/// return the same `Surrogate`; +/// - **monotonic**: every allocated value is greater than every previously +/// allocated value within the same allocator; +/// - **`Send + Sync`**: the converter holds a reference across `await` +/// points on the Control Plane. +pub trait SurrogateAssigner: Send + Sync { + /// Highest surrogate ever issued by this assigner. `0` on a fresh + /// allocator. Used by CLONE DATABASE to capture an AS-OF cutoff. + fn current_hwm(&self) -> u32; + + /// Resolve `(collection, pk_bytes)` to a stable surrogate. Allocate + /// on the first call; return the persisted value on every subsequent + /// call (UPSERT preserves the surrogate). + fn assign(&self, collection: &str, pk_bytes: &[u8]) -> Result; +} diff --git a/nodedb-physical/src/visitor.rs b/nodedb-physical/src/visitor.rs new file mode 100644 index 000000000..dee36b9b9 --- /dev/null +++ b/nodedb-physical/src/visitor.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Executor parity contract for `PhysicalPlan`. +//! +//! Every NodeDB deployment that executes physical plans implements +//! [`PhysicalTaskVisitor`] — Origin's Data Plane handlers and Lite's +//! embedded executor both. The trait has no default methods, so adding +//! a new [`PhysicalPlan`] variant becomes a hard compile error on every +//! implementation until it is handled. +//! +//! Method-level granularity is one method per top-level `PhysicalPlan` +//! variant (engine family). Each method receives the variant's inner op +//! enum; the implementer pattern-matches as it sees fit. + +use crate::physical_plan::{ + ArrayOp, ClusterArrayOp, ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, PhysicalPlan, + QueryOp, SpatialOp, TextOp, TimeseriesOp, VectorOp, +}; + +/// Per-deployment executor for `PhysicalPlan`. +/// +/// Implementations decide their own `Output` and `Error`. Origin's Data +/// Plane handlers may return a row stream; Lite's executor may return a +/// boxed future to a `QueryResult`. The trait stays sync — async backends +/// box their futures and resolve at the call site, mirroring the pattern +/// used by `LiteVisitor` for `PlanVisitor`. +pub trait PhysicalTaskVisitor { + type Output; + type Error; + + fn vector(&mut self, op: &VectorOp) -> Result; + fn graph(&mut self, op: &GraphOp) -> Result; + fn document(&mut self, op: &DocumentOp) -> Result; + fn kv(&mut self, op: &KvOp) -> Result; + fn text(&mut self, op: &TextOp) -> Result; + fn columnar(&mut self, op: &ColumnarOp) -> Result; + fn timeseries(&mut self, op: &TimeseriesOp) -> Result; + fn spatial(&mut self, op: &SpatialOp) -> Result; + fn crdt(&mut self, op: &CrdtOp) -> Result; + fn query(&mut self, op: &QueryOp) -> Result; + fn meta(&mut self, op: &MetaOp) -> Result; + fn array(&mut self, op: &ArrayOp) -> Result; + fn cluster_array(&mut self, op: &ClusterArrayOp) -> Result; +} + +/// Dispatch `plan` to the matching method on `visitor`. +/// Adding a [`PhysicalPlan`] variant without a corresponding arm is a +/// compile error. +pub fn dispatch( + visitor: &mut V, + plan: &PhysicalPlan, +) -> Result { + match plan { + PhysicalPlan::Vector(op) => visitor.vector(op), + PhysicalPlan::Graph(op) => visitor.graph(op), + PhysicalPlan::Document(op) => visitor.document(op), + PhysicalPlan::Kv(op) => visitor.kv(op), + PhysicalPlan::Text(op) => visitor.text(op), + PhysicalPlan::Columnar(op) => visitor.columnar(op), + PhysicalPlan::Timeseries(op) => visitor.timeseries(op), + PhysicalPlan::Spatial(op) => visitor.spatial(op), + PhysicalPlan::Crdt(op) => visitor.crdt(op), + PhysicalPlan::Query(op) => visitor.query(op), + PhysicalPlan::Meta(op) => visitor.meta(op), + PhysicalPlan::Array(op) => visitor.array(op), + PhysicalPlan::ClusterArray(op) => visitor.cluster_array(op), + } +} diff --git a/nodedb-query/src/lib.rs b/nodedb-query/src/lib.rs index bfe959215..bc2c8d452 100644 --- a/nodedb-query/src/lib.rs +++ b/nodedb-query/src/lib.rs @@ -35,4 +35,7 @@ pub use fusion::{ reciprocal_rank_fusion_weighted, }; pub use scan_filter::ScanFilter; -pub use window::{FrameBound, WindowFrame, WindowFuncSpec, evaluate_window_functions}; +pub use window::{ + FrameBound, WindowError, WindowFrame, WindowFuncSpec, evaluate_window_functions, + evaluate_window_functions_value, +}; diff --git a/nodedb-query/src/window/aggregate.rs b/nodedb-query/src/window/aggregate.rs index 472e1736b..d50fc59f5 100644 --- a/nodedb-query/src/window/aggregate.rs +++ b/nodedb-query/src/window/aggregate.rs @@ -65,12 +65,12 @@ fn per_row_aggregate( } // Extract order-by values for RANGE numeric offsets. - let order_col = spec.order_by.first().map(|(col, _)| col.as_str()); + let order_expr = spec.order_by.first().map(|(expr, _)| expr); let order_values: Vec = indices .iter() .map(|&i| { - order_col - .map(|col| get_field(&rows[i].1, col)) + order_expr + .map(|expr| super::helpers::eval_expr_on_json(expr, &rows[i].1)) .unwrap_or(serde_json::Value::Null) }) .collect(); @@ -187,7 +187,7 @@ mod tests { vec![SqlExpr::Column(field.into())] }, partition_by: vec![], - order_by: vec![("n".into(), true)], + order_by: vec![(SqlExpr::Column("n".into()), true)], frame, } } diff --git a/nodedb-query/src/window/eval.rs b/nodedb-query/src/window/eval.rs index 71646cd91..f524c07f9 100644 --- a/nodedb-query/src/window/eval.rs +++ b/nodedb-query/src/window/eval.rs @@ -118,7 +118,7 @@ mod tests { alias: "rn".into(), func_name: "row_number".into(), args: vec![], - partition_by: vec!["dept".into()], + partition_by: vec![SqlExpr::Column("dept".into())], order_by: vec![], frame: WindowFrame::default(), }; @@ -136,8 +136,8 @@ mod tests { alias: "running_total".into(), func_name: "sum".into(), args: vec![SqlExpr::Column("salary".into())], - partition_by: vec!["dept".into()], - order_by: vec![("salary".into(), true)], + partition_by: vec![SqlExpr::Column("dept".into())], + order_by: vec![(SqlExpr::Column("salary".into()), true)], frame: WindowFrame::default(), }; evaluate_window_functions(&mut rows, &[spec]); @@ -156,7 +156,7 @@ mod tests { func_name: "percent_rank".into(), args: vec![], partition_by: vec![], - order_by: vec![("n".into(), true)], + order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; evaluate_window_functions(&mut rows, &[spec]); @@ -182,7 +182,7 @@ mod tests { func_name: "percent_rank".into(), args: vec![], partition_by: vec![], - order_by: vec![("n".into(), true)], + order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; evaluate_window_functions(&mut rows, &[spec]); @@ -200,7 +200,7 @@ mod tests { func_name: "cume_dist".into(), args: vec![], partition_by: vec![], - order_by: vec![("n".into(), true)], + order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; evaluate_window_functions(&mut rows, &[spec]); @@ -224,7 +224,7 @@ mod tests { func_name: "cume_dist".into(), args: vec![], partition_by: vec![], - order_by: vec![("n".into(), true)], + order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; evaluate_window_functions(&mut rows, &[spec]); @@ -246,7 +246,7 @@ mod tests { SqlExpr::Literal(nodedb_types::Value::Integer(2)), ], partition_by: vec![], - order_by: vec![("n".into(), true)], + order_by: vec![(SqlExpr::Column("n".into()), true)], frame: WindowFrame::default(), }; evaluate_window_functions(&mut rows, &[spec]); diff --git a/nodedb-query/src/window/helpers.rs b/nodedb-query/src/window/helpers.rs index ca60a8413..d52c304f3 100644 --- a/nodedb-query/src/window/helpers.rs +++ b/nodedb-query/src/window/helpers.rs @@ -4,10 +4,12 @@ use std::collections::HashMap; +use crate::expr::types::SqlExpr; + /// Group row indices by partition key, preserving first-seen partition order. pub(super) fn build_partitions( rows: &[(String, serde_json::Value)], - partition_by: &[String], + partition_by: &[SqlExpr], ) -> Vec> { if partition_by.is_empty() { return vec![(0..rows.len()).collect()]; @@ -17,16 +19,9 @@ pub(super) fn build_partitions( let mut order = Vec::new(); for (i, (_id, doc)) in rows.iter().enumerate() { - // Partition key uses JSON serialization: the string literal "null" and a missing - // field both produce "null" here, but a JSON string value "null" serializes as - // "\"null\"" and is therefore distinct from a missing field. This is intentional. let key: String = partition_by .iter() - .map(|col| { - doc.get(col) - .map(|v| v.to_string()) - .unwrap_or_else(|| "null".to_string()) - }) + .map(|expr| eval_expr_on_json(expr, doc).to_string()) .collect::>() .join("\x00"); let entry = groups.entry(key.clone()).or_default(); @@ -49,6 +44,19 @@ pub(super) fn get_field(doc: &serde_json::Value, field: &str) -> serde_json::Val doc.get(field).cloned().unwrap_or(serde_json::Value::Null) } +/// Evaluate a `SqlExpr` against a serde_json document, returning a serde_json value. +pub(super) fn eval_expr_on_json(expr: &SqlExpr, doc: &serde_json::Value) -> serde_json::Value { + match expr { + SqlExpr::Column(name) => get_field(doc, name), + SqlExpr::Literal(v) => serde_json::Value::from(v.clone()), + other => { + let ndb_doc = nodedb_types::Value::from(doc.clone()); + let result = other.eval(&ndb_doc); + serde_json::Value::from(result) + } + } +} + pub(super) fn as_f64(v: &serde_json::Value) -> Option { match v { serde_json::Value::Number(n) => n.as_f64(), @@ -63,9 +71,11 @@ pub(super) fn order_keys_equal( rows: &[(String, serde_json::Value)], a: usize, b: usize, - order_by: &[(String, bool)], + order_by: &[(SqlExpr, bool)], ) -> bool { - order_by - .iter() - .all(|(col, _)| get_field(&rows[a].1, col) == get_field(&rows[b].1, col)) + order_by.iter().all(|(expr, _)| { + let va = eval_expr_on_json(expr, &rows[a].1); + let vb = eval_expr_on_json(expr, &rows[b].1); + va == vb + }) } diff --git a/nodedb-query/src/window/mod.rs b/nodedb-query/src/window/mod.rs index 9e2bf1d0e..ed91cf01f 100644 --- a/nodedb-query/src/window/mod.rs +++ b/nodedb-query/src/window/mod.rs @@ -13,6 +13,9 @@ pub mod offset; pub mod ranking; pub mod running; pub mod spec; +pub mod value_agg; +pub mod value_eval; pub use eval::evaluate_window_functions; pub use spec::{FrameBound, WindowFrame, WindowFuncSpec}; +pub use value_eval::{WindowError, evaluate_window_functions_value}; diff --git a/nodedb-query/src/window/ranking.rs b/nodedb-query/src/window/ranking.rs index 9425e69b6..bda5da99b 100644 --- a/nodedb-query/src/window/ranking.rs +++ b/nodedb-query/src/window/ranking.rs @@ -22,7 +22,7 @@ pub(super) fn apply_rank( rows: &mut [(String, serde_json::Value)], indices: &[usize], alias: &str, - order_by: &[(String, bool)], + order_by: &[(SqlExpr, bool)], ) { if indices.is_empty() { return; @@ -46,7 +46,7 @@ pub(super) fn apply_dense_rank( rows: &mut [(String, serde_json::Value)], indices: &[usize], alias: &str, - order_by: &[(String, bool)], + order_by: &[(SqlExpr, bool)], ) { if indices.is_empty() { return; @@ -100,7 +100,7 @@ pub(super) fn apply_percent_rank( rows: &mut [(String, serde_json::Value)], indices: &[usize], alias: &str, - order_by: &[(String, bool)], + order_by: &[(SqlExpr, bool)], ) { let total = indices.len(); if total == 0 { @@ -130,7 +130,7 @@ pub(super) fn apply_cume_dist( rows: &mut [(String, serde_json::Value)], indices: &[usize], alias: &str, - order_by: &[(String, bool)], + order_by: &[(SqlExpr, bool)], ) { let total = indices.len(); if total == 0 { diff --git a/nodedb-query/src/window/spec.rs b/nodedb-query/src/window/spec.rs index 67142564e..d624ef535 100644 --- a/nodedb-query/src/window/spec.rs +++ b/nodedb-query/src/window/spec.rs @@ -2,7 +2,7 @@ //! Window function spec and frame types serialized over the SPSC bridge. -use crate::expr::SqlExpr; +use crate::expr::types::SqlExpr; /// A window function specification. #[derive( @@ -22,10 +22,10 @@ pub struct WindowFuncSpec { pub func_name: String, /// Function arguments (e.g., `salary` for SUM(salary)). Empty for ROW_NUMBER. pub args: Vec, - /// PARTITION BY column names. Empty = single partition (entire result set). - pub partition_by: Vec, - /// ORDER BY within each partition: [(field, ascending)]. - pub order_by: Vec<(String, bool)>, + /// PARTITION BY expressions. Empty = single partition (entire result set). + pub partition_by: Vec, + /// ORDER BY within each partition: [(expr, ascending)]. + pub order_by: Vec<(SqlExpr, bool)>, /// Window frame specification. pub frame: WindowFrame, } diff --git a/nodedb-query/src/window/value_agg.rs b/nodedb-query/src/window/value_agg.rs new file mode 100644 index 000000000..8542eadda --- /dev/null +++ b/nodedb-query/src/window/value_agg.rs @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Aggregate window functions (sum, count, avg, min, max, first_value, last_value) +//! and frame-bound resolution for the Value-native evaluator. + +use std::collections::HashMap; + +use nodedb_types::Value; + +use super::spec::{FrameBound, WindowFrame, WindowFuncSpec}; +use super::value_eval::{cmp_values, eval_arg_for_row, order_keys_equal_v, set_cell}; +use crate::simd_agg; + +pub(super) fn apply_v_aggregate( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + let use_running = spec.frame.mode == "range" + && matches!(spec.frame.start, FrameBound::UnboundedPreceding) + && matches!(spec.frame.end, FrameBound::CurrentRow); + + if use_running { + apply_v_running_aggregate(rows, indices, column_index, spec, write_col); + } else { + apply_v_per_row_aggregate(rows, indices, column_index, spec, write_col); + } +} + +fn eval_arg(spec: &WindowFuncSpec, row: &[Value], column_index: &HashMap) -> Value { + spec.args + .first() + .map(|expr| eval_arg_for_row(expr, row, column_index)) + .unwrap_or(Value::Null) +} + +fn apply_v_running_aggregate( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + let len = indices.len(); + if len == 0 { + return; + } + + let mut running_sum = 0.0f64; + let mut running_count = 0u64; + let mut running_min: Option = None; + let mut running_max: Option = None; + let mut peer_start = 0usize; + + for pos in 0..len { + let i = indices[pos]; + let val = rows + .get(i) + .map(|row| eval_arg(spec, row, column_index)) + .unwrap_or(Value::Null); + + if let Some(n) = val.as_f64() { + running_sum += n; + running_count += 1; + running_min = Some(running_min.map_or(n, |m: f64| m.min(n))); + running_max = Some(running_max.map_or(n, |m: f64| m.max(n))); + } else if spec.func_name == "count" { + running_count += 1; + } + + let is_last_in_group = pos + 1 == len + || !order_keys_equal_v(rows, i, indices[pos + 1], column_index, &spec.order_by); + + if is_last_in_group { + let first_val = rows + .get(indices[0]) + .map(|row| eval_arg(spec, row, column_index)) + .unwrap_or(Value::Null); + let last_val = rows + .get(indices[pos]) + .map(|row| eval_arg(spec, row, column_index)) + .unwrap_or(Value::Null); + + let result = match spec.func_name.as_str() { + "sum" => Value::Float(running_sum), + "count" => Value::Integer(running_count as i64), + "avg" => { + if running_count > 0 { + Value::Float(running_sum / running_count as f64) + } else { + Value::Null + } + } + "min" => running_min.map(Value::Float).unwrap_or(Value::Null), + "max" => running_max.map(Value::Float).unwrap_or(Value::Null), + "first_value" => first_val, + "last_value" => last_val, + _ => Value::Null, + }; + + for &peer_idx in &indices[peer_start..=pos] { + set_cell(rows, peer_idx, write_col, result.clone()); + } + peer_start = pos + 1; + } + } +} + +fn apply_v_per_row_aggregate( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + let len = indices.len(); + if len == 0 { + return; + } + + let order_expr = spec.order_by.first().map(|(expr, _)| expr); + let order_values: Vec = indices + .iter() + .map(|&i| { + order_expr + .and_then(|expr| { + rows.get(i) + .map(|row| eval_arg_for_row(expr, row, column_index)) + }) + .unwrap_or(Value::Null) + }) + .collect(); + + let peer_groups: Vec = if spec.frame.mode == "groups" { + build_v_peer_groups(&order_values) + } else { + Vec::new() + }; + + let all_vals: Vec> = indices + .iter() + .map(|&i| { + rows.get(i) + .map(|row| eval_arg(spec, row, column_index).as_f64()) + .unwrap_or(None) + }) + .collect(); + + let results: Vec = (0..len) + .map(|pos| { + let (start_idx, end_idx) = + evaluate_v_frame_bounds(&spec.frame, pos, len, &order_values, &peer_groups); + aggregate_v_slice( + &all_vals, + indices, + rows, + column_index, + spec, + start_idx, + end_idx, + ) + }) + .collect(); + + for (pos, result) in results.into_iter().enumerate() { + set_cell(rows, indices[pos], write_col, result); + } +} + +fn aggregate_v_slice( + all_vals: &[Option], + indices: &[usize], + rows: &[Vec], + column_index: &HashMap, + spec: &WindowFuncSpec, + start_idx: usize, + end_idx: usize, +) -> Value { + let slice_vals: Vec = all_vals[start_idx..=end_idx] + .iter() + .filter_map(|v| *v) + .collect(); + let slice_count = end_idx - start_idx + 1; + + match spec.func_name.as_str() { + "sum" => { + let rt = simd_agg::ts_runtime(); + Value::Float((rt.sum_f64)(&slice_vals)) + } + "count" => Value::Integer(slice_count as i64), + "avg" => { + if slice_vals.is_empty() { + Value::Null + } else { + let rt = simd_agg::ts_runtime(); + Value::Float((rt.sum_f64)(&slice_vals) / slice_vals.len() as f64) + } + } + "min" => { + if slice_vals.is_empty() { + Value::Null + } else { + let rt = simd_agg::ts_runtime(); + Value::Float((rt.min_f64)(&slice_vals)) + } + } + "max" => { + if slice_vals.is_empty() { + Value::Null + } else { + let rt = simd_agg::ts_runtime(); + Value::Float((rt.max_f64)(&slice_vals)) + } + } + "first_value" => indices + .get(start_idx) + .and_then(|&i| rows.get(i)) + .map(|row| { + eval_arg_for_row( + spec.args + .first() + .unwrap_or(&crate::expr::types::SqlExpr::Literal(Value::Null)), + row, + column_index, + ) + }) + .unwrap_or(Value::Null), + "last_value" => indices + .get(end_idx) + .and_then(|&i| rows.get(i)) + .map(|row| { + eval_arg_for_row( + spec.args + .first() + .unwrap_or(&crate::expr::types::SqlExpr::Literal(Value::Null)), + row, + column_index, + ) + }) + .unwrap_or(Value::Null), + _ => Value::Null, + } +} + +fn build_v_peer_groups(order_values: &[Value]) -> Vec { + let mut groups = Vec::with_capacity(order_values.len()); + let mut current_group = 0usize; + for (i, val) in order_values.iter().enumerate() { + if i > 0 + && !matches!( + cmp_values(val, &order_values[i - 1]), + std::cmp::Ordering::Equal + ) + { + current_group += 1; + } + groups.push(current_group); + } + groups +} + +pub(super) fn evaluate_v_frame_bounds( + frame: &WindowFrame, + pos: usize, + len: usize, + order_values: &[Value], + peer_groups: &[usize], +) -> (usize, usize) { + match frame.mode.as_str() { + "rows" => v_rows_bounds(&frame.start, &frame.end, pos, len), + "range" => v_range_bounds(&frame.start, &frame.end, pos, len, order_values), + "groups" => v_groups_bounds(&frame.start, &frame.end, pos, len, peer_groups), + _ => (0, len.saturating_sub(1)), + } +} + +fn v_rows_bounds(start: &FrameBound, end: &FrameBound, pos: usize, len: usize) -> (usize, usize) { + let s = v_rows_bound_to_idx(start, pos, len); + let e = v_rows_bound_to_idx(end, pos, len); + (s.min(e), s.max(e)) +} + +fn v_rows_bound_to_idx(bound: &FrameBound, pos: usize, len: usize) -> usize { + match bound { + FrameBound::UnboundedPreceding => 0, + FrameBound::Preceding(n) => pos.saturating_sub(*n as usize), + FrameBound::CurrentRow => pos, + FrameBound::Following(n) => (pos + *n as usize).min(len.saturating_sub(1)), + FrameBound::UnboundedFollowing => len.saturating_sub(1), + } +} + +fn v_range_bounds( + start: &FrameBound, + end: &FrameBound, + pos: usize, + len: usize, + order_values: &[Value], +) -> (usize, usize) { + let current_val = order_values.get(pos).and_then(|v| v.as_f64()); + let s = v_range_bound_to_idx(start, pos, len, order_values, current_val, true); + let e = v_range_bound_to_idx(end, pos, len, order_values, current_val, false); + (s.min(e), s.max(e)) +} + +fn v_range_bound_to_idx( + bound: &FrameBound, + pos: usize, + len: usize, + order_values: &[Value], + current_val: Option, + is_start: bool, +) -> usize { + match bound { + FrameBound::UnboundedPreceding => 0, + FrameBound::UnboundedFollowing => len.saturating_sub(1), + FrameBound::CurrentRow => { + if is_start { + let mut idx = pos; + while idx > 0 + && matches!( + cmp_values( + order_values.get(idx - 1).unwrap_or(&Value::Null), + order_values.get(pos).unwrap_or(&Value::Null), + ), + std::cmp::Ordering::Equal + ) + { + idx -= 1; + } + idx + } else { + let mut idx = pos; + while idx + 1 < len + && matches!( + cmp_values( + order_values.get(idx + 1).unwrap_or(&Value::Null), + order_values.get(pos).unwrap_or(&Value::Null), + ), + std::cmp::Ordering::Equal + ) + { + idx += 1; + } + idx + } + } + FrameBound::Preceding(n) => { + let threshold = match current_val { + Some(cv) => cv - *n as f64, + None => return pos, + }; + let mut idx = 0; + for (i, v) in order_values.iter().enumerate() { + if v.as_f64().is_some_and(|fv| fv >= threshold) { + idx = i; + break; + } + idx = i + 1; + } + idx.min(len.saturating_sub(1)) + } + FrameBound::Following(n) => { + let threshold = match current_val { + Some(cv) => cv + *n as f64, + None => return pos, + }; + let mut idx = pos; + for (i, v) in order_values.iter().enumerate().skip(pos) { + if v.as_f64().is_none_or(|fv| fv > threshold) { + break; + } + idx = i; + } + idx.min(len.saturating_sub(1)) + } + } +} + +fn v_groups_bounds( + start: &FrameBound, + end: &FrameBound, + pos: usize, + len: usize, + peer_groups: &[usize], +) -> (usize, usize) { + let current_group = peer_groups.get(pos).copied().unwrap_or(0); + let max_group = peer_groups.last().copied().unwrap_or(0); + let start_group = v_groups_bound_to_group(start, current_group, max_group); + let end_group = v_groups_bound_to_group(end, current_group, max_group); + let start_idx = peer_groups + .iter() + .position(|&g| g == start_group) + .unwrap_or(0); + let end_idx = peer_groups + .iter() + .rposition(|&g| g == end_group) + .unwrap_or(len.saturating_sub(1)); + (start_idx, end_idx) +} + +fn v_groups_bound_to_group(bound: &FrameBound, current_group: usize, max_group: usize) -> usize { + match bound { + FrameBound::UnboundedPreceding => 0, + FrameBound::UnboundedFollowing => max_group, + FrameBound::CurrentRow => current_group, + FrameBound::Preceding(n) => current_group.saturating_sub(*n as usize), + FrameBound::Following(n) => (current_group + *n as usize).min(max_group), + } +} diff --git a/nodedb-query/src/window/value_eval.rs b/nodedb-query/src/window/value_eval.rs new file mode 100644 index 000000000..764ed54c9 --- /dev/null +++ b/nodedb-query/src/window/value_eval.rs @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Value-native window-function evaluator for the Lite embedded engine. +//! +//! Operates on `Vec>` rows directly, without any +//! serde_json dependency. Each spec appends one `Value` per row; the caller +//! appends the returned column names to its `columns` vec. + +use std::collections::HashMap; + +use nodedb_types::Value; + +use super::spec::WindowFuncSpec; +use super::value_agg::apply_v_aggregate; +use crate::expr::types::SqlExpr; +use crate::value_ops::compare_values; + +/// Error type for Value-mode window evaluation. +#[derive(Debug, thiserror::Error)] +pub enum WindowError { + #[error("window column '{name}' not found in result columns")] + ColumnNotFound { name: String }, + + #[error("window function argument error: {detail}")] + ArgEval { detail: String }, + + #[error("window frame error: {detail}")] + BadFrame { detail: String }, +} + +/// Evaluate window functions over a `Vec>` result set. +/// +/// `column_index` maps column name → position in each row slice. +/// For each spec, one `Value` is appended to every row. Returns the list of +/// new column names, one per spec in spec order. +pub fn evaluate_window_functions_value( + rows: &mut Vec>, + column_index: &HashMap, + specs: &[WindowFuncSpec], +) -> Result, WindowError> { + let mut new_cols: Vec = Vec::with_capacity(specs.len()); + + for spec in specs { + let partitions = build_value_partitions(rows, column_index, spec)?; + let write_col = rows.first().map(|r| r.len()).unwrap_or(0); + + for row in rows.iter_mut() { + row.push(Value::Null); + } + + for partition_indices in &partitions { + match spec.func_name.as_str() { + "row_number" => apply_v_row_number(rows, partition_indices, write_col), + "rank" => apply_v_rank(rows, partition_indices, column_index, spec, write_col), + "dense_rank" => { + apply_v_dense_rank(rows, partition_indices, column_index, spec, write_col) + } + "ntile" => apply_v_ntile(rows, partition_indices, spec, write_col)?, + "percent_rank" => { + apply_v_percent_rank(rows, partition_indices, column_index, spec, write_col) + } + "cume_dist" => { + apply_v_cume_dist(rows, partition_indices, column_index, spec, write_col) + } + "lag" => apply_v_lag(rows, partition_indices, column_index, spec, write_col)?, + "lead" => apply_v_lead(rows, partition_indices, column_index, spec, write_col)?, + "nth_value" => { + apply_v_nth_value(rows, partition_indices, column_index, spec, write_col)? + } + "sum" | "count" | "avg" | "min" | "max" | "first_value" | "last_value" => { + apply_v_aggregate(rows, partition_indices, column_index, spec, write_col) + } + other => { + return Err(WindowError::ArgEval { + detail: format!( + "unknown window function '{other}'; valid names: row_number, rank, \ + dense_rank, ntile, percent_rank, cume_dist, lag, lead, nth_value, \ + sum, count, avg, min, max, first_value, last_value" + ), + }); + } + } + } + + new_cols.push(spec.alias.clone()); + } + + Ok(new_cols) +} + +// ── Partition building ──────────────────────────────────────────────────────── + +fn build_value_partitions( + rows: &[Vec], + column_index: &HashMap, + spec: &WindowFuncSpec, +) -> Result>, WindowError> { + if spec.partition_by.is_empty() { + return Ok(vec![(0..rows.len()).collect()]); + } + + let mut groups: HashMap> = HashMap::new(); + let mut order: Vec = Vec::new(); + + for (i, row) in rows.iter().enumerate() { + let key = partition_key(row, column_index, &spec.partition_by); + let entry = groups.entry(key.clone()).or_default(); + if entry.is_empty() { + order.push(key); + } + entry.push(i); + } + + Ok(order.iter().filter_map(|k| groups.remove(k)).collect()) +} + +fn partition_key( + row: &[Value], + column_index: &HashMap, + partition_by: &[SqlExpr], +) -> String { + partition_by + .iter() + .map(|expr| { + let v = eval_arg_for_row(expr, row, column_index); + format!("{v:?}") + }) + .collect::>() + .join("\x00") +} + +// ── Value comparison helpers (pub(super) for value_agg) ─────────────────────── + +pub(super) fn cmp_values(a: &Value, b: &Value) -> std::cmp::Ordering { + match (a, b) { + (Value::Null, Value::Null) => std::cmp::Ordering::Equal, + (Value::Null, _) => std::cmp::Ordering::Less, + (_, Value::Null) => std::cmp::Ordering::Greater, + (va, vb) => compare_values(va, vb), + } +} + +pub(super) fn order_keys_equal_v( + rows: &[Vec], + a: usize, + b: usize, + column_index: &HashMap, + order_by: &[(SqlExpr, bool)], +) -> bool { + order_by.iter().all(|(expr, _)| { + let row_a = rows.get(a).map(|r| r.as_slice()).unwrap_or(&[]); + let row_b = rows.get(b).map(|r| r.as_slice()).unwrap_or(&[]); + let va = eval_arg_for_row(expr, row_a, column_index); + let vb = eval_arg_for_row(expr, row_b, column_index); + matches!(cmp_values(&va, &vb), std::cmp::Ordering::Equal) + }) +} + +// ── Argument evaluation (pub(super) for value_agg) ──────────────────────────── + +pub(super) fn eval_arg_for_row( + expr: &SqlExpr, + row: &[Value], + column_index: &HashMap, +) -> Value { + match expr { + SqlExpr::Column(name) => column_index + .get(name.as_str()) + .and_then(|&idx| row.get(idx)) + .cloned() + .unwrap_or(Value::Null), + SqlExpr::Literal(v) => v.clone(), + other => { + let doc = row_to_obj(row, column_index); + other.eval(&doc) + } + } +} + +fn row_to_obj(row: &[Value], column_index: &HashMap) -> Value { + let mut map = HashMap::new(); + for (name, &idx) in column_index { + if let Some(v) = row.get(idx) { + map.insert(name.clone(), v.clone()); + } + } + Value::Object(map) +} + +fn usize_arg(spec: &WindowFuncSpec, idx: usize, default: usize) -> usize { + spec.args + .get(idx) + .and_then(|e| match e { + SqlExpr::Literal(v) => v.as_f64().map(|n| n as usize), + _ => None, + }) + .unwrap_or(default) +} + +fn default_arg_value(spec: &WindowFuncSpec, idx: usize) -> Value { + spec.args + .get(idx) + .and_then(|e| match e { + SqlExpr::Literal(v) => Some(v.clone()), + _ => None, + }) + .unwrap_or(Value::Null) +} + +// ── Cell write helper (pub(super) for value_agg) ────────────────────────────── + +pub(super) fn set_cell(rows: &mut Vec>, row_idx: usize, col_idx: usize, val: Value) { + if let Some(row) = rows.get_mut(row_idx) { + if let Some(cell) = row.get_mut(col_idx) { + *cell = val; + } + } +} + +// ── Ranking functions ───────────────────────────────────────────────────────── + +fn apply_v_row_number(rows: &mut Vec>, indices: &[usize], write_col: usize) { + for (rank, &i) in indices.iter().enumerate() { + set_cell(rows, i, write_col, Value::Integer((rank + 1) as i64)); + } +} + +fn apply_v_rank( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + if indices.is_empty() { + return; + } + let mut current_rank = 1usize; + set_cell(rows, indices[0], write_col, Value::Integer(1)); + for pos in 1..indices.len() { + if !order_keys_equal_v( + rows, + indices[pos - 1], + indices[pos], + column_index, + &spec.order_by, + ) { + current_rank = pos + 1; + } + set_cell( + rows, + indices[pos], + write_col, + Value::Integer(current_rank as i64), + ); + } +} + +fn apply_v_dense_rank( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + if indices.is_empty() { + return; + } + let mut current_rank = 1usize; + set_cell(rows, indices[0], write_col, Value::Integer(1)); + for pos in 1..indices.len() { + if !order_keys_equal_v( + rows, + indices[pos - 1], + indices[pos], + column_index, + &spec.order_by, + ) { + current_rank += 1; + } + set_cell( + rows, + indices[pos], + write_col, + Value::Integer(current_rank as i64), + ); + } +} + +fn apply_v_ntile( + rows: &mut Vec>, + indices: &[usize], + spec: &WindowFuncSpec, + write_col: usize, +) -> Result<(), WindowError> { + let n = usize_arg(spec, 0, 1).max(1); + let total = indices.len(); + if total == 0 { + return Ok(()); + } + for (pos, &i) in indices.iter().enumerate() { + let bucket = (pos * n / total) + 1; + set_cell(rows, i, write_col, Value::Integer(bucket as i64)); + } + Ok(()) +} + +fn apply_v_percent_rank( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + let total = indices.len(); + if total == 0 { + return; + } + if total == 1 { + set_cell(rows, indices[0], write_col, Value::Float(0.0)); + return; + } + let denom = (total - 1) as f64; + let mut current_rank = 1usize; + set_cell(rows, indices[0], write_col, Value::Float(0.0)); + for pos in 1..total { + if !order_keys_equal_v( + rows, + indices[pos - 1], + indices[pos], + column_index, + &spec.order_by, + ) { + current_rank = pos + 1; + } + let pr = (current_rank - 1) as f64 / denom; + set_cell(rows, indices[pos], write_col, Value::Float(pr)); + } +} + +fn apply_v_cume_dist( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) { + let total = indices.len(); + if total == 0 { + return; + } + let denom = total as f64; + let mut group_start = 0; + while group_start < total { + let mut group_end = group_start + 1; + while group_end < total + && order_keys_equal_v( + rows, + indices[group_start], + indices[group_end], + column_index, + &spec.order_by, + ) + { + group_end += 1; + } + let cd = group_end as f64 / denom; + for pos in group_start..group_end { + set_cell(rows, indices[pos], write_col, Value::Float(cd)); + } + group_start = group_end; + } +} + +// ── Offset functions ────────────────────────────────────────────────────────── + +fn collect_arg_values( + rows: &[Vec], + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, +) -> Vec { + indices + .iter() + .map(|&i| { + rows.get(i) + .map(|row| { + spec.args + .first() + .map(|expr| eval_arg_for_row(expr, row, column_index)) + .unwrap_or(Value::Null) + }) + .unwrap_or(Value::Null) + }) + .collect() +} + +fn apply_v_lag( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) -> Result<(), WindowError> { + let offset = usize_arg(spec, 1, 1); + let default = default_arg_value(spec, 2); + let values = collect_arg_values(rows, indices, column_index, spec); + for (pos, &i) in indices.iter().enumerate() { + let val = if pos >= offset { + values[pos - offset].clone() + } else { + default.clone() + }; + set_cell(rows, i, write_col, val); + } + Ok(()) +} + +fn apply_v_lead( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) -> Result<(), WindowError> { + let offset = usize_arg(spec, 1, 1); + let default = default_arg_value(spec, 2); + let values = collect_arg_values(rows, indices, column_index, spec); + for (pos, &i) in indices.iter().enumerate() { + let val = if pos + offset < indices.len() { + values[pos + offset].clone() + } else { + default.clone() + }; + set_cell(rows, i, write_col, val); + } + Ok(()) +} + +fn apply_v_nth_value( + rows: &mut Vec>, + indices: &[usize], + column_index: &HashMap, + spec: &WindowFuncSpec, + write_col: usize, +) -> Result<(), WindowError> { + let n = usize_arg(spec, 1, 1).max(1); + let values = collect_arg_values(rows, indices, column_index, spec); + for (pos, &i) in indices.iter().enumerate() { + let val = if pos + 1 >= n { + values[n - 1].clone() + } else { + Value::Null + }; + set_cell(rows, i, write_col, val); + } + Ok(()) +} diff --git a/nodedb-sql/src/lib.rs b/nodedb-sql/src/lib.rs index a4d38f3b6..7530b5d92 100644 --- a/nodedb-sql/src/lib.rs +++ b/nodedb-sql/src/lib.rs @@ -29,8 +29,11 @@ pub mod temporal; pub mod types; pub mod types_array; pub mod types_expr; +pub mod visitor; pub use temporal::{TemporalScope, ValidTime}; +pub use visitor::PlanVisitor; +pub use visitor::dispatch; pub use catalog::{SqlCatalog, SqlCatalogError}; pub use error::{Result, SqlError}; diff --git a/nodedb-sql/src/visitor/mod.rs b/nodedb-sql/src/visitor/mod.rs new file mode 100644 index 000000000..ce4373503 --- /dev/null +++ b/nodedb-sql/src/visitor/mod.rs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub mod plan_visitor; + +pub use plan_visitor::PlanVisitor; +pub use plan_visitor::dispatch; diff --git a/nodedb-sql/src/visitor/plan_visitor/dispatch.rs b/nodedb-sql/src/visitor/plan_visitor/dispatch.rs new file mode 100644 index 000000000..5e763b706 --- /dev/null +++ b/nodedb-sql/src/visitor/plan_visitor/dispatch.rs @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Exhaustive [`SqlPlan`] → [`PlanVisitor`] dispatcher; missing arms fail to compile. +use super::trait_def::PlanVisitor; +use crate::types::SqlPlan; + +pub fn dispatch(visitor: &mut V, plan: &SqlPlan) -> Result { + match plan { + SqlPlan::ConstantResult { columns, values } => visitor.constant_result(columns, values), + SqlPlan::Scan { + collection, + alias, + engine, + filters, + projection, + sort_keys, + limit, + offset, + distinct, + window_functions, + temporal, + } => visitor.scan( + collection, + alias.as_deref(), + *engine, + filters, + projection, + sort_keys, + *limit, + *offset, + *distinct, + window_functions, + temporal, + ), + SqlPlan::PointGet { + collection, + alias, + engine, + key_column, + key_value, + } => visitor.point_get(collection, alias.as_deref(), *engine, key_column, key_value), + SqlPlan::DocumentIndexLookup { + collection, + alias, + engine, + field, + value, + filters, + projection, + sort_keys, + limit, + offset, + distinct, + window_functions, + case_insensitive, + temporal, + } => visitor.document_index_lookup( + collection, + alias.as_deref(), + *engine, + field, + value, + filters, + projection, + sort_keys, + *limit, + *offset, + *distinct, + window_functions, + *case_insensitive, + temporal, + ), + SqlPlan::RangeScan { + collection, + field, + lower, + upper, + limit, + } => visitor.range_scan(collection, field, lower.as_ref(), upper.as_ref(), *limit), + SqlPlan::Insert { + collection, + engine, + rows, + column_defaults, + if_absent, + column_schema, + } => visitor.insert( + collection, + *engine, + rows, + column_defaults, + *if_absent, + column_schema, + ), + SqlPlan::KvInsert { + collection, + entries, + ttl_secs, + intent, + on_conflict_updates, + } => visitor.kv_insert(collection, entries, *ttl_secs, *intent, on_conflict_updates), + SqlPlan::Upsert { + collection, + engine, + rows, + column_defaults, + on_conflict_updates, + column_schema, + } => visitor.upsert( + collection, + *engine, + rows, + column_defaults, + on_conflict_updates, + column_schema, + ), + SqlPlan::InsertSelect { + target, + source, + limit, + } => visitor.insert_select(target, source, *limit), + SqlPlan::Update { + collection, + engine, + assignments, + filters, + target_keys, + returning, + } => visitor.update( + collection, + *engine, + assignments, + filters, + target_keys, + *returning, + ), + SqlPlan::UpdateFrom { + collection, + engine, + source, + target_join_col, + source_join_col, + assignments, + target_filters, + returning, + } => visitor.update_from( + collection, + *engine, + source, + target_join_col, + source_join_col, + assignments, + target_filters, + *returning, + ), + SqlPlan::Delete { + collection, + engine, + filters, + target_keys, + } => visitor.delete(collection, *engine, filters, target_keys), + SqlPlan::Truncate { + collection, + restart_identity, + } => visitor.truncate(collection, *restart_identity), + SqlPlan::Join { + left, + right, + on, + join_type, + condition, + limit, + projection, + filters, + } => visitor.join( + left, + right, + on, + *join_type, + condition.as_ref(), + *limit, + projection, + filters, + ), + SqlPlan::Aggregate { + input, + group_by, + aggregates, + having, + limit, + grouping_sets, + sort_keys, + } => visitor.aggregate( + input, + group_by, + aggregates, + having, + *limit, + grouping_sets.as_deref(), + sort_keys, + ), + SqlPlan::TimeseriesScan { + collection, + time_range, + bucket_interval_ms, + group_by, + aggregates, + filters, + projection, + gap_fill, + limit, + tiered, + temporal, + } => visitor.timeseries_scan( + collection, + *time_range, + *bucket_interval_ms, + group_by, + aggregates, + filters, + projection, + gap_fill, + *limit, + *tiered, + temporal, + ), + SqlPlan::TimeseriesIngest { collection, rows } => { + visitor.timeseries_ingest(collection, rows) + } + SqlPlan::VectorSearch { + collection, + field, + query_vector, + top_k, + ef_search, + metric, + filters, + array_prefilter, + ann_options, + skip_payload_fetch, + payload_filters, + } => visitor.vector_search( + collection, + field, + query_vector, + *top_k, + *ef_search, + *metric, + filters, + array_prefilter.as_ref(), + ann_options, + *skip_payload_fetch, + payload_filters, + ), + SqlPlan::MultiVectorSearch { + collection, + query_vector, + top_k, + ef_search, + } => visitor.multi_vector_search(collection, query_vector, *top_k, *ef_search), + SqlPlan::TextSearch { + collection, + query, + top_k, + filters, + score_alias, + } => visitor.text_search(collection, query, *top_k, filters, score_alias.as_deref()), + SqlPlan::HybridSearch { + collection, + query_vector, + query_text, + top_k, + ef_search, + vector_weight, + fuzzy, + score_alias, + } => visitor.hybrid_search( + collection, + query_vector, + query_text, + *top_k, + *ef_search, + *vector_weight, + *fuzzy, + score_alias.as_deref(), + ), + SqlPlan::HybridSearchTriple { + collection, + query_vector, + query_text, + graph_seed_id, + graph_depth, + graph_edge_label, + top_k, + ef_search, + fuzzy, + rrf_k, + score_alias, + } => visitor.hybrid_search_triple( + collection, + query_vector, + query_text, + graph_seed_id, + *graph_depth, + graph_edge_label.as_deref(), + *top_k, + *ef_search, + *fuzzy, + *rrf_k, + score_alias.as_deref(), + ), + SqlPlan::SpatialScan { + collection, + field, + predicate, + query_geometry, + distance_meters, + attribute_filters, + limit, + projection, + } => visitor.spatial_scan( + collection, + field, + predicate, + query_geometry, + *distance_meters, + attribute_filters, + *limit, + projection, + ), + SqlPlan::Union { inputs, distinct } => visitor.union(inputs, *distinct), + SqlPlan::Intersect { left, right, all } => visitor.intersect(left, right, *all), + SqlPlan::Except { left, right, all } => visitor.except(left, right, *all), + SqlPlan::RecursiveScan { + collection, + base_filters, + recursive_filters, + join_link, + max_iterations, + distinct, + limit, + } => visitor.recursive_scan( + collection, + base_filters, + recursive_filters, + join_link.as_ref(), + *max_iterations, + *distinct, + *limit, + ), + SqlPlan::RecursiveValue { + cte_name, + columns, + init_exprs, + step_exprs, + condition, + max_depth, + distinct, + } => visitor.recursive_value( + cte_name, + columns, + init_exprs, + step_exprs, + condition.as_deref(), + *max_depth, + *distinct, + ), + SqlPlan::Cte { definitions, outer } => visitor.cte(definitions, outer), + SqlPlan::CreateArray { + name, + dims, + attrs, + tile_extents, + cell_order, + tile_order, + prefix_bits, + audit_retain_ms, + minimum_audit_retain_ms, + } => visitor.create_array( + name, + dims, + attrs, + tile_extents, + *cell_order, + *tile_order, + *prefix_bits, + *audit_retain_ms, + *minimum_audit_retain_ms, + ), + SqlPlan::DropArray { name, if_exists } => visitor.drop_array(name, *if_exists), + SqlPlan::AlterArray { + name, + audit_retain_ms, + minimum_audit_retain_ms, + } => visitor.alter_array(name, *audit_retain_ms, *minimum_audit_retain_ms), + SqlPlan::InsertArray { name, rows } => visitor.insert_array(name, rows), + SqlPlan::DeleteArray { name, coords } => visitor.delete_array(name, coords), + SqlPlan::ArraySlice { + name, + slice, + attr_projection, + limit, + temporal, + } => visitor.array_slice(name, slice, attr_projection, *limit, temporal), + SqlPlan::ArrayProject { + name, + attr_projection, + } => visitor.array_project(name, attr_projection), + SqlPlan::ArrayAgg { + name, + attr, + reducer, + group_by_dim, + temporal, + } => visitor.array_agg(name, attr, reducer, group_by_dim.as_deref(), temporal), + SqlPlan::ArrayElementwise { + left, + right, + op, + attr, + } => visitor.array_elementwise(left, right, *op, attr), + SqlPlan::ArrayFlush { name } => visitor.array_flush(name), + SqlPlan::ArrayCompact { name } => visitor.array_compact(name), + SqlPlan::Merge { + target, + engine, + source, + target_join_col, + source_join_col, + source_alias, + clauses, + returning, + } => visitor.merge( + target, + *engine, + source, + target_join_col, + source_join_col, + source_alias, + clauses, + *returning, + ), + SqlPlan::LateralTopK { + outer, + outer_alias, + inner_collection, + inner_filters, + inner_order_by, + inner_limit, + correlation_keys, + lateral_alias, + projection, + left_join, + } => visitor.lateral_top_k( + outer, + outer_alias.as_deref(), + inner_collection, + inner_filters, + inner_order_by, + *inner_limit, + correlation_keys, + lateral_alias, + projection, + *left_join, + ), + SqlPlan::LateralLoop { + outer, + outer_alias, + inner, + correlation_predicates, + lateral_alias, + projection, + outer_row_cap, + left_join, + } => visitor.lateral_loop( + outer, + outer_alias.as_deref(), + inner, + correlation_predicates, + lateral_alias, + projection, + *outer_row_cap, + *left_join, + ), + SqlPlan::VectorPrimaryInsert { + collection, + field, + quantization, + payload_indexes, + rows, + } => visitor.vector_primary_insert(collection, field, quantization, payload_indexes, rows), + } +} diff --git a/nodedb-sql/src/visitor/plan_visitor/mod.rs b/nodedb-sql/src/visitor/plan_visitor/mod.rs new file mode 100644 index 000000000..9e70606ac --- /dev/null +++ b/nodedb-sql/src/visitor/plan_visitor/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub mod dispatch; +pub mod trait_def; + +pub use dispatch::dispatch; +pub use trait_def::PlanVisitor; diff --git a/nodedb-sql/src/visitor/plan_visitor/trait_def.rs b/nodedb-sql/src/visitor/plan_visitor/trait_def.rs new file mode 100644 index 000000000..d60d4b768 --- /dev/null +++ b/nodedb-sql/src/visitor/plan_visitor/trait_def.rs @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Executor parity contract for [`SqlPlan`]: one abstract method per variant. +//! Trait method arity mirrors `SqlPlan` variant field counts and is not a code smell. +#![allow(clippy::too_many_arguments)] + +use crate::fts_types::FtsQuery; +use crate::temporal::TemporalScope; +use crate::types::SqlPlan; +use crate::types::filter::Filter; +use crate::types::plan::{ + ArrayPrefilter, KvInsertIntent, MergePlanClause, VectorAnnOptions, VectorPrimaryRow, +}; +use crate::types::query::{ + AggregateExpr, EngineType, JoinType, Projection, SortKey, SpatialPredicate, WindowSpec, +}; +use crate::types_array::{ + ArrayAttrAst, ArrayBinaryOpAst, ArrayCellOrderAst, ArrayCoordLiteral, ArrayDimAst, + ArrayInsertRow, ArrayReducerAst, ArraySliceAst, ArrayTileOrderAst, +}; +use crate::types_expr::{SqlExpr, SqlPayloadAtom, SqlValue}; +use nodedb_types::PayloadIndexKind; +use nodedb_types::VectorQuantization; +use nodedb_types::vector_distance::DistanceMetric; + +/// Executor parity contract: every [`SqlPlan`] variant must be handled. +/// Implement this trait and call [`dispatch`](super::dispatch) to route plans. +pub trait PlanVisitor { + /// The successful result type returned by each visit method. + type Output; + /// The error type returned by each visit method. + type Error; + + /// Handle [`SqlPlan::ConstantResult`]. + fn constant_result( + &mut self, + columns: &[String], + values: &[SqlValue], + ) -> Result; + + /// Handle [`SqlPlan::Scan`]. + fn scan( + &mut self, + collection: &str, + alias: Option<&str>, + engine: EngineType, + filters: &[Filter], + projection: &[Projection], + sort_keys: &[SortKey], + limit: Option, + offset: usize, + distinct: bool, + window_functions: &[WindowSpec], + temporal: &TemporalScope, + ) -> Result; + + /// Handle [`SqlPlan::PointGet`]. + fn point_get( + &mut self, + collection: &str, + alias: Option<&str>, + engine: EngineType, + key_column: &str, + key_value: &SqlValue, + ) -> Result; + + /// Handle [`SqlPlan::DocumentIndexLookup`]. + fn document_index_lookup( + &mut self, + collection: &str, + alias: Option<&str>, + engine: EngineType, + field: &str, + value: &SqlValue, + filters: &[Filter], + projection: &[Projection], + sort_keys: &[SortKey], + limit: Option, + offset: usize, + distinct: bool, + window_functions: &[WindowSpec], + case_insensitive: bool, + temporal: &TemporalScope, + ) -> Result; + + /// Handle [`SqlPlan::RangeScan`]. + fn range_scan( + &mut self, + collection: &str, + field: &str, + lower: Option<&SqlValue>, + upper: Option<&SqlValue>, + limit: usize, + ) -> Result; + + /// Handle [`SqlPlan::Insert`]. + fn insert( + &mut self, + collection: &str, + engine: EngineType, + rows: &[Vec<(String, SqlValue)>], + column_defaults: &[(String, String)], + if_absent: bool, + column_schema: &[(String, String)], + ) -> Result; + + /// Handle [`SqlPlan::KvInsert`]. + fn kv_insert( + &mut self, + collection: &str, + entries: &[(SqlValue, Vec<(String, SqlValue)>)], + ttl_secs: u64, + intent: KvInsertIntent, + on_conflict_updates: &[(String, SqlExpr)], + ) -> Result; + + /// Handle [`SqlPlan::Upsert`]. + fn upsert( + &mut self, + collection: &str, + engine: EngineType, + rows: &[Vec<(String, SqlValue)>], + column_defaults: &[(String, String)], + on_conflict_updates: &[(String, SqlExpr)], + column_schema: &[(String, String)], + ) -> Result; + + /// Handle [`SqlPlan::InsertSelect`]. + fn insert_select( + &mut self, + target: &str, + source: &SqlPlan, + limit: usize, + ) -> Result; + + /// Handle [`SqlPlan::Update`]. + fn update( + &mut self, + collection: &str, + engine: EngineType, + assignments: &[(String, SqlExpr)], + filters: &[Filter], + target_keys: &[SqlValue], + returning: bool, + ) -> Result; + + /// Handle [`SqlPlan::UpdateFrom`]. + fn update_from( + &mut self, + collection: &str, + engine: EngineType, + source: &SqlPlan, + target_join_col: &str, + source_join_col: &str, + assignments: &[(String, SqlExpr)], + target_filters: &[Filter], + returning: bool, + ) -> Result; + + /// Handle [`SqlPlan::Delete`]. + fn delete( + &mut self, + collection: &str, + engine: EngineType, + filters: &[Filter], + target_keys: &[SqlValue], + ) -> Result; + + /// Handle [`SqlPlan::Truncate`]. + fn truncate( + &mut self, + collection: &str, + restart_identity: bool, + ) -> Result; + + /// Handle [`SqlPlan::Join`]. + fn join( + &mut self, + left: &SqlPlan, + right: &SqlPlan, + on: &[(String, String)], + join_type: JoinType, + condition: Option<&SqlExpr>, + limit: usize, + projection: &[Projection], + filters: &[Filter], + ) -> Result; + + /// Handle [`SqlPlan::Aggregate`]. + fn aggregate( + &mut self, + input: &SqlPlan, + group_by: &[SqlExpr], + aggregates: &[AggregateExpr], + having: &[Filter], + limit: usize, + grouping_sets: Option<&[Vec]>, + sort_keys: &[SortKey], + ) -> Result; + + /// Handle [`SqlPlan::TimeseriesScan`]. + fn timeseries_scan( + &mut self, + collection: &str, + time_range: (i64, i64), + bucket_interval_ms: i64, + group_by: &[String], + aggregates: &[AggregateExpr], + filters: &[Filter], + projection: &[Projection], + gap_fill: &str, + limit: usize, + tiered: bool, + temporal: &TemporalScope, + ) -> Result; + + /// Handle [`SqlPlan::TimeseriesIngest`]. + fn timeseries_ingest( + &mut self, + collection: &str, + rows: &[Vec<(String, SqlValue)>], + ) -> Result; + + /// Handle [`SqlPlan::VectorSearch`]. + fn vector_search( + &mut self, + collection: &str, + field: &str, + query_vector: &[f32], + top_k: usize, + ef_search: usize, + metric: DistanceMetric, + filters: &[Filter], + array_prefilter: Option<&ArrayPrefilter>, + ann_options: &VectorAnnOptions, + skip_payload_fetch: bool, + payload_filters: &[SqlPayloadAtom], + ) -> Result; + + /// Handle [`SqlPlan::MultiVectorSearch`]. + fn multi_vector_search( + &mut self, + collection: &str, + query_vector: &[f32], + top_k: usize, + ef_search: usize, + ) -> Result; + + /// Handle [`SqlPlan::TextSearch`]. + fn text_search( + &mut self, + collection: &str, + query: &FtsQuery, + top_k: usize, + filters: &[Filter], + score_alias: Option<&str>, + ) -> Result; + + /// Handle [`SqlPlan::HybridSearch`]. + fn hybrid_search( + &mut self, + collection: &str, + query_vector: &[f32], + query_text: &str, + top_k: usize, + ef_search: usize, + vector_weight: f32, + fuzzy: bool, + score_alias: Option<&str>, + ) -> Result; + + /// Handle [`SqlPlan::HybridSearchTriple`]. + fn hybrid_search_triple( + &mut self, + collection: &str, + query_vector: &[f32], + query_text: &str, + graph_seed_id: &str, + graph_depth: usize, + graph_edge_label: Option<&str>, + top_k: usize, + ef_search: usize, + fuzzy: bool, + rrf_k: (f64, f64, f64), + score_alias: Option<&str>, + ) -> Result; + + /// Handle [`SqlPlan::SpatialScan`]. + fn spatial_scan( + &mut self, + collection: &str, + field: &str, + predicate: &SpatialPredicate, + query_geometry: &nodedb_types::geometry::Geometry, + distance_meters: f64, + attribute_filters: &[Filter], + limit: usize, + projection: &[Projection], + ) -> Result; + + /// Handle [`SqlPlan::Union`]. + fn union(&mut self, inputs: &[SqlPlan], distinct: bool) -> Result; + + /// Handle [`SqlPlan::Intersect`]. + fn intersect( + &mut self, + left: &SqlPlan, + right: &SqlPlan, + all: bool, + ) -> Result; + + /// Handle [`SqlPlan::Except`]. + fn except( + &mut self, + left: &SqlPlan, + right: &SqlPlan, + all: bool, + ) -> Result; + + /// Handle [`SqlPlan::RecursiveScan`]. + fn recursive_scan( + &mut self, + collection: &str, + base_filters: &[Filter], + recursive_filters: &[Filter], + join_link: Option<&(String, String)>, + max_iterations: usize, + distinct: bool, + limit: usize, + ) -> Result; + + /// Handle [`SqlPlan::RecursiveValue`]. + fn recursive_value( + &mut self, + cte_name: &str, + columns: &[String], + init_exprs: &[String], + step_exprs: &[String], + condition: Option<&str>, + max_depth: usize, + distinct: bool, + ) -> Result; + + /// Handle [`SqlPlan::Cte`]. + fn cte( + &mut self, + definitions: &[(String, SqlPlan)], + outer: &SqlPlan, + ) -> Result; + + /// Handle [`SqlPlan::CreateArray`]. + fn create_array( + &mut self, + name: &str, + dims: &[ArrayDimAst], + attrs: &[ArrayAttrAst], + tile_extents: &[i64], + cell_order: ArrayCellOrderAst, + tile_order: ArrayTileOrderAst, + prefix_bits: u8, + audit_retain_ms: Option, + minimum_audit_retain_ms: Option, + ) -> Result; + + /// Handle [`SqlPlan::DropArray`]. + fn drop_array(&mut self, name: &str, if_exists: bool) -> Result; + + /// Handle [`SqlPlan::AlterArray`]. + fn alter_array( + &mut self, + name: &str, + audit_retain_ms: Option>, + minimum_audit_retain_ms: Option, + ) -> Result; + + /// Handle [`SqlPlan::InsertArray`]. + fn insert_array( + &mut self, + name: &str, + rows: &[ArrayInsertRow], + ) -> Result; + + /// Handle [`SqlPlan::DeleteArray`]. + fn delete_array( + &mut self, + name: &str, + coords: &[Vec], + ) -> Result; + + /// Handle [`SqlPlan::ArraySlice`]. + fn array_slice( + &mut self, + name: &str, + slice: &ArraySliceAst, + attr_projection: &[String], + limit: u32, + temporal: &TemporalScope, + ) -> Result; + + /// Handle [`SqlPlan::ArrayProject`]. + fn array_project( + &mut self, + name: &str, + attr_projection: &[String], + ) -> Result; + + /// Handle [`SqlPlan::ArrayAgg`]. + fn array_agg( + &mut self, + name: &str, + attr: &str, + reducer: &ArrayReducerAst, + group_by_dim: Option<&str>, + temporal: &TemporalScope, + ) -> Result; + + /// Handle [`SqlPlan::ArrayElementwise`]. + fn array_elementwise( + &mut self, + left: &str, + right: &str, + op: ArrayBinaryOpAst, + attr: &str, + ) -> Result; + + /// Handle [`SqlPlan::ArrayFlush`]. + fn array_flush(&mut self, name: &str) -> Result; + + /// Handle [`SqlPlan::ArrayCompact`]. + fn array_compact(&mut self, name: &str) -> Result; + + /// Handle [`SqlPlan::Merge`]. + fn merge( + &mut self, + target: &str, + engine: EngineType, + source: &SqlPlan, + target_join_col: &str, + source_join_col: &str, + source_alias: &str, + clauses: &[MergePlanClause], + returning: bool, + ) -> Result; + + /// Handle [`SqlPlan::LateralTopK`]. + fn lateral_top_k( + &mut self, + outer: &SqlPlan, + outer_alias: Option<&str>, + inner_collection: &str, + inner_filters: &[Filter], + inner_order_by: &[SortKey], + inner_limit: usize, + correlation_keys: &[(String, String)], + lateral_alias: &str, + projection: &[Projection], + left_join: bool, + ) -> Result; + + /// Handle [`SqlPlan::LateralLoop`]. + fn lateral_loop( + &mut self, + outer: &SqlPlan, + outer_alias: Option<&str>, + inner: &SqlPlan, + correlation_predicates: &[(String, String)], + lateral_alias: &str, + projection: &[Projection], + outer_row_cap: usize, + left_join: bool, + ) -> Result; + + /// Handle [`SqlPlan::VectorPrimaryInsert`]. + fn vector_primary_insert( + &mut self, + collection: &str, + field: &str, + quantization: &VectorQuantization, + payload_indexes: &[(String, PayloadIndexKind)], + rows: &[VectorPrimaryRow], + ) -> Result; +} diff --git a/nodedb-types/src/calvin.rs b/nodedb-types/src/calvin.rs new file mode 100644 index 000000000..e387a1b56 --- /dev/null +++ b/nodedb-types/src/calvin.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Primitive Calvin scheduling types shared between `nodedb-physical` +//! (the physical-plan IR layer) and `nodedb-cluster` (the distributed +//! Calvin sequencer / scheduler). +//! +//! Provides [`SortedVec`], [`EngineKeySet`], and [`PassiveReadKey`] — +//! the building blocks of Calvin read/write sets. `DependentReadSpec` +//! and other scheduler-internal aggregates stay in `nodedb-cluster`. + +use serde::{Deserialize, Serialize}; + +/// A newtype over `Vec` that guarantees sorted, deduplicated contents. +/// +/// Constructed via [`SortedVec::new`], which sorts and deduplicates at +/// construction time. This property is load-bearing for byte-determinism: +/// two `SortedVec`s built from the same logical set (in any insertion order) +/// produce identical serialized bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SortedVec(Vec); + +impl zerompk::ToMessagePack for SortedVec { + fn write(&self, writer: &mut W) -> zerompk::Result<()> { + self.0.write(writer) + } +} + +impl<'de, T> zerompk::FromMessagePack<'de> for SortedVec +where + T: zerompk::FromMessagePack<'de> + Ord + Clone, +{ + fn read>(reader: &mut R) -> zerompk::Result { + let v = Vec::::read(reader)?; + Ok(Self::new(v)) + } +} + +impl SortedVec { + /// Build from any slice. Sorts and deduplicates in place. + pub fn new(mut items: Vec) -> Self { + items.sort(); + items.dedup(); + Self(items) + } + + pub fn as_slice(&self) -> &[T] { + &self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter(&self) -> std::slice::Iter<'_, T> { + self.0.iter() + } +} + +impl From> for SortedVec { + fn from(v: Vec) -> Self { + Self::new(v) + } +} + +/// A typed key set for one engine within a read or write set. +/// +/// Keys are normalized to surrogates (or byte keys for KV) at admission, so +/// all engine-specific naming is resolved upstream of the sequencer. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub enum EngineKeySet { + /// Document engine (schemaless or strict): identified by surrogate. + Document { + collection: String, + surrogates: SortedVec, + }, + /// Vector engine: identified by surrogate. + Vector { + collection: String, + surrogates: SortedVec, + }, + /// Key-Value engine: identified by raw byte keys. + Kv { + collection: String, + keys: SortedVec>, + }, + /// Graph edge engine: identified by (src_surrogate, dst_surrogate) pairs. + Edge { + collection: String, + edges: SortedVec<(u32, u32)>, + }, +} + +impl EngineKeySet { + /// O(1) estimate of the serialized byte size of this key set. + /// + /// Used by the dependent-read cap check at sequencer admission to bound + /// the total bytes that would be Raft-replicated in a `CalvinReadResult` + /// entry. This is an estimate, not an exact count; do NOT use it as a + /// correctness check — only as a pre-flight guard. + pub fn serialized_size_hint(&self) -> usize { + match self { + // u32 surrogates: 4 bytes each. + Self::Document { surrogates, .. } | Self::Vector { surrogates, .. } => { + surrogates.len() * 4 + } + // KV keys: sum of key byte lengths. + Self::Kv { keys, .. } => keys.iter().map(|k| k.len()).sum(), + // Edge: two u32 per edge = 8 bytes each. + Self::Edge { edges, .. } => edges.len() * 8, + } + } + + /// The collection this key set belongs to. + pub fn collection(&self) -> &str { + match self { + Self::Document { collection, .. } + | Self::Vector { collection, .. } + | Self::Kv { collection, .. } + | Self::Edge { collection, .. } => collection, + } + } + + /// Returns `true` if this key set contains no keys. + pub fn is_empty(&self) -> bool { + match self { + Self::Document { surrogates, .. } => surrogates.is_empty(), + Self::Vector { surrogates, .. } => surrogates.is_empty(), + Self::Kv { keys, .. } => keys.is_empty(), + Self::Edge { edges, .. } => edges.is_empty(), + } + } +} + +/// A single key that a passive participant must read and broadcast. +/// +/// Wraps an [`EngineKeySet`]; per the dependent-read protocol each +/// `PassiveReadKey` contains a single-element (or small) key set. The +/// sequencer does not enforce single-element sets; the scheduler enforces the +/// total byte budget via `DependentReadSpec::total_bytes()` (which lives in +/// `nodedb-cluster`). +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct PassiveReadKey { + /// The engine key set to read on the passive vshard. + pub engine_key: EngineKeySet, +} diff --git a/nodedb-types/src/filter.rs b/nodedb-types/src/filter.rs index 85951f801..d723d3b12 100644 --- a/nodedb-types/src/filter.rs +++ b/nodedb-types/src/filter.rs @@ -12,7 +12,15 @@ use crate::value::Value; /// Metadata filter for vector search. Applied as a pre-filter (Roaring bitmap) /// or post-filter depending on selectivity. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + PartialEq, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] #[non_exhaustive] pub enum MetadataFilter { /// Field equals a specific value. diff --git a/nodedb-types/src/graph.rs b/nodedb-types/src/graph.rs index eb7e1c743..88d79d361 100644 --- a/nodedb-types/src/graph.rs +++ b/nodedb-types/src/graph.rs @@ -89,30 +89,6 @@ impl std::str::FromStr for Direction { } } -/// Aggregated graph statistics for a single collection. -/// -/// Mirrors the row shape returned by `SHOW GRAPH STATS ''` -/// on Origin and the equivalent direct-engine read on Lite. Values are -/// the global counts after cross-core aggregation; `labels` is sorted -/// ascending by label name. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub struct GraphStats { - pub collection: String, - pub node_count: u64, - pub edge_count: u64, - pub distinct_label_count: u64, - pub labels: Vec<(String, u64)>, -} - impl GraphStats { /// Column names of the wire row shape produced by /// `SHOW GRAPH STATS [<'collection'>]`. Single source of truth — the diff --git a/nodedb-types/src/id/mod.rs b/nodedb-types/src/id/mod.rs index c73b35351..8b7c43806 100644 --- a/nodedb-types/src/id/mod.rs +++ b/nodedb-types/src/id/mod.rs @@ -7,6 +7,7 @@ pub mod edge; pub mod error; pub mod id_type; pub mod node; +pub mod request; pub mod shape; pub mod tenant; pub mod vshard; @@ -18,6 +19,7 @@ pub use edge::{EdgeId, EdgeIdParseError}; pub use error::{ID_MAX_LEN, IdError}; pub use id_type::IdType; pub use node::NodeId; +pub use request::RequestId; pub use shape::ShapeId; pub use tenant::TenantId; pub use vshard::VShardId; diff --git a/nodedb-types/src/id/request.rs b/nodedb-types/src/id/request.rs new file mode 100644 index 000000000..c7e10881f --- /dev/null +++ b/nodedb-types/src/id/request.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Globally unique request identifier. Monotonic per connection, unique for >= 24h. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct RequestId(u64); + +impl RequestId { + pub const fn new(id: u64) -> Self { + Self(id) + } + + pub const fn as_u64(self) -> u64 { + self.0 + } +} + +impl fmt::Display for RequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "req:{}", self.0) + } +} diff --git a/nodedb-types/src/lib.rs b/nodedb-types/src/lib.rs index 810c81efa..6c54efec9 100644 --- a/nodedb-types/src/lib.rs +++ b/nodedb-types/src/lib.rs @@ -17,6 +17,7 @@ pub mod approx; pub mod array_cell; pub mod backup_envelope; pub mod bbox; +pub mod calvin; pub mod clone; pub mod collection; pub mod collection_config; diff --git a/nodedb-vector/src/hnsw/graph.rs b/nodedb-vector/src/hnsw/graph.rs index fd6c44001..55c789a94 100644 --- a/nodedb-vector/src/hnsw/graph.rs +++ b/nodedb-vector/src/hnsw/graph.rs @@ -147,6 +147,13 @@ impl Ord for Candidate { } impl HnswIndex { + /// The distance metric this index was built with. Search-time metric + /// overrides must match this; differing metrics require either rebuilding + /// the index or a metric-aware re-rank pass. + pub fn metric(&self) -> crate::distance::DistanceMetric { + self.params.metric + } + /// Create a new empty HNSW index. pub fn new(dim: usize, params: HnswParams) -> Self { let initial_capacity = params.ef_construction.max(ARENA_INITIAL_CAPACITY); diff --git a/nodedb/Cargo.toml b/nodedb/Cargo.toml index 0df0ccd46..4e92d262d 100644 --- a/nodedb/Cargo.toml +++ b/nodedb/Cargo.toml @@ -25,6 +25,7 @@ path = "src/lib.rs" nodedb-types = { workspace = true } nodedb-columnar = { workspace = true } nodedb-bridge = { workspace = true } +nodedb-physical = { workspace = true } rust_decimal = { workspace = true } nodedb-cluster = { workspace = true } nodedb-raft = { workspace = true } diff --git a/nodedb/src/bridge/dispatch.rs b/nodedb/src/bridge/dispatch.rs index 7c6597480..e38cbcc71 100644 --- a/nodedb/src/bridge/dispatch.rs +++ b/nodedb/src/bridge/dispatch.rs @@ -480,8 +480,8 @@ impl Dispatcher { mod tests { use super::*; use crate::bridge::envelope::*; - use crate::bridge::physical_plan::DocumentOp; use crate::types::*; + use nodedb_physical::physical_plan::DocumentOp; use std::time::{Duration, Instant}; fn make_request(vshard: u32) -> envelope::Request { diff --git a/nodedb/src/bridge/envelope.rs b/nodedb/src/bridge/envelope.rs index 804fd930a..71ce08c62 100644 --- a/nodedb/src/bridge/envelope.rs +++ b/nodedb/src/bridge/envelope.rs @@ -162,7 +162,7 @@ pub struct Response { pub error_code: Option, } -pub use super::physical_plan::PhysicalPlan; +pub use nodedb_physical::physical_plan::PhysicalPlan; /// Request priority. Higher priority requests are scheduled first on the Data Plane. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -336,7 +336,7 @@ impl From for ErrorCode { #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{DocumentOp, MetaOp}; + use nodedb_physical::physical_plan::{DocumentOp, MetaOp}; use std::time::Duration; fn sample_request() -> Request { diff --git a/nodedb/src/bridge/mod.rs b/nodedb/src/bridge/mod.rs index c318cde2b..f2a6e44f7 100644 --- a/nodedb/src/bridge/mod.rs +++ b/nodedb/src/bridge/mod.rs @@ -2,7 +2,6 @@ pub mod dispatch; pub mod envelope; -pub mod physical_plan; pub mod quiesce; pub mod slab; diff --git a/nodedb/src/bridge/physical_plan/wire.rs b/nodedb/src/bridge/physical_plan/wire.rs deleted file mode 100644 index d24627ee3..000000000 --- a/nodedb/src/bridge/physical_plan/wire.rs +++ /dev/null @@ -1,332 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -//! Wire-format encode/decode helpers for PhysicalPlan. -//! -//! MessagePack encoding via zerompk. Used by the cluster layer to ship -//! physical plans over the wire as part of `ExecuteRequest` RPC. - -use super::PhysicalPlan; -use crate::Error; - -/// Encode a `PhysicalPlan` to MessagePack bytes. -/// -/// Returns an error for `ClusterArray` variants, which are handled on the -/// Control Plane and must never be shipped over the QUIC wire. -pub fn encode(plan: &PhysicalPlan) -> Result, Error> { - if matches!(plan, PhysicalPlan::ClusterArray(_)) { - return Err(Error::Internal { - detail: "ClusterArray plans must not be sent over the wire".into(), - }); - } - zerompk::to_msgpack_vec(plan).map_err(|e| Error::Internal { - detail: format!("plan encode: {e}"), - }) -} - -/// Decode a `PhysicalPlan` from MessagePack bytes. -pub fn decode(bytes: &[u8]) -> Result { - zerompk::from_msgpack(bytes).map_err(|e| Error::Internal { - detail: format!("plan decode: {e}"), - }) -} - -/// Encode a `Vec` to MessagePack bytes. -/// -/// Used by the Calvin scheduler when building `TxClass::plans` bytes for a -/// cross-shard transaction that will be shipped through the sequencer. -pub fn encode_batch(plans: &Vec) -> Result, Error> { - for plan in plans { - if matches!(plan, PhysicalPlan::ClusterArray(_)) { - return Err(Error::Internal { - detail: "ClusterArray plans must not be shipped via the sequencer".into(), - }); - } - } - zerompk::to_msgpack_vec(plans).map_err(|e| Error::Internal { - detail: format!("plan batch encode: {e}"), - }) -} - -/// Decode a `Vec` from MessagePack bytes. -/// -/// Used by the Calvin scheduler to decode the opaque `TxClass::plans` blob -/// into executable plans for dispatch via `MetaOp::CalvinExecute`. -pub fn decode_batch(bytes: &[u8]) -> Result, Error> { - zerompk::from_msgpack(bytes).map_err(|e| Error::Internal { - detail: format!("plan batch decode: {e}"), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::bridge::physical_plan::{ - AggregateSpec, BalancedDef, ColumnarOp, CrdtOp, DocumentOp, EnforcementOptions, GraphOp, - JoinProjection, KvOp, MetaOp, QueryOp, SpatialOp, SpatialPredicate, TextOp, TimeseriesOp, - VectorOp, - }; - use crate::engine::graph::algo::params::{AlgoParams, GraphAlgorithm}; - use crate::engine::graph::edge_store::Direction; - use crate::engine::graph::traversal_options::GraphTraversalOptions; - use crate::engine::timeseries::continuous_agg::{ - AggFunction, AggregateExpr, ContinuousAggregateDef, RefreshPolicy, - }; - use crate::types::RequestId; - - fn roundtrip(plan: PhysicalPlan) { - let encoded = encode(&plan).expect("encode failed"); - let decoded = decode(&encoded).expect("decode failed"); - assert_eq!(plan, decoded, "roundtrip mismatch"); - } - - #[test] - fn roundtrip_vector() { - roundtrip(PhysicalPlan::Vector(VectorOp::Search { - collection: "embeddings".into(), - query_vector: vec![0.1, 0.2, 0.3], - top_k: 10, - ef_search: 40, - metric: nodedb_types::vector_distance::DistanceMetric::L2, - filter_bitmap: Some(nodedb_types::SurrogateBitmap::from_iter( - [1u32, 2].map(nodedb_types::Surrogate), - )), - field_name: "vec".into(), - rls_filters: vec![], - inline_prefilter_plan: None, - ann_options: Default::default(), - skip_payload_fetch: false, - payload_filters: Vec::new(), - })); - } - - #[test] - fn roundtrip_graph() { - roundtrip(PhysicalPlan::Graph(GraphOp::Hop { - start_nodes: vec!["alice".into()], - edge_label: Some("follows".into()), - direction: Direction::Out, - depth: 2, - options: GraphTraversalOptions::default(), - rls_filters: vec![], - frontier_bitmap: None, - })); - } - - #[test] - fn roundtrip_graph_algo() { - roundtrip(PhysicalPlan::Graph(GraphOp::Algo { - algorithm: GraphAlgorithm::PageRank, - params: AlgoParams { - collection: "social".into(), - damping: Some(0.85), - max_iterations: Some(20), - ..Default::default() - }, - })); - } - - #[test] - fn roundtrip_document() { - roundtrip(PhysicalPlan::Document(DocumentOp::PointGet { - collection: "users".into(), - document_id: "user-1".into(), - surrogate: nodedb_types::Surrogate::ZERO, - pk_bytes: vec![], - rls_filters: vec![], - system_as_of_ms: None, - valid_at_ms: None, - })); - } - - #[test] - fn roundtrip_document_register() { - roundtrip(PhysicalPlan::Document(DocumentOp::Register { - collection: "users".into(), - indexes: vec![crate::bridge::physical_plan::RegisteredIndex { - name: "email".into(), - path: "$.email".into(), - unique: false, - case_insensitive: false, - state: crate::bridge::physical_plan::RegisteredIndexState::Ready, - predicate: None, - }], - crdt_enabled: false, - storage_mode: crate::bridge::physical_plan::StorageMode::Schemaless, - enforcement: Box::new(EnforcementOptions { - append_only: true, - balanced: Some(BalancedDef { - group_key_column: "journal_id".into(), - entry_type_column: "type".into(), - debit_value: "D".into(), - credit_value: "C".into(), - amount_column: "amount".into(), - }), - ..Default::default() - }), - bitemporal: false, - })); - } - - #[test] - fn roundtrip_kv() { - roundtrip(PhysicalPlan::Kv(KvOp::Put { - collection: "sessions".into(), - key: b"sess:abc".to_vec(), - value: b"\x81\xa3foo\xa3bar".to_vec(), - ttl_ms: 3_600_000, - surrogate: nodedb_types::Surrogate::ZERO, - })); - } - - #[test] - fn roundtrip_text() { - roundtrip(PhysicalPlan::Text(TextOp::Search { - collection: "docs".into(), - query: "hello world".into(), - top_k: 5, - fuzzy: true, - rls_filters: vec![], - prefilter: None, - })); - } - - #[test] - fn roundtrip_columnar() { - roundtrip(PhysicalPlan::Columnar(ColumnarOp::Scan { - collection: "metrics".into(), - projection: vec!["cpu".into(), "mem".into()], - limit: 1000, - filters: vec![], - rls_filters: vec![], - sort_keys: vec![], - system_as_of_ms: None, - valid_at_ms: None, - prefilter: None, - computed_columns: vec![], - })); - } - - #[test] - fn roundtrip_timeseries() { - roundtrip(PhysicalPlan::Timeseries(TimeseriesOp::Ingest { - collection: "metrics".into(), - payload: vec![0xc0], - format: "ilp".into(), - wal_lsn: Some(42), - surrogates: vec![nodedb_types::Surrogate::ZERO], - })); - roundtrip(PhysicalPlan::Timeseries(TimeseriesOp::Scan { - collection: "cpu_metrics".into(), - time_range: (0, i64::MAX), - projection: vec!["cpu".into()], - limit: 500, - filters: vec![], - bucket_interval_ms: 60_000, - group_by: vec!["host".into()], - aggregates: vec![("avg".into(), "cpu".into())], - gap_fill: "null".into(), - computed_columns: vec![], - rls_filters: vec![], - system_as_of_ms: None, - valid_at_ms: None, - })); - } - - #[test] - fn roundtrip_spatial() { - roundtrip(PhysicalPlan::Spatial(SpatialOp::Scan { - collection: "places".into(), - field: "location".into(), - predicate: SpatialPredicate::DWithin, - query_geometry: nodedb_types::geometry::Geometry::point(0.0, 0.0), - distance_meters: 500.0, - attribute_filters: vec![], - limit: 20, - projection: vec!["name".into()], - rls_filters: vec![], - prefilter: None, - })); - } - - #[test] - fn roundtrip_crdt() { - roundtrip(PhysicalPlan::Crdt(CrdtOp::Read { - collection: "notes".into(), - document_id: "note-1".into(), - })); - } - - #[test] - fn roundtrip_query() { - roundtrip(PhysicalPlan::Query(QueryOp::Aggregate { - collection: "orders".into(), - group_by: vec!["status".into()], - aggregates: vec![AggregateSpec { - function: "count".into(), - alias: "cnt".into(), - user_alias: None, - field: "*".into(), - expr: None, - }], - filters: vec![], - having: vec![], - limit: 100, - sub_group_by: vec![], - sub_aggregates: vec![], - grouping_sets: vec![], - sort_keys: vec![], - })); - } - - #[test] - fn roundtrip_query_hashjoin() { - roundtrip(PhysicalPlan::Query(QueryOp::HashJoin { - left_collection: "orders".into(), - right_collection: "customers".into(), - left_alias: None, - right_alias: None, - on: vec![("customer_id".into(), "id".into())], - join_type: "inner".into(), - limit: 50, - post_group_by: vec![], - post_aggregates: vec![], - projection: vec![JoinProjection { - source: "orders.id".into(), - output: "order_id".into(), - }], - post_filters: vec![], - inline_left: None, - inline_right: None, - inline_left_bitmap: None, - inline_right_bitmap: None, - })); - } - - #[test] - fn roundtrip_meta() { - roundtrip(PhysicalPlan::Meta(MetaOp::Cancel { - target_request_id: RequestId::new(42), - })); - } - - #[test] - fn roundtrip_meta_continuous_agg() { - roundtrip(PhysicalPlan::Meta(MetaOp::RegisterContinuousAggregate { - def: ContinuousAggregateDef { - name: "metrics_1m".into(), - source: "raw_metrics".into(), - bucket_interval: "1m".into(), - bucket_interval_ms: 60_000, - group_by: vec!["host".into()], - aggregates: vec![AggregateExpr { - function: AggFunction::Avg, - source_column: "cpu".into(), - output_column: "cpu_avg".into(), - }], - refresh_policy: RefreshPolicy::OnFlush, - retention_period_ms: 0, - stale: false, - }, - })); - } -} diff --git a/nodedb/src/control/array_sync/inbound_propose.rs b/nodedb/src/control/array_sync/inbound_propose.rs index 86965cc31..24e17f051 100644 --- a/nodedb/src/control/array_sync/inbound_propose.rs +++ b/nodedb/src/control/array_sync/inbound_propose.rs @@ -214,8 +214,8 @@ impl OriginArrayInbound { &self, op: &ArrayOp, ) -> Result> { - use crate::bridge::physical_plan::ArrayOp as DataArrayOp; use nodedb_array::sync::op::ArrayOpKind; + use nodedb_physical::physical_plan::ArrayOp as DataArrayOp; let array_id = nodedb_array::types::ArrayId::new(self.tenant_id(), &op.header.array); diff --git a/nodedb/src/control/array_sync/raft_apply.rs b/nodedb/src/control/array_sync/raft_apply.rs index 40597b81c..97102d4a6 100644 --- a/nodedb/src/control/array_sync/raft_apply.rs +++ b/nodedb/src/control/array_sync/raft_apply.rs @@ -89,8 +89,8 @@ pub(crate) async fn apply_array_op( }; // Build Data Plane plan. - use crate::bridge::physical_plan::ArrayOp as DataArrayOp; use nodedb_array::sync::op::ArrayOpKind; + use nodedb_physical::physical_plan::ArrayOp as DataArrayOp; let tenant_id = TenantId::new(0); // array ops are tenant-0 at the sync layer let array_id = nodedb_array::types::ArrayId::new(tenant_id, &op.header.array); @@ -260,7 +260,7 @@ async fn ensure_array_open( let open_request_id = state.next_request_id(); let open_plan = crate::bridge::envelope::PhysicalPlan::Array( - crate::bridge::physical_plan::ArrayOp::OpenArray { + nodedb_physical::physical_plan::ArrayOp::OpenArray { array_id: array_id.clone(), schema_msgpack, schema_hash, diff --git a/nodedb/src/control/backup/orchestrator.rs b/nodedb/src/control/backup/orchestrator.rs index 315b0ab0f..7cc40a4b2 100644 --- a/nodedb/src/control/backup/orchestrator.rs +++ b/nodedb/src/control/backup/orchestrator.rs @@ -21,10 +21,10 @@ use nodedb_types::backup_envelope::{EnvelopeMeta, EnvelopeWriter}; use crate::Error; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{MetaOp, wire as plan_wire}; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::state::SharedState; use crate::types::{DatabaseId, TenantId, TraceId}; +use nodedb_physical::physical_plan::{MetaOp, wire as plan_wire}; /// Default per-node snapshot dispatch timeout. const NODE_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(120); diff --git a/nodedb/src/control/backup/restore/mod.rs b/nodedb/src/control/backup/restore/mod.rs index 7bc69ee27..6d805d46f 100644 --- a/nodedb/src/control/backup/restore/mod.rs +++ b/nodedb/src/control/backup/restore/mod.rs @@ -20,10 +20,10 @@ use serde::Serialize; use crate::Error; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::state::SharedState; use crate::types::{TenantDataSnapshot, TenantId}; +use nodedb_physical::physical_plan::MetaOp; use remote::{NODE_RESTORE_TIMEOUT, dispatch_remote, envelope_to_err}; use sections::{apply_metadata_sections, merge_sections}; diff --git a/nodedb/src/control/backup/restore/remote.rs b/nodedb/src/control/backup/restore/remote.rs index b8810d4de..1394dcfba 100644 --- a/nodedb/src/control/backup/restore/remote.rs +++ b/nodedb/src/control/backup/restore/remote.rs @@ -10,9 +10,9 @@ use nodedb_types::backup_envelope::EnvelopeError; use crate::Error; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::wire as plan_wire; use crate::control::state::SharedState; use crate::types::TraceId; +use nodedb_physical::physical_plan::wire as plan_wire; pub(super) const NODE_RESTORE_TIMEOUT: Duration = Duration::from_secs(120); diff --git a/nodedb/src/control/catalog_entry/post_apply/async_dispatch/continuous_aggregate.rs b/nodedb/src/control/catalog_entry/post_apply/async_dispatch/continuous_aggregate.rs index 09ca30cc9..d9c62cb93 100644 --- a/nodedb/src/control/catalog_entry/post_apply/async_dispatch/continuous_aggregate.rs +++ b/nodedb/src/control/catalog_entry/post_apply/async_dispatch/continuous_aggregate.rs @@ -14,10 +14,10 @@ use std::sync::Arc; use tracing::debug; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; use crate::engine::timeseries::continuous_agg::ContinuousAggregateDef; use crate::types::{DatabaseId, ReadConsistency, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::MetaOp; /// Dispatch `MetaOp::RegisterContinuousAggregate` to every core on /// this node. `def_bytes` is the MessagePack-encoded diff --git a/nodedb/src/control/catalog_entry/post_apply/async_dispatch/materialized_view.rs b/nodedb/src/control/catalog_entry/post_apply/async_dispatch/materialized_view.rs index 9298eee0c..9267be2db 100644 --- a/nodedb/src/control/catalog_entry/post_apply/async_dispatch/materialized_view.rs +++ b/nodedb/src/control/catalog_entry/post_apply/async_dispatch/materialized_view.rs @@ -11,9 +11,9 @@ use std::sync::Arc; use tracing::debug; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; use crate::types::{DatabaseId, ReadConsistency, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::MetaOp; /// Dispatch `MetaOp::UnregisterMaterializedView` to every core on /// this node. Fire-and-forget: any core that fails or times out diff --git a/nodedb/src/control/catalog_entry/tests/invalidation.rs b/nodedb/src/control/catalog_entry/tests/invalidation.rs index 82477d95c..6bc5e6b69 100644 --- a/nodedb/src/control/catalog_entry/tests/invalidation.rs +++ b/nodedb/src/control/catalog_entry/tests/invalidation.rs @@ -70,7 +70,7 @@ fn make_test_state() -> (Arc, Arc) { /// Insert a sentinel plan entry for collection `col` at version 1. fn plant_sentinel(cache: &PlanCache, col: &str) -> PlanCacheKey { - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; let key = PlanCacheKey { sql_text_hash: hash_sql(&format!("SELECT * FROM {col}")), placeholder_types_hash: 0, diff --git a/nodedb/src/control/checkpoint_manager.rs b/nodedb/src/control/checkpoint_manager.rs index 0725614c5..1f19ca832 100644 --- a/nodedb/src/control/checkpoint_manager.rs +++ b/nodedb/src/control/checkpoint_manager.rs @@ -30,10 +30,10 @@ use tracing::{debug, info, warn}; use crate::bridge::dispatch::Dispatcher; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use crate::bridge::physical_plan::MetaOp; use crate::control::request_tracker::RequestTracker; use crate::types::{DatabaseId, Lsn, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; use crate::wal::WalManager; +use nodedb_physical::physical_plan::MetaOp; /// Monotonic counter for checkpoint request IDs. /// Uses a high base to avoid collision with session-generated request IDs. diff --git a/nodedb/src/control/clone/copyup.rs b/nodedb/src/control/clone/copyup.rs index 22505f973..b888b6092 100644 --- a/nodedb/src/control/clone/copyup.rs +++ b/nodedb/src/control/clone/copyup.rs @@ -18,9 +18,9 @@ use std::time::Duration; use nodedb_types::{CloneOrigin, DatabaseId, Surrogate, TenantId}; use crate::bridge::envelope::{Priority, Request, Status}; -use crate::bridge::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; use crate::control::state::SharedState; use crate::types::{ReadConsistency, RequestId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; /// Parameters for a KV copy-up operation. pub struct KvCopyUpParams<'a> { diff --git a/nodedb/src/control/clone/resolver/resolve.rs b/nodedb/src/control/clone/resolver/resolve.rs index cf761195c..ad6e8bea1 100644 --- a/nodedb/src/control/clone/resolver/resolve.rs +++ b/nodedb/src/control/clone/resolver/resolve.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use nodedb_types::{CloneOrigin, CloneStatus, Lsn, TenantId}; -use crate::control::planner::physical::PhysicalTask; use crate::control::state::SharedState; use crate::types::VShardId; +use nodedb_physical::physical_task::PhysicalTask; use super::super::metadata::ClonePredicatesNote; use super::rewrite::rewrite_plan_for_source; @@ -177,7 +177,7 @@ pub fn resolve_read( vshard_id: source_vshard, database_id: src_db_id, plan: source_plan, - post_set_op: crate::control::planner::physical::PostSetOp::None, + post_set_op: nodedb_physical::physical_task::PostSetOp::None, }; this_level_tasks.push(task); } diff --git a/nodedb/src/control/clone/resolver/rewrite.rs b/nodedb/src/control/clone/resolver/rewrite.rs index 1ca4dedae..5e8ba4cd6 100644 --- a/nodedb/src/control/clone/resolver/rewrite.rs +++ b/nodedb/src/control/clone/resolver/rewrite.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use nodedb_types::DatabaseId; -use crate::bridge::physical_plan::{ColumnarOp, DocumentOp, KvOp, PhysicalPlan, TimeseriesOp}; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::{ColumnarOp, DocumentOp, KvOp, PhysicalPlan, TimeseriesOp}; /// Rewrite a `PhysicalPlan` to target the source database and collection at /// the effective source LSN. Returns `None` for plan types that are not diff --git a/nodedb/src/control/cluster/array_cluster_exec.rs b/nodedb/src/control/cluster/array_cluster_exec.rs index 3f29c48f5..86ed6d437 100644 --- a/nodedb/src/control/cluster/array_cluster_exec.rs +++ b/nodedb/src/control/cluster/array_cluster_exec.rs @@ -33,12 +33,12 @@ use nodedb_cluster::rpc_codec::RaftRpc; use nodedb_cluster::wire::VShardEnvelope; use nodedb_cluster::{NexarTransport, RoutingTable}; -use crate::bridge::physical_plan::ClusterArrayOp; use crate::control::cluster::array_cluster_helpers::{ array_resp_msg_type, cluster_err, encode_err, finalize_agg_partials, }; use crate::control::cluster::array_executor::DataPlaneArrayExecutor; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::ClusterArrayOp; use zerompk; /// Default RPC timeout for array cluster operations in milliseconds. @@ -422,7 +422,7 @@ impl ClusterArrayExecutor { coordinator.coord_agg(req).await.map_err(cluster_err)?; // Decode the reducer so we know which field to finalize. - let reducer: crate::bridge::physical_plan::ArrayReducer = + let reducer: nodedb_physical::physical_plan::ArrayReducer = zerompk::from_msgpack(reducer_msgpack).map_err(|e| crate::Error::Serialization { format: "msgpack".into(), detail: format!("agg reducer decode: {e}"), diff --git a/nodedb/src/control/cluster/array_cluster_helpers.rs b/nodedb/src/control/cluster/array_cluster_helpers.rs index 7aeb1f7a9..c77978b4e 100644 --- a/nodedb/src/control/cluster/array_cluster_helpers.rs +++ b/nodedb/src/control/cluster/array_cluster_helpers.rs @@ -39,10 +39,10 @@ impl zerompk::ToMessagePack for AggValue { /// `decode_payload_to_json` produces identical JSON on both paths. pub(super) fn finalize_agg_partials( partials: &[ArrayAggPartial], - reducer: &crate::bridge::physical_plan::ArrayReducer, + reducer: &nodedb_physical::physical_plan::ArrayReducer, group_by_dim: i32, ) -> Vec> { - use crate::bridge::physical_plan::ArrayReducer; + use nodedb_physical::physical_plan::ArrayReducer; let finalize = |p: &ArrayAggPartial| -> AggValue { if p.count == 0 { diff --git a/nodedb/src/control/cluster/array_executor.rs b/nodedb/src/control/cluster/array_executor.rs index 93c864a9f..7a2f17572 100644 --- a/nodedb/src/control/cluster/array_executor.rs +++ b/nodedb/src/control/cluster/array_executor.rs @@ -34,11 +34,11 @@ use nodedb_types::SurrogateBitmap; use zerompk; use crate::bridge::envelope::{Priority, Request}; -use crate::bridge::physical_plan::{ArrayOp, ArrayReducer, PhysicalPlan}; use crate::control::state::SharedState; use crate::data::executor::response_codec::ArraySliceResponse; use crate::event::types::EventSource; use crate::types::{DatabaseId, ReadConsistency, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{ArrayOp, ArrayReducer, PhysicalPlan}; /// Timeout for a single shard-side array operation dispatched through the /// local SPSC bridge. This bounds how long the cluster handler waits for the diff --git a/nodedb/src/control/cluster/calvin/scheduler/driver/barrier.rs b/nodedb/src/control/cluster/calvin/scheduler/driver/barrier.rs index 0cea2d34d..564d2ffc8 100644 --- a/nodedb/src/control/cluster/calvin/scheduler/driver/barrier.rs +++ b/nodedb/src/control/cluster/calvin/scheduler/driver/barrier.rs @@ -24,9 +24,9 @@ use std::time::Instant; use nodedb_cluster::calvin::types::SequencedTxn; -use crate::bridge::physical_plan::meta::PassiveReadKeyId; use crate::control::cluster::calvin::scheduler::lock_manager::LockKey; use crate::types::TenantId; +use nodedb_physical::physical_plan::meta::PassiveReadKeyId; use nodedb_types::Value; // ── ReadResultEvent ──────────────────────────────────────────────────────────── diff --git a/nodedb/src/control/cluster/calvin/scheduler/driver/core/dispatch.rs b/nodedb/src/control/cluster/calvin/scheduler/driver/core/dispatch.rs index de80cf347..9dd2e2a68 100644 --- a/nodedb/src/control/cluster/calvin/scheduler/driver/core/dispatch.rs +++ b/nodedb/src/control/cluster/calvin/scheduler/driver/core/dispatch.rs @@ -10,11 +10,11 @@ use nodedb_cluster::calvin::types::SequencedTxn; use super::scheduler::Scheduler; use crate::bridge::envelope::{Priority, Request}; -use crate::bridge::physical_plan::PhysicalPlan; -use crate::bridge::physical_plan::meta::MetaOp; -use crate::bridge::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, TimeseriesOp, VectorOp}; use crate::control::cluster::calvin::scheduler::lock_manager::{LockKey, TxnId}; use crate::types::{DatabaseId, ReadConsistency, VShardId}; +use nodedb_physical::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::meta::MetaOp; +use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, TimeseriesOp, VectorOp}; impl Scheduler { fn local_calvin_plans( @@ -62,7 +62,7 @@ impl Scheduler { PhysicalPlan::Timeseries(TimeseriesOp::Ingest { collection, .. }) => { collection.as_str() } - PhysicalPlan::Columnar(crate::bridge::physical_plan::ColumnarOp::Insert { + PhysicalPlan::Columnar(nodedb_physical::physical_plan::ColumnarOp::Insert { collection, .. }) => collection.as_str(), @@ -228,7 +228,7 @@ impl Scheduler { keys: std::collections::BTreeSet, lock_acquired_time: Instant, injected_reads: std::collections::BTreeMap< - crate::bridge::physical_plan::meta::PassiveReadKeyId, + nodedb_physical::physical_plan::meta::PassiveReadKeyId, nodedb_types::Value, >, ) { diff --git a/nodedb/src/control/cluster/calvin/scheduler/driver/helpers.rs b/nodedb/src/control/cluster/calvin/scheduler/driver/helpers.rs index 8f58e1445..ad4cdab14 100644 --- a/nodedb/src/control/cluster/calvin/scheduler/driver/helpers.rs +++ b/nodedb/src/control/cluster/calvin/scheduler/driver/helpers.rs @@ -7,9 +7,9 @@ use std::sync::Arc; use nodedb_cluster::calvin::types::{EngineKeySet, SequencedTxn}; -use crate::bridge::physical_plan::PhysicalPlan; -use crate::bridge::physical_plan::wire as plan_wire; use crate::control::cluster::calvin::scheduler::lock_manager::LockKey; +use nodedb_physical::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::wire as plan_wire; /// Expand the read_set ∪ write_set of a sequenced transaction into a /// `BTreeSet`. diff --git a/nodedb/src/control/distributed_applier/apply_loop.rs b/nodedb/src/control/distributed_applier/apply_loop.rs index e99ccdbd0..61122380e 100644 --- a/nodedb/src/control/distributed_applier/apply_loop.rs +++ b/nodedb/src/control/distributed_applier/apply_loop.rs @@ -161,7 +161,7 @@ pub async fn run_apply_loop( ref values, } => { let decoded_values: Vec<( - crate::bridge::physical_plan::meta::PassiveReadKeyId, + nodedb_physical::physical_plan::meta::PassiveReadKeyId, nodedb_types::Value, )> = match zerompk::from_msgpack(values) { Ok(decoded) => decoded, diff --git a/nodedb/src/control/exec_receiver.rs b/nodedb/src/control/exec_receiver.rs index fb1a53901..5b639801b 100644 --- a/nodedb/src/control/exec_receiver.rs +++ b/nodedb/src/control/exec_receiver.rs @@ -19,10 +19,10 @@ use nodedb_cluster::forward::PlanExecutor; use nodedb_cluster::rpc_codec::{ExecuteRequest, ExecuteResponse, TypedClusterError}; use crate::bridge::envelope::{Priority, Request}; -use crate::bridge::physical_plan::wire as plan_wire; use crate::control::state::SharedState; use crate::types::DatabaseId; use crate::types::ReadConsistency; +use nodedb_physical::physical_plan::wire as plan_wire; /// Numeric code for `TypedClusterError::Internal` when plan bytes fail to decode. const PLAN_DECODE_FAILED: u32 = nodedb_cluster::rpc_codec::PLAN_DECODE_FAILED; diff --git a/nodedb/src/control/gateway/cache_miss.rs b/nodedb/src/control/gateway/cache_miss.rs index fd339304d..7088e17ed 100644 --- a/nodedb/src/control/gateway/cache_miss.rs +++ b/nodedb/src/control/gateway/cache_miss.rs @@ -83,7 +83,7 @@ async fn refresh_descriptor_lease( #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; fn ok_plan() -> Result { Ok(PhysicalPlan::Kv(KvOp::Get { diff --git a/nodedb/src/control/gateway/core.rs b/nodedb/src/control/gateway/core.rs index 88a14c25e..8800b902f 100644 --- a/nodedb/src/control/gateway/core.rs +++ b/nodedb/src/control/gateway/core.rs @@ -25,9 +25,9 @@ use std::time::SystemTime; use tracing::{Instrument, debug, info_span}; use crate::Error; -use crate::bridge::physical_plan::PhysicalPlan; use crate::control::state::SharedState; use crate::types::{DatabaseId, TenantId, TraceId}; +use nodedb_physical::physical_plan::PhysicalPlan; use super::dispatcher::{default_deadline_ms, dispatch_route}; use super::fuser::fuse_payloads; @@ -396,8 +396,8 @@ impl Gateway { #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::gateway::plan_cache::SqlKey; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; fn kv_get(col: &str) -> PhysicalPlan { PhysicalPlan::Kv(KvOp::Get { diff --git a/nodedb/src/control/gateway/dispatcher.rs b/nodedb/src/control/gateway/dispatcher.rs index c141c33b3..8f000690e 100644 --- a/nodedb/src/control/gateway/dispatcher.rs +++ b/nodedb/src/control/gateway/dispatcher.rs @@ -22,10 +22,10 @@ use nodedb_cluster::rpc_codec::{ExecuteRequest, RaftRpc, TypedClusterError}; use tracing::debug; use crate::Error; -use crate::bridge::physical_plan::wire as plan_wire; use crate::control::server::dispatch_utils::dispatch_to_data_plane; use crate::control::state::SharedState; use crate::types::{DatabaseId, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::wire as plan_wire; use super::route::{RouteDecision, TaskRoute}; use super::version_set::GatewayVersionSet; @@ -98,7 +98,7 @@ async fn dispatch_local( /// Arguments for a remote dispatch call (bundles the 8 parameters to stay /// within clippy's `too_many_arguments` limit). struct RemoteDispatchArgs<'a> { - plan: crate::bridge::physical_plan::PhysicalPlan, + plan: nodedb_physical::physical_plan::PhysicalPlan, shared: &'a Arc, node_id: u64, vshard_id: u64, diff --git a/nodedb/src/control/gateway/invalidation.rs b/nodedb/src/control/gateway/invalidation.rs index 5d5866135..7dba84305 100644 --- a/nodedb/src/control/gateway/invalidation.rs +++ b/nodedb/src/control/gateway/invalidation.rs @@ -56,9 +56,9 @@ mod tests { use std::sync::Arc; use super::*; - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::gateway::plan_cache::{PlanCache, PlanCacheKey, hash_sql}; use crate::control::gateway::version_set::GatewayVersionSet; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; fn kv_plan() -> Arc { Arc::new(PhysicalPlan::Kv(KvOp::Get { diff --git a/nodedb/src/control/gateway/plan_cache.rs b/nodedb/src/control/gateway/plan_cache.rs index 8f198fac4..9bbc8a751 100644 --- a/nodedb/src/control/gateway/plan_cache.rs +++ b/nodedb/src/control/gateway/plan_cache.rs @@ -20,7 +20,7 @@ use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; -use crate::bridge::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::PhysicalPlan; use super::version_set::GatewayVersionSet; @@ -257,8 +257,8 @@ mod tests { use std::sync::Arc; use super::*; - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::gateway::version_set::GatewayVersionSet; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; fn kv_plan(collection: &str) -> Arc { Arc::new(PhysicalPlan::Kv(KvOp::Get { diff --git a/nodedb/src/control/gateway/route.rs b/nodedb/src/control/gateway/route.rs index f22f04514..fdc30ba56 100644 --- a/nodedb/src/control/gateway/route.rs +++ b/nodedb/src/control/gateway/route.rs @@ -6,7 +6,7 @@ //! [`RouteDecision`] encodes whether the plan runs on the local node, //! on a single remote node, or broadcasts to every vShard in a list. -use crate::bridge::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::PhysicalPlan; /// A routing decision for a single physical sub-plan. #[derive(Debug, Clone)] @@ -49,7 +49,7 @@ pub enum RouteDecision { #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; #[test] fn route_decision_equality() { diff --git a/nodedb/src/control/gateway/router.rs b/nodedb/src/control/gateway/router.rs index 3c6f8a830..87c3afa14 100644 --- a/nodedb/src/control/gateway/router.rs +++ b/nodedb/src/control/gateway/router.rs @@ -20,7 +20,7 @@ use nodedb_cluster::routing::{RoutingTable, vshard_for_collection}; use nodedb_types::id::DatabaseId; -use crate::bridge::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::PhysicalPlan; use super::route::{RouteDecision, TaskRoute}; use super::version_set::touched_collections; @@ -158,7 +158,7 @@ fn primary_vshard(plan: &PhysicalPlan, database_id: DatabaseId) -> u32 { #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; + use nodedb_physical::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; fn single_node_table() -> RoutingTable { RoutingTable::uniform(1, &[1], 1) diff --git a/nodedb/src/control/gateway/version_set.rs b/nodedb/src/control/gateway/version_set.rs index 9e75f9cca..41c3567aa 100644 --- a/nodedb/src/control/gateway/version_set.rs +++ b/nodedb/src/control/gateway/version_set.rs @@ -9,7 +9,7 @@ use std::hash::{DefaultHasher, Hash, Hasher}; -use crate::bridge::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::PhysicalPlan; /// Deterministic ordered set of `(collection_name, descriptor_version)` pairs. /// @@ -84,7 +84,7 @@ impl GatewayVersionSet { /// Returns a `Vec` that may contain duplicates; callers are /// responsible for de-duplication (e.g., `GatewayVersionSet::from_plan`). pub fn touched_collections(plan: &PhysicalPlan) -> Vec { - use crate::bridge::physical_plan::*; + use nodedb_physical::physical_plan::*; let mut out: Vec = Vec::new(); @@ -393,7 +393,7 @@ pub fn touched_collections(plan: &PhysicalPlan) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; + use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; #[test] fn from_plan_kv_get() { diff --git a/nodedb/src/control/maintenance/clone_materializer/columnar.rs b/nodedb/src/control/maintenance/clone_materializer/columnar.rs index 7953546a8..0f5b7aed8 100644 --- a/nodedb/src/control/maintenance/clone_materializer/columnar.rs +++ b/nodedb/src/control/maintenance/clone_materializer/columnar.rs @@ -29,13 +29,15 @@ use nodedb_types::{CloneStatus, DatabaseId, Lsn, TenantId}; use super::dispatch::dispatch_local; use super::reaper::{ReapParams, reap_materialized_collection}; use crate::bridge::envelope::Status; -use crate::bridge::physical_plan::document::UpdateValue; -use crate::bridge::physical_plan::{ColumnarInsertIntent, ColumnarOp, PhysicalPlan, TimeseriesOp}; use crate::control::catalog_entry::entry::CatalogEntry; use crate::control::metadata_proposer::propose_catalog_entry; use crate::control::planner::sql_plan_convert::convert::db_qualified; use crate::control::security::catalog::{StoredCollection, SystemCatalog}; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::document::UpdateValue; +use nodedb_physical::physical_plan::{ + ColumnarInsertIntent, ColumnarOp, PhysicalPlan, TimeseriesOp, +}; /// Rows fetched per scan round-trip. Matches the KV / Document page size. const SCAN_PAGE: usize = 4_096; diff --git a/nodedb/src/control/maintenance/clone_materializer/dispatch.rs b/nodedb/src/control/maintenance/clone_materializer/dispatch.rs index cc8b59807..7d938d27d 100644 --- a/nodedb/src/control/maintenance/clone_materializer/dispatch.rs +++ b/nodedb/src/control/maintenance/clone_materializer/dispatch.rs @@ -16,9 +16,9 @@ use std::time::{Duration, Instant}; use nodedb_types::{DatabaseId, TenantId}; use crate::bridge::envelope::{Priority, Request, Response}; -use crate::bridge::physical_plan::PhysicalPlan; use crate::control::state::SharedState; use crate::types::{ReadConsistency, RequestId, TraceId, VShardId}; +use nodedb_physical::physical_plan::PhysicalPlan; /// Dispatch a `PhysicalPlan` to the local Data Plane and await the response. /// diff --git a/nodedb/src/control/maintenance/clone_materializer/document.rs b/nodedb/src/control/maintenance/clone_materializer/document.rs index 092831f46..c722cbefb 100644 --- a/nodedb/src/control/maintenance/clone_materializer/document.rs +++ b/nodedb/src/control/maintenance/clone_materializer/document.rs @@ -25,12 +25,12 @@ use nodedb_types::{CloneStatus, DatabaseId, Lsn, Surrogate, TenantId}; use super::dispatch::dispatch_local; use super::reaper::{ReapParams, reap_materialized_collection}; use crate::bridge::envelope::Status; -use crate::bridge::physical_plan::{DocumentOp, PhysicalPlan}; use crate::control::catalog_entry::entry::CatalogEntry; use crate::control::metadata_proposer::propose_catalog_entry; use crate::control::planner::sql_plan_convert::convert::db_qualified; use crate::control::security::catalog::{StoredCollection, SystemCatalog}; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; /// Rows fetched per scan round-trip. Matches the KV page size. const SCAN_PAGE: usize = 4_096; diff --git a/nodedb/src/control/maintenance/clone_materializer/kv.rs b/nodedb/src/control/maintenance/clone_materializer/kv.rs index b423918ad..6bdb49ea8 100644 --- a/nodedb/src/control/maintenance/clone_materializer/kv.rs +++ b/nodedb/src/control/maintenance/clone_materializer/kv.rs @@ -18,12 +18,12 @@ use nodedb_types::{CloneStatus, DatabaseId, Lsn, TenantId}; use crate::bridge::envelope::Status; -use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::catalog_entry::entry::CatalogEntry; use crate::control::metadata_proposer::propose_catalog_entry; use crate::control::planner::sql_plan_convert::convert::db_qualified; use crate::control::security::catalog::{StoredCollection, SystemCatalog}; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use super::dispatch::dispatch_local; use super::reaper::{ReapParams, reap_materialized_collection}; diff --git a/nodedb/src/control/otel/receiver.rs b/nodedb/src/control/otel/receiver.rs index bd46dcf37..d0f7415c6 100644 --- a/nodedb/src/control/otel/receiver.rs +++ b/nodedb/src/control/otel/receiver.rs @@ -20,10 +20,10 @@ use prost::Message; use tracing::info; use super::proto; -use crate::bridge::physical_plan::{PhysicalPlan, TimeseriesOp}; use crate::control::server::dispatch_utils::dispatch_to_data_plane; use crate::control::state::SharedState; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; /// Configuration for the OTLP receiver. #[derive(Debug, Clone)] diff --git a/nodedb/src/control/planner/auto_tier.rs b/nodedb/src/control/planner/auto_tier.rs index c9b7edc73..791d679e6 100644 --- a/nodedb/src/control/planner/auto_tier.rs +++ b/nodedb/src/control/planner/auto_tier.rs @@ -13,11 +13,11 @@ //! is instantaneous and correct for steady-state operation. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::TimeseriesOp; use crate::engine::timeseries::retention_policy::RetentionPolicyDef; use crate::types::{DatabaseId, TenantId, VShardId}; +use nodedb_physical::physical_plan::TimeseriesOp; -use super::physical::{PhysicalTask, PostSetOp}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// Tenant + database scope identity, passed together to avoid over-8-arg signatures. #[derive(Clone, Copy, Debug)] diff --git a/nodedb/src/control/planner/calvin/dispatch.rs b/nodedb/src/control/planner/calvin/dispatch.rs index 103a39b6a..9aa480b3e 100644 --- a/nodedb/src/control/planner/calvin/dispatch.rs +++ b/nodedb/src/control/planner/calvin/dispatch.rs @@ -28,15 +28,15 @@ use nodedb_cluster::calvin::types::{EngineKeySet, ReadWriteSet, SortedVec, TxCla use nodedb_types::TenantId; use crate::Error; -use crate::bridge::physical_plan::{ - DocumentOp, GraphOp, KvOp, PhysicalPlan, TimeseriesOp, VectorOp, -}; use crate::control::cluster::calvin::executor::ollp::orchestrator::OllpOrchestrator; use crate::control::planner::calvin::types::{DispatchClass, DispatchOutcome}; -use crate::control::planner::physical::PhysicalTask; use crate::control::server::pgwire::session::TransactionState; use crate::control::server::pgwire::session::cross_shard_mode::CrossShardTxnMode; use crate::types::VShardId; +use nodedb_physical::physical_plan::{ + DocumentOp, GraphOp, KvOp, PhysicalPlan, TimeseriesOp, VectorOp, +}; +use nodedb_physical::physical_task::PhysicalTask; pub use crate::control::planner::calvin::predicate::predicate_class; @@ -91,17 +91,17 @@ pub fn is_write_plan(plan: &PhysicalPlan) -> bool { PhysicalPlan::Timeseries(op) => matches!(op, TimeseriesOp::Ingest { .. }), // Columnar writes PhysicalPlan::Columnar(op) => { - use crate::bridge::physical_plan::ColumnarOp; + use nodedb_physical::physical_plan::ColumnarOp; matches!(op, ColumnarOp::Insert { .. }) } // CRDT writes PhysicalPlan::Crdt(op) => { - use crate::bridge::physical_plan::CrdtOp; + use nodedb_physical::physical_plan::CrdtOp; matches!(op, CrdtOp::ListInsert { .. } | CrdtOp::ListDelete { .. }) } // Array writes PhysicalPlan::Array(op) => { - use crate::bridge::physical_plan::ArrayOp; + use nodedb_physical::physical_plan::ArrayOp; matches!( op, ArrayOp::Put { .. } | ArrayOp::Delete { .. } | ArrayOp::Flush { .. } diff --git a/nodedb/src/control/planner/calvin/dispatch_tests.rs b/nodedb/src/control/planner/calvin/dispatch_tests.rs index 5503f8570..f6df9a4de 100644 --- a/nodedb/src/control/planner/calvin/dispatch_tests.rs +++ b/nodedb/src/control/planner/calvin/dispatch_tests.rs @@ -4,12 +4,12 @@ use super::*; use crate::Error; -use crate::bridge::physical_plan::{DocumentOp, PhysicalPlan}; use crate::control::planner::calvin::types::{DispatchClass, DispatchOutcome}; -use crate::control::planner::physical::{PhysicalTask, PostSetOp}; use crate::control::server::pgwire::session::TransactionState; use crate::control::server::pgwire::session::cross_shard_mode::CrossShardTxnMode; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; fn doc_insert_task(vshard: u32) -> PhysicalTask { PhysicalTask { diff --git a/nodedb/src/control/planner/calvin/explain.rs b/nodedb/src/control/planner/calvin/explain.rs index 5f5f83719..56d5b5b5c 100644 --- a/nodedb/src/control/planner/calvin/explain.rs +++ b/nodedb/src/control/planner/calvin/explain.rs @@ -7,8 +7,8 @@ use crate::control::planner::calvin::dispatch::classify_dispatch; use crate::control::planner::calvin::types::DispatchClass; -use crate::control::planner::physical::PhysicalTask; use crate::control::server::pgwire::session::cross_shard_mode::CrossShardTxnMode; +use nodedb_physical::physical_task::PhysicalTask; /// Generate the Calvin dispatch preamble row for EXPLAIN output. /// @@ -73,9 +73,9 @@ pub fn calvin_explain_preamble( #[cfg(test)] mod tests { use super::*; - use crate::bridge::physical_plan::{DocumentOp, PhysicalPlan}; - use crate::control::planner::physical::{PhysicalTask, PostSetOp}; use crate::types::{TenantId, VShardId}; + use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; + use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; fn doc_insert_task(vshard: u32) -> PhysicalTask { PhysicalTask { diff --git a/nodedb/src/control/planner/calvin/preexec.rs b/nodedb/src/control/planner/calvin/preexec.rs index 35abc1956..1107289bb 100644 --- a/nodedb/src/control/planner/calvin/preexec.rs +++ b/nodedb/src/control/planner/calvin/preexec.rs @@ -19,10 +19,10 @@ use nodedb_types::TenantId; -use crate::bridge::physical_plan::{DocumentOp, PhysicalPlan}; use crate::control::server::dispatch_utils::dispatch_to_data_plane; use crate::control::state::SharedState; use crate::types::{TraceId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; /// Dispatch a pre-execution scan for the given collection and serialized /// filter bytes. Returns the sorted list of matching surrogate u32 values. diff --git a/nodedb/src/control/planner/context.rs b/nodedb/src/control/planner/context.rs index b373ab1d6..7ad59e372 100644 --- a/nodedb/src/control/planner/context.rs +++ b/nodedb/src/control/planner/context.rs @@ -237,7 +237,7 @@ impl QueryContext { sql: &str, tenant_id: crate::types::TenantId, database_id: crate::types::DatabaseId, - ) -> crate::Result> { + ) -> crate::Result> { self.plan_with_nodedb_sql(sql, tenant_id, database_id) .map(|(t, _)| t) } @@ -256,7 +256,7 @@ impl QueryContext { tenant_id: crate::types::TenantId, database_id: crate::types::DatabaseId, ) -> crate::Result<( - Vec, + Vec, super::descriptor_set::DescriptorVersionSet, )> { let inputs = match &self.catalog_inputs { @@ -320,7 +320,7 @@ impl QueryContext { tenant_id: crate::types::TenantId, database_id: crate::types::DatabaseId, sec: &PlanSecurityContext<'_>, - ) -> crate::Result> { + ) -> crate::Result> { self.plan_sql_with_rls_returning(sql, tenant_id, database_id, sec, false) .await } @@ -333,7 +333,7 @@ impl QueryContext { database_id: crate::types::DatabaseId, sec: &PlanSecurityContext<'_>, returning: bool, - ) -> crate::Result> { + ) -> crate::Result> { self.plan_sql_with_rls_and_versions(sql, tenant_id, database_id, sec, returning) .await .map(|(tasks, _)| tasks) @@ -353,7 +353,7 @@ impl QueryContext { sec: &PlanSecurityContext<'_>, _returning: bool, ) -> crate::Result<( - Vec, + Vec, super::descriptor_set::DescriptorVersionSet, )> { let (mut tasks, version_set) = self.plan_with_nodedb_sql(sql, tenant_id, database_id)?; @@ -380,7 +380,7 @@ impl QueryContext { tenant_id: crate::types::TenantId, database_id: crate::types::DatabaseId, sec: &PlanSecurityContext<'_>, - ) -> crate::Result> { + ) -> crate::Result> { let inputs = match &self.catalog_inputs { Some(i) => i, None => { diff --git a/nodedb/src/control/planner/mod.rs b/nodedb/src/control/planner/mod.rs index d85637c55..1840cecae 100644 --- a/nodedb/src/control/planner/mod.rs +++ b/nodedb/src/control/planner/mod.rs @@ -5,7 +5,6 @@ pub mod calvin; pub mod catalog_adapter; pub mod context; pub mod descriptor_set; -pub mod physical; pub mod procedural; pub mod rls_injection; pub mod sql_plan_convert; diff --git a/nodedb/src/control/planner/procedural/executor/core/dispatch.rs b/nodedb/src/control/planner/procedural/executor/core/dispatch.rs index 6c6e115d3..b84b6d6d2 100644 --- a/nodedb/src/control/planner/procedural/executor/core/dispatch.rs +++ b/nodedb/src/control/planner/procedural/executor/core/dispatch.rs @@ -191,7 +191,7 @@ impl<'a> StatementExecutor<'a> { let vshard_id = tasks[0].vshard_id; let plans: Vec<_> = tasks.into_iter().map(|t| t.plan).collect(); let batch_plan = crate::bridge::envelope::PhysicalPlan::Meta( - crate::bridge::physical_plan::MetaOp::TransactionBatch { plans }, + nodedb_physical::physical_plan::MetaOp::TransactionBatch { plans }, ); crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source( self.state, diff --git a/nodedb/src/control/planner/procedural/executor/transaction.rs b/nodedb/src/control/planner/procedural/executor/transaction.rs index cc6da3d63..d6eab6981 100644 --- a/nodedb/src/control/planner/procedural/executor/transaction.rs +++ b/nodedb/src/control/planner/procedural/executor/transaction.rs @@ -7,7 +7,7 @@ //! //! Triggers do NOT use this — they dispatch DML immediately. -use crate::control::planner::physical::PhysicalTask; +use nodedb_physical::physical_task::PhysicalTask; /// Buffered transaction context for stored procedure execution. /// @@ -96,9 +96,9 @@ impl ProcedureTransactionCtx { mod tests { use super::*; use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::DocumentOp; - use crate::control::planner::physical::PostSetOp; use crate::types::{TenantId, VShardId}; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_task::PostSetOp; fn dummy_task(id: &str) -> PhysicalTask { PhysicalTask { diff --git a/nodedb/src/control/planner/rls_injection.rs b/nodedb/src/control/planner/rls_injection.rs index d38a6933a..94cf9f8b8 100644 --- a/nodedb/src/control/planner/rls_injection.rs +++ b/nodedb/src/control/planner/rls_injection.rs @@ -8,14 +8,14 @@ //! session or JWT awareness. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ - ColumnarOp, DocumentOp, GraphOp, KvOp, QueryOp, SpatialOp, TextOp, TimeseriesOp, VectorOp, -}; use crate::bridge::scan_filter::FilterOp; -use crate::control::planner::physical::PhysicalTask; use crate::control::security::auth_context::AuthContext; use crate::control::security::rls::RlsPolicyStore; use crate::types::TenantId; +use nodedb_physical::physical_plan::{ + ColumnarOp, DocumentOp, GraphOp, KvOp, QueryOp, SpatialOp, TextOp, TimeseriesOp, VectorOp, +}; +use nodedb_physical::physical_task::PhysicalTask; /// Inject RLS predicates into physical tasks after plan conversion. /// diff --git a/nodedb/src/control/planner/sql_plan_convert/aggregate.rs b/nodedb/src/control/planner/sql_plan_convert/aggregate.rs index dd76e4aa0..fe0fb70b6 100644 --- a/nodedb/src/control/planner/sql_plan_convert/aggregate.rs +++ b/nodedb/src/control/planner/sql_plan_convert/aggregate.rs @@ -7,14 +7,14 @@ use nodedb_sql::types::{ }; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::physical::{PhysicalTask, PostSetOp}; -use super::convert::{ConvertContext, convert_one}; +use super::convert::{convert_one, ConvertContext}; use super::expr::sql_expr_to_bridge_expr; use super::filter::serialize_filters; use super::value::extract_time_range; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(super) struct ConvertAggregateParams<'a> { pub input: &'a SqlPlan, @@ -409,21 +409,11 @@ pub(super) fn serialize_window_functions( alias: s.alias.clone(), func_name: s.function.clone(), args: s.args.iter().map(sql_expr_to_bridge_expr).collect(), - partition_by: s - .partition_by - .iter() - .filter_map(|e| match e { - SqlExpr::Column { name, .. } => Some(name.clone()), - _ => None, - }) - .collect(), + partition_by: s.partition_by.iter().map(sql_expr_to_bridge_expr).collect(), order_by: s .order_by .iter() - .filter_map(|k| match &k.expr { - SqlExpr::Column { name, .. } => Some((name.clone(), k.ascending)), - _ => None, - }) + .map(|k| (sql_expr_to_bridge_expr(&k.expr), k.ascending)) .collect(), frame: s.frame.clone(), }) diff --git a/nodedb/src/control/planner/sql_plan_convert/array_alter_convert.rs b/nodedb/src/control/planner/sql_plan_convert/array_alter_convert.rs index 58edf088c..bc7c1f4a0 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_alter_convert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_alter_convert.rs @@ -16,13 +16,13 @@ use nodedb_types::config::retention::BitemporalRetention; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::array_catalog::ArrayCatalogEntry; use crate::engine::bitemporal::registry::BitemporalEngineKind; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::MetaOp; -use super::super::physical::{PhysicalTask, PostSetOp}; use super::convert::ConvertContext; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// Convert `SqlPlan::AlterArray` to a `PhysicalTask`. /// diff --git a/nodedb/src/control/planner/sql_plan_convert/array_convert/ddl.rs b/nodedb/src/control/planner/sql_plan_convert/array_convert/ddl.rs index 74295ff11..270114b51 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_convert/ddl.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_convert/ddl.rs @@ -18,12 +18,12 @@ use nodedb_sql::types_array::{ }; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::ArrayOp; use crate::control::array_catalog::ArrayCatalogEntry; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::ArrayOp; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// All inputs for `CREATE ARRAY` lowering, bundled to stay under /// the 7-parameter clippy limit. diff --git a/nodedb/src/control/planner/sql_plan_convert/array_convert/dml.rs b/nodedb/src/control/planner/sql_plan_convert/array_convert/dml.rs index 7f1b13a48..5c7a79f8c 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_convert/dml.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_convert/dml.rs @@ -8,13 +8,13 @@ use nodedb_array::types::ArrayId; use nodedb_sql::types_array::{ArrayCoordLiteral, ArrayInsertRow}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ArrayOp, ClusterArrayOp}; use crate::engine::array::wal::{ArrayDeleteCell, ArrayPutCell}; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{ArrayOp, ClusterArrayOp}; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::helpers::{coerce_attrs, coerce_coords}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in super::super) fn convert_insert_array( name: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs index 0be29c09e..25b297c63 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs @@ -8,12 +8,12 @@ use nodedb_sql::temporal::TemporalScope; use nodedb_sql::types_array::ArrayReducerAst; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ArrayOp, ClusterArrayOp}; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{ArrayOp, ClusterArrayOp}; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::helpers::{load_entry, map_reducer}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(crate) fn convert_agg( name: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/elementwise.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/elementwise.rs index 1a3fba9d3..7d53c3f04 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/elementwise.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/elementwise.rs @@ -6,12 +6,12 @@ use nodedb_array::types::ArrayId; use nodedb_sql::types_array::ArrayBinaryOpAst; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::ArrayOp; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::ArrayOp; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::helpers::{load_schema, map_binary_op}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(crate) fn convert_elementwise( left_name: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/helpers.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/helpers.rs index 6f8c83eb8..ff86ba9af 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/helpers.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/helpers.rs @@ -7,8 +7,8 @@ use nodedb_array::types::domain::DomainBound; use nodedb_sql::temporal::{TemporalScope, ValidTime}; use nodedb_sql::types_array::{ArrayBinaryOpAst, ArrayCoordLiteral, ArrayReducerAst}; -use crate::bridge::physical_plan::{ArrayBinaryOp, ArrayReducer}; use crate::control::array_catalog::ArrayCatalogEntry; +use nodedb_physical::physical_plan::{ArrayBinaryOp, ArrayReducer}; use super::super::convert::ConvertContext; diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/maint.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/maint.rs index 89274fcc6..02e83f2de 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/maint.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/maint.rs @@ -5,11 +5,11 @@ use nodedb_array::types::ArrayId; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::ArrayOp; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::ArrayOp; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(crate) fn convert_flush( name: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/project.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/project.rs index fc1fb8bcf..a39c33e5d 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/project.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/project.rs @@ -5,12 +5,12 @@ use nodedb_array::types::ArrayId; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::ArrayOp; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::ArrayOp; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::helpers::{load_schema, resolve_attr_indices}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(crate) fn convert_project( name: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs index 5df425616..539ae6b78 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs @@ -11,12 +11,12 @@ use nodedb_sql::temporal::TemporalScope; use nodedb_sql::types_array::ArraySliceAst; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ArrayOp, ClusterArrayOp}; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{ArrayOp, ClusterArrayOp}; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::helpers::{coerce_bound, load_entry, resolve_attr_indices}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(crate) fn convert_slice( name: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/convert.rs b/nodedb/src/control/planner/sql_plan_convert/convert.rs index c7491f169..aec55cbc0 100644 --- a/nodedb/src/control/planner/sql_plan_convert/convert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/convert.rs @@ -17,11 +17,7 @@ use crate::engine::timeseries::retention_policy::RetentionPolicyRegistry; use crate::types::TenantId; use crate::wal::WalManager; -use super::super::physical::PhysicalTask; -use convert_array_arms::convert_array_plans; - -#[path = "convert_array_arms.rs"] -mod convert_array_arms; +use nodedb_physical::physical_task::PhysicalTask; /// Qualify a raw collection name with its database ID so that storage keys /// for collections in different databases never collide. @@ -74,6 +70,22 @@ pub struct ConvertContext { pub database_id: crate::types::DatabaseId, } +impl ConvertContext { + /// Build the deployment-neutral subset shared with `nodedb-physical`'s + /// converter helpers. Cheap: 3 `Copy` fields + an `Arc` clone. + pub fn shared(&self) -> nodedb_physical::SharedConvertContext { + nodedb_physical::SharedConvertContext { + database_id: self.database_id, + max_vector_dim: self.max_vector_dim, + cluster_enabled: self.cluster_enabled, + surrogate_assigner: self + .surrogate_assigner + .as_ref() + .map(|a| a.clone() as std::sync::Arc), + } + } +} + /// Convert a list of SqlPlans to PhysicalTasks. pub fn convert( plans: &[SqlPlan], @@ -92,530 +104,6 @@ pub(super) fn convert_one( tenant_id: TenantId, ctx: &ConvertContext, ) -> crate::Result> { - // Delegate array plans first to keep this match manageable. - if let Some(result) = convert_array_plans(plan, tenant_id, ctx) { - return result; - } - - match plan { - SqlPlan::ConstantResult { columns, values } => { - super::set_ops::convert_constant_result(columns, values, tenant_id, ctx) - } - - SqlPlan::Scan { - collection, - alias: _, - engine, - filters, - projection, - sort_keys, - limit, - offset, - distinct, - window_functions, - temporal, - } => super::scan::convert_scan(super::scan_params::ScanParams { - collection, - engine, - filters, - projection, - sort_keys, - limit, - offset, - distinct, - window_functions, - tenant_id, - temporal, - database_id: ctx.database_id, - }), - - SqlPlan::PointGet { - collection, - alias: _, - engine, - key_column, - key_value, - } => super::scan::convert_point_get( - collection, engine, key_column, key_value, tenant_id, ctx, - ), - - SqlPlan::DocumentIndexLookup { - collection, - alias: _, - engine: _, - field, - value, - filters, - projection, - sort_keys: _, - limit, - offset, - distinct: _, - window_functions: _, - case_insensitive: _, - temporal: _, - } => super::scan::convert_document_index_lookup( - collection, - field, - value, - filters, - projection, - *limit, - *offset, - tenant_id, - ctx.database_id, - ), - - SqlPlan::Insert { - collection, - engine, - rows, - column_defaults, - if_absent, - column_schema, - } => super::dml::convert_insert( - collection, - engine, - rows, - column_defaults, - column_schema, - *if_absent, - tenant_id, - ctx, - ), - - SqlPlan::Upsert { - collection, - engine, - rows, - column_defaults, - on_conflict_updates, - column_schema, - } => super::dml::convert_upsert( - collection, - engine, - rows, - column_defaults, - column_schema, - on_conflict_updates, - tenant_id, - ctx, - ), - - SqlPlan::KvInsert { - collection, - entries, - ttl_secs, - intent, - on_conflict_updates, - } => super::dml::convert_kv_insert( - collection, - entries, - *ttl_secs, - *intent, - on_conflict_updates, - tenant_id, - ctx, - ), - - SqlPlan::Update { - collection, - engine, - assignments, - filters, - target_keys, - returning, - } => super::dml::convert_update( - collection, - engine, - assignments, - filters, - target_keys, - *returning, - tenant_id, - ctx, - ), - - SqlPlan::UpdateFrom { - collection, - engine: _, - source, - target_join_col, - source_join_col, - assignments, - target_filters, - returning, - } => super::dml::convert_update_from( - collection, - source, - target_join_col, - source_join_col, - assignments, - target_filters, - *returning, - tenant_id, - ctx, - ), - - SqlPlan::Delete { - collection, - engine, - filters, - target_keys, - } => super::dml::convert_delete(collection, engine, filters, target_keys, tenant_id, ctx), - - SqlPlan::Truncate { - collection, - restart_identity, - } => super::set_ops::convert_truncate(collection, *restart_identity, tenant_id, ctx), - - SqlPlan::Join { - left, - right, - on, - join_type, - condition, - limit, - projection, - filters, - } => super::scan::convert_join(super::scan_params::JoinPlanParams { - left, - right, - on, - join_type, - condition, - limit, - projection, - filters, - tenant_id, - ctx, - }), - - SqlPlan::Aggregate { - input, - group_by, - aggregates, - having, - limit, - grouping_sets, - sort_keys, - } => super::aggregate::convert_aggregate(super::aggregate::ConvertAggregateParams { - input, - group_by, - aggregates, - having, - limit: *limit, - grouping_sets: grouping_sets.as_deref(), - sort_keys, - tenant_id, - ctx, - }), - - SqlPlan::TimeseriesScan { - collection, - time_range, - bucket_interval_ms, - group_by, - aggregates, - filters, - projection, - gap_fill, - limit, - tiered, - temporal, - } => super::scan::convert_timeseries_scan(super::scan_params::TimeseriesScanParams { - collection, - time_range, - bucket_interval_ms, - group_by, - aggregates, - filters, - projection, - gap_fill, - limit, - tiered, - tenant_id, - ctx, - temporal, - }), - - SqlPlan::TimeseriesIngest { collection, rows } => { - super::scan::convert_timeseries_ingest(collection, rows, tenant_id, ctx) - } - - SqlPlan::VectorSearch { - collection, - field, - query_vector, - top_k, - ef_search, - metric, - filters, - array_prefilter, - ann_options, - skip_payload_fetch, - payload_filters, - } => super::scan::convert_vector_search(super::scan_params::VectorSearchParams { - collection, - field, - query_vector, - top_k, - ef_search, - metric, - filters, - array_prefilter: array_prefilter.as_ref(), - ann_options, - tenant_id, - ctx, - skip_payload_fetch: *skip_payload_fetch, - payload_filters, - }), - - SqlPlan::TextSearch { - collection, - query, - top_k, - score_alias, - .. - } => super::scan::convert_text_search( - collection, - query, - top_k, - score_alias.as_deref(), - tenant_id, - ctx.database_id, - ), - - SqlPlan::HybridSearch { - collection, - query_vector, - query_text, - top_k, - ef_search, - vector_weight, - fuzzy, - score_alias, - } => super::scan::convert_hybrid_search(super::scan_params::HybridSearchParams { - collection, - query_vector, - query_text, - top_k, - ef_search, - vector_weight, - fuzzy, - score_alias: score_alias.as_deref(), - tenant_id, - database_id: ctx.database_id, - }), - - SqlPlan::HybridSearchTriple { - collection, - query_vector, - query_text, - graph_seed_id, - graph_depth, - graph_edge_label, - top_k, - ef_search, - fuzzy, - rrf_k, - score_alias, - } => super::scan::convert_hybrid_search_triple( - super::scan_params::HybridSearchTripleParams { - collection, - query_vector, - query_text, - graph_seed_id, - graph_depth, - graph_edge_label, - top_k, - ef_search, - fuzzy, - rrf_k, - score_alias: score_alias.as_deref(), - tenant_id, - database_id: ctx.database_id, - }, - ), - - SqlPlan::SpatialScan { - collection, - field, - predicate, - query_geometry, - distance_meters, - attribute_filters, - limit, - projection, - } => super::scan::convert_spatial_scan(super::scan_params::SpatialScanParams { - collection, - field, - predicate, - query_geometry, - distance_meters, - attribute_filters, - limit, - projection, - tenant_id, - database_id: ctx.database_id, - }), - - SqlPlan::Union { inputs, distinct } => { - super::set_ops::convert_union(inputs, *distinct, tenant_id, ctx) - } - - SqlPlan::Intersect { left, right, all } => { - super::set_ops::convert_intersect(left, right, *all, tenant_id, ctx) - } - - SqlPlan::Except { left, right, all } => { - super::set_ops::convert_except(left, right, *all, tenant_id, ctx) - } - - SqlPlan::InsertSelect { target, source, .. } => { - super::set_ops::convert_insert_select(target, source, tenant_id, ctx) - } - - SqlPlan::RecursiveScan { - collection, - base_filters, - recursive_filters, - join_link, - max_iterations, - distinct, - limit, - } => super::scan::convert_recursive_scan(super::scan_params::RecursiveScanParams { - collection, - base_filters, - recursive_filters, - join_link, - max_iterations, - distinct, - limit, - tenant_id, - database_id: ctx.database_id, - }), - - SqlPlan::RecursiveValue { - cte_name, - columns, - init_exprs, - step_exprs, - condition, - max_depth, - distinct, - } => super::scan::convert_recursive_value(super::scan_params::RecursiveValueParams { - cte_name, - columns, - init_exprs, - step_exprs, - condition, - max_depth, - distinct, - tenant_id, - database_id: ctx.database_id, - }), - - SqlPlan::Cte { definitions, outer } => { - super::set_ops::convert_cte(definitions, outer, tenant_id, ctx) - } - - SqlPlan::VectorPrimaryInsert { - collection, - field, - quantization, - payload_indexes, - rows, - } => super::dml::convert_vector_primary_insert( - collection, - field, - *quantization, - payload_indexes, - rows, - tenant_id, - ctx, - ), - - SqlPlan::Merge { - target, - engine: _, - source, - target_join_col, - source_join_col, - source_alias, - clauses, - returning, - } => super::dml::convert_merge( - target, - source, - target_join_col, - source_join_col, - source_alias, - clauses, - *returning, - tenant_id, - ctx, - ), - - SqlPlan::LateralTopK { - outer, - outer_alias, - inner_collection, - inner_filters, - inner_order_by, - inner_limit, - correlation_keys, - lateral_alias, - projection, - left_join, - } => super::lateral::convert_lateral_top_k( - outer, - outer_alias.as_deref(), - inner_collection, - inner_filters, - inner_order_by, - *inner_limit, - correlation_keys, - lateral_alias, - projection, - *left_join, - tenant_id, - ctx, - ), - - SqlPlan::LateralLoop { - outer, - outer_alias, - inner, - correlation_predicates, - lateral_alias, - projection, - outer_row_cap, - left_join, - } => super::lateral::convert_lateral_loop( - outer, - outer_alias.as_deref(), - inner, - correlation_predicates, - lateral_alias, - projection, - *outer_row_cap, - *left_join, - tenant_id, - ctx, - ), - - SqlPlan::MultiVectorSearch { .. } | SqlPlan::RangeScan { .. } => { - Err(crate::Error::PlanError { - detail: format!("unsupported SqlPlan variant: {plan:?}"), - }) - } - - // Array arms are handled above by `convert_array_plans`. - // This catch-all handles any future array-related variants that - // haven't been added to `convert_array_arms.rs` yet. - _ => Err(crate::Error::PlanError { - detail: format!("unhandled SqlPlan variant: {plan:?}"), - }), - } + let mut visitor = super::visitor::ConvertVisitor { tenant_id, ctx }; + nodedb_sql::dispatch(&mut visitor, plan) } diff --git a/nodedb/src/control/planner/sql_plan_convert/convert_array_arms.rs b/nodedb/src/control/planner/sql_plan_convert/convert_array_arms.rs index 5d6fe06a8..93bedfd75 100644 --- a/nodedb/src/control/planner/sql_plan_convert/convert_array_arms.rs +++ b/nodedb/src/control/planner/sql_plan_convert/convert_array_arms.rs @@ -2,7 +2,7 @@ use nodedb_sql::types::SqlPlan; -use crate::control::planner::physical::PhysicalTask; +use nodedb_physical::physical_task::PhysicalTask; use crate::types::TenantId; use super::ConvertContext; diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs b/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs index 4b1221310..834367779 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs @@ -5,15 +5,15 @@ use nodedb_types::Surrogate; use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::ColumnarInsertIntent; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::ColumnarInsertIntent; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::super::value::{ assignments_to_update_values, row_to_msgpack, rows_to_msgpack_array, sql_value_to_string, }; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// Build a `ColumnarSchema` from raw catalog column-type strings, then /// serialize it as MessagePack for the `ColumnarOp::Insert::schema_bytes` field. diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs b/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs index aa49f3d99..6e3a8082c 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs @@ -3,16 +3,16 @@ use nodedb_sql::types::{KvInsertIntent, SqlExpr, SqlValue, VectorPrimaryRow}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::super::value::{ assignments_to_update_values, sql_value_to_bytes, sql_value_to_nodedb_value, write_msgpack_map_header, write_msgpack_str, write_msgpack_value, }; use super::insert::assign_for_pk; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in super::super) fn convert_kv_insert( collection: &str, diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/merge.rs b/nodedb/src/control/planner/sql_plan_convert/dml/merge.rs index 9feb0a0b9..17022c62a 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/merge.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/merge.rs @@ -5,15 +5,15 @@ use nodedb_sql::types::{MergeClauseKind, MergePlanAction, MergePlanClause, SqlExpr, SqlPlan}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::DocumentOp; -use crate::bridge::physical_plan::document::merge_types::{ +use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::DocumentOp; +use nodedb_physical::physical_plan::document::merge_types::{ MergeActionOp, MergeClauseKind as MergeClauseKindOp, MergeClauseOp, }; -use crate::types::{TenantId, VShardId}; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::filter::serialize_filters; use super::super::value::{assignments_to_update_values_qualified, sql_value_to_msgpack}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// Lower a `SqlPlan::Merge` to a single `DocumentOp::Merge` physical task. #[allow(clippy::too_many_arguments)] diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/update_delete.rs b/nodedb/src/control/planner/sql_plan_convert/dml/update_delete.rs index 5a25503bc..0ef107732 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/update_delete.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/update_delete.rs @@ -4,16 +4,16 @@ use nodedb_sql::types::{EngineType, Filter, SqlExpr, SqlPlan, SqlValue}; use nodedb_types::Surrogate; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::convert::ConvertContext; use super::super::filter::serialize_filters; use super::super::value::{ assignments_to_update_values, assignments_to_update_values_qualified, sql_value_to_bytes, sql_value_to_msgpack, sql_value_to_string, }; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; #[allow(clippy::too_many_arguments)] pub(in super::super) fn convert_update( @@ -77,7 +77,7 @@ pub(in super::super) fn convert_update( // ColumnarOp::Update carries raw msgpack bytes per field; extract // literals only (expressions require row-context eval not yet wired // into the columnar mutation handler). - use crate::bridge::physical_plan::UpdateValue; + use nodedb_physical::physical_plan::UpdateValue; let mut columnar_updates: Vec<(String, Vec)> = Vec::with_capacity(updates.len()); for (field, update_val) in &updates { match update_val { diff --git a/nodedb/src/control/planner/sql_plan_convert/lateral.rs b/nodedb/src/control/planner/sql_plan_convert/lateral.rs index 4ab64ba88..399b81ab4 100644 --- a/nodedb/src/control/planner/sql_plan_convert/lateral.rs +++ b/nodedb/src/control/planner/sql_plan_convert/lateral.rs @@ -9,12 +9,12 @@ use nodedb_sql::types::{Filter, Projection, SortKey, SqlExpr, SqlPlan}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{JoinProjection, QueryOp}; use crate::types::TenantId; +use nodedb_physical::physical_plan::{JoinProjection, QueryOp}; -use super::super::physical::{PhysicalTask, PostSetOp}; use super::convert::ConvertContext; use super::filter::serialize_filters; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// Lower `SqlPlan::LateralTopK` to a `QueryOp::LateralTopK` physical task. #[allow(clippy::too_many_arguments)] diff --git a/nodedb/src/control/planner/sql_plan_convert/mod.rs b/nodedb/src/control/planner/sql_plan_convert/mod.rs index 4fb8b56d4..a0d21c347 100644 --- a/nodedb/src/control/planner/sql_plan_convert/mod.rs +++ b/nodedb/src/control/planner/sql_plan_convert/mod.rs @@ -13,5 +13,6 @@ pub mod scan; pub mod scan_params; pub mod set_ops; pub mod value; +pub mod visitor; pub use convert::{ConvertContext, convert}; diff --git a/nodedb/src/control/planner/sql_plan_convert/scan/core.rs b/nodedb/src/control/planner/sql_plan_convert/scan/core.rs index 827dd148e..3521fdd5f 100644 --- a/nodedb/src/control/planner/sql_plan_convert/scan/core.rs +++ b/nodedb/src/control/planner/sql_plan_convert/scan/core.rs @@ -5,10 +5,9 @@ use nodedb_sql::types::{EngineType, Filter, SqlValue}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::aggregate::{ extract_computed_columns, extract_projection_names, serialize_window_functions, }; @@ -19,6 +18,7 @@ use super::super::value::{ extract_time_range, sql_value_to_bytes, sql_value_to_nodedb_value, sql_value_to_string, }; use super::helpers::valid_at_from_scope; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in crate::control::planner::sql_plan_convert) fn convert_scan( p: ScanParams<'_>, diff --git a/nodedb/src/control/planner/sql_plan_convert/scan/join.rs b/nodedb/src/control/planner/sql_plan_convert/scan/join.rs index 9bf33aa64..0f46d90e9 100644 --- a/nodedb/src/control/planner/sql_plan_convert/scan/join.rs +++ b/nodedb/src/control/planner/sql_plan_convert/scan/join.rs @@ -7,10 +7,9 @@ use nodedb_sql::planner::bitmap_emit::predicate::BitmapHint; use nodedb_sql::types::{Filter, SqlPlan}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{DatabaseId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::aggregate::{ extract_collection_name, extract_join_projection_specs, extract_scan_alias, }; @@ -18,6 +17,7 @@ use super::super::convert::convert_one; use super::super::filter::{expr_filter_qualified, serialize_filters}; use super::super::scan_params::JoinPlanParams; use super::super::value::sql_value_to_string; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; /// Serialize WHERE filters + non-equi join condition into a single `Vec`. /// diff --git a/nodedb/src/control/planner/sql_plan_convert/scan/recursive.rs b/nodedb/src/control/planner/sql_plan_convert/scan/recursive.rs index b7389229d..a2f4a7373 100644 --- a/nodedb/src/control/planner/sql_plan_convert/scan/recursive.rs +++ b/nodedb/src/control/planner/sql_plan_convert/scan/recursive.rs @@ -3,12 +3,12 @@ //! Recursive (CTE-style) scan converters. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::VShardId; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::filter::serialize_filters; use super::super::scan_params::{RecursiveScanParams, RecursiveValueParams}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in crate::control::planner::sql_plan_convert) fn convert_recursive_scan( p: RecursiveScanParams<'_>, diff --git a/nodedb/src/control/planner/sql_plan_convert/scan/search.rs b/nodedb/src/control/planner/sql_plan_convert/scan/search.rs index b478f4eba..2fb11e721 100644 --- a/nodedb/src/control/planner/sql_plan_convert/scan/search.rs +++ b/nodedb/src/control/planner/sql_plan_convert/scan/search.rs @@ -4,13 +4,13 @@ //! builder shared across them. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::filter::serialize_filters; use super::super::scan_params::{HybridSearchParams, HybridSearchTripleParams, VectorSearchParams}; use super::super::value::sql_value_to_nodedb_value as sql_value_to_value; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in crate::control::planner::sql_plan_convert) fn convert_vector_search( p: VectorSearchParams<'_>, @@ -140,7 +140,7 @@ fn build_array_prefilter_plan( let aid = ArrayId::new(tenant_id, &prefilter.array_name); Ok(PhysicalPlan::Array( - crate::bridge::physical_plan::ArrayOp::SurrogateBitmapScan { + nodedb_physical::physical_plan::ArrayOp::SurrogateBitmapScan { array_id: aid, slice_msgpack, }, diff --git a/nodedb/src/control/planner/sql_plan_convert/scan/spatial.rs b/nodedb/src/control/planner/sql_plan_convert/scan/spatial.rs index 31a1c3fed..01c95e2e4 100644 --- a/nodedb/src/control/planner/sql_plan_convert/scan/spatial.rs +++ b/nodedb/src/control/planner/sql_plan_convert/scan/spatial.rs @@ -3,13 +3,13 @@ //! Spatial scan converter. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::VShardId; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::aggregate::extract_projection_names; use super::super::filter::serialize_filters; use super::super::scan_params::SpatialScanParams; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in crate::control::planner::sql_plan_convert) fn convert_spatial_scan( p: SpatialScanParams<'_>, diff --git a/nodedb/src/control/planner/sql_plan_convert/scan/timeseries.rs b/nodedb/src/control/planner/sql_plan_convert/scan/timeseries.rs index 6388ffb8e..38c9a597b 100644 --- a/nodedb/src/control/planner/sql_plan_convert/scan/timeseries.rs +++ b/nodedb/src/control/planner/sql_plan_convert/scan/timeseries.rs @@ -5,10 +5,9 @@ use nodedb_sql::types::SqlValue; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::super::physical::{PhysicalTask, PostSetOp}; use super::super::aggregate::{ agg_expr_to_pair, extract_computed_columns, extract_projection_names, }; @@ -16,6 +15,7 @@ use super::super::filter::serialize_filters; use super::super::scan_params::TimeseriesScanParams; use super::super::value::{row_to_msgpack, sql_value_to_string, write_msgpack_array_header}; use super::helpers::valid_at_from_scope; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(in crate::control::planner::sql_plan_convert) fn convert_timeseries_scan( p: TimeseriesScanParams<'_>, diff --git a/nodedb/src/control/planner/sql_plan_convert/set_ops.rs b/nodedb/src/control/planner/sql_plan_convert/set_ops.rs index 5632ffc66..cd8b90b8f 100644 --- a/nodedb/src/control/planner/sql_plan_convert/set_ops.rs +++ b/nodedb/src/control/planner/sql_plan_convert/set_ops.rs @@ -5,13 +5,13 @@ use nodedb_sql::types::{Projection, SqlPlan, SqlValue}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::*; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::*; -use super::super::physical::{PhysicalTask, PostSetOp}; use super::convert::{ConvertContext, convert_one}; use super::expr::inline_cte; use super::value::sql_value_to_string; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; pub(super) fn convert_constant_result( columns: &[String], diff --git a/nodedb/src/control/planner/sql_plan_convert/value/assignments.rs b/nodedb/src/control/planner/sql_plan_convert/value/assignments.rs index cc91a08cd..a91b0c570 100644 --- a/nodedb/src/control/planner/sql_plan_convert/value/assignments.rs +++ b/nodedb/src/control/planner/sql_plan_convert/value/assignments.rs @@ -10,7 +10,7 @@ use nodedb_sql::types::SqlExpr; -use crate::bridge::physical_plan::UpdateValue; +use nodedb_physical::physical_plan::UpdateValue; use super::super::expr::{sql_expr_to_bridge_expr, sql_expr_to_bridge_expr_qualified}; use super::convert::sql_value_to_msgpack; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/adapter.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/adapter.rs new file mode 100644 index 000000000..0896589e5 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/adapter.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `ConvertVisitor` — implements `PlanVisitor` to lower SqlPlan → PhysicalTask. +//! Method bodies live in sibling `arms_*.rs` files (macro_rules), grouped by family. + +use nodedb_sql::PlanVisitor; + +use crate::types::TenantId; +use nodedb_physical::physical_task::PhysicalTask; + +use super::super::convert::ConvertContext; +use super::arms_aggregate_lateral::impl_aggregate_lateral_arms_for_convert_visitor; +use super::arms_array::impl_array_arms_for_convert_visitor; +use super::arms_dml::impl_dml_arms_for_convert_visitor; +use super::arms_scan_read::impl_scan_read_arms_for_convert_visitor; +use super::arms_scan_search::impl_scan_search_arms_for_convert_visitor; +use super::arms_set_ops::impl_set_ops_arms_for_convert_visitor; +use super::unsupported_arms::impl_unsupported_convert_visitor_methods; + +pub struct ConvertVisitor<'a> { + pub tenant_id: TenantId, + pub ctx: &'a ConvertContext, +} + +impl<'a> PlanVisitor for ConvertVisitor<'a> { + type Output = Vec; + type Error = crate::Error; + + impl_scan_read_arms_for_convert_visitor!(); + impl_scan_search_arms_for_convert_visitor!(); + impl_dml_arms_for_convert_visitor!(); + impl_set_ops_arms_for_convert_visitor!(); + impl_aggregate_lateral_arms_for_convert_visitor!(); + impl_array_arms_for_convert_visitor!(); + impl_unsupported_convert_visitor_methods!(); +} diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_aggregate_lateral.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_aggregate_lateral.rs new file mode 100644 index 000000000..3f17da10b --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_aggregate_lateral.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `PlanVisitor` method bodies for aggregate and lateral join variants on `ConvertVisitor`. +//! Defined as a macro and invoked once from `adapter.rs` inside the single impl block. + +macro_rules! impl_aggregate_lateral_arms_for_convert_visitor { + () => { + fn aggregate( + &mut self, + input: &nodedb_sql::types::SqlPlan, + group_by: &[nodedb_sql::types_expr::SqlExpr], + aggregates: &[nodedb_sql::types::query::AggregateExpr], + having: &[nodedb_sql::types::filter::Filter], + limit: usize, + grouping_sets: Option<&[Vec]>, + sort_keys: &[nodedb_sql::types::query::SortKey], + ) -> crate::Result> { + super::super::aggregate::convert_aggregate( + super::super::aggregate::ConvertAggregateParams { + input, + group_by, + aggregates, + having, + limit, + grouping_sets, + sort_keys, + tenant_id: self.tenant_id, + ctx: self.ctx, + }, + ) + } + + fn lateral_top_k( + &mut self, + outer: &nodedb_sql::types::SqlPlan, + outer_alias: Option<&str>, + inner_collection: &str, + inner_filters: &[nodedb_sql::types::filter::Filter], + inner_order_by: &[nodedb_sql::types::query::SortKey], + inner_limit: usize, + correlation_keys: &[(String, String)], + lateral_alias: &str, + projection: &[nodedb_sql::types::query::Projection], + left_join: bool, + ) -> crate::Result> { + super::super::lateral::convert_lateral_top_k( + outer, + outer_alias, + inner_collection, + inner_filters, + inner_order_by, + inner_limit, + correlation_keys, + lateral_alias, + projection, + left_join, + self.tenant_id, + self.ctx, + ) + } + + fn lateral_loop( + &mut self, + outer: &nodedb_sql::types::SqlPlan, + outer_alias: Option<&str>, + inner: &nodedb_sql::types::SqlPlan, + correlation_predicates: &[(String, String)], + lateral_alias: &str, + projection: &[nodedb_sql::types::query::Projection], + outer_row_cap: usize, + left_join: bool, + ) -> crate::Result> { + super::super::lateral::convert_lateral_loop( + outer, + outer_alias, + inner, + correlation_predicates, + lateral_alias, + projection, + outer_row_cap, + left_join, + self.tenant_id, + self.ctx, + ) + } + }; +} + +pub(super) use impl_aggregate_lateral_arms_for_convert_visitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_array.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_array.rs new file mode 100644 index 000000000..02b68c008 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_array.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `PlanVisitor` method bodies for Array DDL/DML/TVF variants on `ConvertVisitor`. +//! Defined as a macro and invoked once from `adapter.rs` inside the single impl block. + +macro_rules! impl_array_arms_for_convert_visitor { + () => { + fn create_array( + &mut self, + name: &str, + dims: &[nodedb_sql::types_array::ArrayDimAst], + attrs: &[nodedb_sql::types_array::ArrayAttrAst], + tile_extents: &[i64], + cell_order: nodedb_sql::types_array::ArrayCellOrderAst, + tile_order: nodedb_sql::types_array::ArrayTileOrderAst, + prefix_bits: u8, + audit_retain_ms: Option, + minimum_audit_retain_ms: Option, + ) -> crate::Result> { + super::super::array_convert::convert_create_array( + super::super::array_convert::CreateArrayArgs { + name, + dims, + attrs, + tile_extents, + cell_order, + tile_order, + prefix_bits, + audit_retain_ms, + minimum_audit_retain_ms, + tenant_id: self.tenant_id, + ctx: self.ctx, + }, + ) + } + + fn drop_array( + &mut self, + name: &str, + if_exists: bool, + ) -> crate::Result> { + super::super::array_convert::convert_drop_array( + name, + if_exists, + self.tenant_id, + self.ctx, + ) + } + + fn alter_array( + &mut self, + name: &str, + audit_retain_ms: Option>, + minimum_audit_retain_ms: Option, + ) -> crate::Result> { + super::super::array_alter_convert::convert_alter_array( + name, + audit_retain_ms, + minimum_audit_retain_ms, + self.tenant_id, + self.ctx, + ) + } + + fn insert_array( + &mut self, + name: &str, + rows: &[nodedb_sql::types_array::ArrayInsertRow], + ) -> crate::Result> { + super::super::array_convert::convert_insert_array(name, rows, self.tenant_id, self.ctx) + } + + fn delete_array( + &mut self, + name: &str, + coords: &[Vec], + ) -> crate::Result> { + super::super::array_convert::convert_delete_array( + name, + coords, + self.tenant_id, + self.ctx, + ) + } + + fn array_slice( + &mut self, + name: &str, + slice: &nodedb_sql::types_array::ArraySliceAst, + attr_projection: &[String], + limit: u32, + temporal: &nodedb_sql::temporal::TemporalScope, + ) -> crate::Result> { + super::super::array_fn_convert::convert_slice( + name, + slice, + attr_projection, + limit, + *temporal, + self.tenant_id, + self.ctx, + ) + } + + fn array_project( + &mut self, + name: &str, + attr_projection: &[String], + ) -> crate::Result> { + super::super::array_fn_convert::convert_project( + name, + attr_projection, + self.tenant_id, + self.ctx, + ) + } + + fn array_agg( + &mut self, + name: &str, + attr: &str, + reducer: &nodedb_sql::types_array::ArrayReducerAst, + group_by_dim: Option<&str>, + temporal: &nodedb_sql::temporal::TemporalScope, + ) -> crate::Result> { + super::super::array_fn_convert::convert_agg( + name, + attr, + *reducer, + group_by_dim, + *temporal, + self.tenant_id, + self.ctx, + ) + } + + fn array_elementwise( + &mut self, + left: &str, + right: &str, + op: nodedb_sql::types_array::ArrayBinaryOpAst, + attr: &str, + ) -> crate::Result> { + super::super::array_fn_convert::convert_elementwise( + left, + right, + op, + attr, + self.tenant_id, + self.ctx, + ) + } + + fn array_flush( + &mut self, + name: &str, + ) -> crate::Result> { + super::super::array_fn_convert::convert_flush(name, self.tenant_id, self.ctx) + } + + fn array_compact( + &mut self, + name: &str, + ) -> crate::Result> { + super::super::array_fn_convert::convert_compact(name, self.tenant_id, self.ctx) + } + }; +} + +pub(super) use impl_array_arms_for_convert_visitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs new file mode 100644 index 000000000..72a37d654 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `PlanVisitor` method bodies for DML variants on `ConvertVisitor`. +//! Defined as a macro and invoked once from `adapter.rs` inside the single impl block. + +macro_rules! impl_dml_arms_for_convert_visitor { + () => { + fn insert( + &mut self, + collection: &str, + engine: nodedb_sql::types::query::EngineType, + rows: &[Vec<(String, nodedb_sql::types_expr::SqlValue)>], + column_defaults: &[(String, String)], + if_absent: bool, + column_schema: &[(String, String)], + ) -> crate::Result> { + super::super::dml::convert_insert( + collection, + &engine, + rows, + column_defaults, + column_schema, + if_absent, + self.tenant_id, + self.ctx, + ) + } + + fn upsert( + &mut self, + collection: &str, + engine: nodedb_sql::types::query::EngineType, + rows: &[Vec<(String, nodedb_sql::types_expr::SqlValue)>], + column_defaults: &[(String, String)], + on_conflict_updates: &[(String, nodedb_sql::types_expr::SqlExpr)], + column_schema: &[(String, String)], + ) -> crate::Result> { + super::super::dml::convert_upsert( + collection, + &engine, + rows, + column_defaults, + column_schema, + on_conflict_updates, + self.tenant_id, + self.ctx, + ) + } + + fn kv_insert( + &mut self, + collection: &str, + entries: &[( + nodedb_sql::types_expr::SqlValue, + Vec<(String, nodedb_sql::types_expr::SqlValue)>, + )], + ttl_secs: u64, + intent: nodedb_sql::types::plan::KvInsertIntent, + on_conflict_updates: &[(String, nodedb_sql::types_expr::SqlExpr)], + ) -> crate::Result> { + super::super::dml::convert_kv_insert( + collection, + entries, + ttl_secs, + intent, + on_conflict_updates, + self.tenant_id, + self.ctx, + ) + } + + fn update( + &mut self, + collection: &str, + engine: nodedb_sql::types::query::EngineType, + assignments: &[(String, nodedb_sql::types_expr::SqlExpr)], + filters: &[nodedb_sql::types::filter::Filter], + target_keys: &[nodedb_sql::types_expr::SqlValue], + returning: bool, + ) -> crate::Result> { + super::super::dml::convert_update( + collection, + &engine, + assignments, + filters, + target_keys, + returning, + self.tenant_id, + self.ctx, + ) + } + + fn update_from( + &mut self, + collection: &str, + _engine: nodedb_sql::types::query::EngineType, + source: &nodedb_sql::types::SqlPlan, + target_join_col: &str, + source_join_col: &str, + assignments: &[(String, nodedb_sql::types_expr::SqlExpr)], + target_filters: &[nodedb_sql::types::filter::Filter], + returning: bool, + ) -> crate::Result> { + super::super::dml::convert_update_from( + collection, + source, + target_join_col, + source_join_col, + assignments, + target_filters, + returning, + self.tenant_id, + self.ctx, + ) + } + + fn delete( + &mut self, + collection: &str, + engine: nodedb_sql::types::query::EngineType, + filters: &[nodedb_sql::types::filter::Filter], + target_keys: &[nodedb_sql::types_expr::SqlValue], + ) -> crate::Result> { + super::super::dml::convert_delete( + collection, + &engine, + filters, + target_keys, + self.tenant_id, + self.ctx, + ) + } + + fn vector_primary_insert( + &mut self, + collection: &str, + field: &str, + quantization: &nodedb_types::VectorQuantization, + payload_indexes: &[(String, nodedb_types::PayloadIndexKind)], + rows: &[nodedb_sql::types::plan::VectorPrimaryRow], + ) -> crate::Result> { + super::super::dml::convert_vector_primary_insert( + collection, + field, + *quantization, + payload_indexes, + rows, + self.tenant_id, + self.ctx, + ) + } + + fn merge( + &mut self, + target: &str, + _engine: nodedb_sql::types::query::EngineType, + source: &nodedb_sql::types::SqlPlan, + target_join_col: &str, + source_join_col: &str, + source_alias: &str, + clauses: &[nodedb_sql::types::plan::MergePlanClause], + returning: bool, + ) -> crate::Result> { + super::super::dml::convert_merge( + target, + source, + target_join_col, + source_join_col, + source_alias, + clauses, + returning, + self.tenant_id, + self.ctx, + ) + } + }; +} + +pub(super) use impl_dml_arms_for_convert_visitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_read.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_read.rs new file mode 100644 index 000000000..ad563ad38 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_read.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `PlanVisitor` method bodies for scan/read/join/recursive variants on `ConvertVisitor`. +//! Defined as a macro and invoked once from `adapter.rs` inside the single impl block. + +macro_rules! impl_scan_read_arms_for_convert_visitor { + () => { + fn scan( + &mut self, + collection: &str, + _alias: Option<&str>, + engine: nodedb_sql::types::query::EngineType, + filters: &[nodedb_sql::types::filter::Filter], + projection: &[nodedb_sql::types::query::Projection], + sort_keys: &[nodedb_sql::types::query::SortKey], + limit: Option, + offset: usize, + distinct: bool, + window_functions: &[nodedb_sql::types::query::WindowSpec], + temporal: &nodedb_sql::temporal::TemporalScope, + ) -> crate::Result> { + super::super::scan::convert_scan(super::super::scan_params::ScanParams { + collection, + engine: &engine, + filters, + projection, + sort_keys, + limit: &limit, + offset: &offset, + distinct: &distinct, + window_functions, + tenant_id: self.tenant_id, + temporal, + database_id: self.ctx.database_id, + }) + } + + fn point_get( + &mut self, + collection: &str, + _alias: Option<&str>, + engine: nodedb_sql::types::query::EngineType, + key_column: &str, + key_value: &nodedb_sql::types_expr::SqlValue, + ) -> crate::Result> { + super::super::scan::convert_point_get( + collection, + &engine, + key_column, + key_value, + self.tenant_id, + self.ctx, + ) + } + + fn document_index_lookup( + &mut self, + collection: &str, + _alias: Option<&str>, + _engine: nodedb_sql::types::query::EngineType, + field: &str, + value: &nodedb_sql::types_expr::SqlValue, + filters: &[nodedb_sql::types::filter::Filter], + projection: &[nodedb_sql::types::query::Projection], + _sort_keys: &[nodedb_sql::types::query::SortKey], + limit: Option, + offset: usize, + _distinct: bool, + _window_functions: &[nodedb_sql::types::query::WindowSpec], + _case_insensitive: bool, + _temporal: &nodedb_sql::temporal::TemporalScope, + ) -> crate::Result> { + super::super::scan::convert_document_index_lookup( + collection, + field, + value, + filters, + projection, + limit, + offset, + self.tenant_id, + self.ctx.database_id, + ) + } + + fn join( + &mut self, + left: &nodedb_sql::types::SqlPlan, + right: &nodedb_sql::types::SqlPlan, + on: &[(String, String)], + join_type: nodedb_sql::types::query::JoinType, + condition: Option<&nodedb_sql::types_expr::SqlExpr>, + limit: usize, + projection: &[nodedb_sql::types::query::Projection], + filters: &[nodedb_sql::types::filter::Filter], + ) -> crate::Result> { + let condition_owned: Option = condition.cloned(); + super::super::scan::convert_join(super::super::scan_params::JoinPlanParams { + left, + right, + on, + join_type: &join_type, + condition: &condition_owned, + limit: &limit, + projection, + filters, + tenant_id: self.tenant_id, + ctx: self.ctx, + }) + } + + fn recursive_scan( + &mut self, + collection: &str, + base_filters: &[nodedb_sql::types::filter::Filter], + recursive_filters: &[nodedb_sql::types::filter::Filter], + join_link: Option<&(String, String)>, + max_iterations: usize, + distinct: bool, + limit: usize, + ) -> crate::Result> { + let join_link_owned: Option<(String, String)> = join_link.cloned(); + super::super::scan::convert_recursive_scan( + super::super::scan_params::RecursiveScanParams { + collection, + base_filters, + recursive_filters, + join_link: &join_link_owned, + max_iterations: &max_iterations, + distinct: &distinct, + limit: &limit, + tenant_id: self.tenant_id, + database_id: self.ctx.database_id, + }, + ) + } + + fn recursive_value( + &mut self, + cte_name: &str, + columns: &[String], + init_exprs: &[String], + step_exprs: &[String], + condition: Option<&str>, + max_depth: usize, + distinct: bool, + ) -> crate::Result> { + let condition_owned: Option = condition.map(str::to_owned); + super::super::scan::convert_recursive_value( + super::super::scan_params::RecursiveValueParams { + cte_name, + columns, + init_exprs, + step_exprs, + condition: &condition_owned, + max_depth: &max_depth, + distinct: &distinct, + tenant_id: self.tenant_id, + database_id: self.ctx.database_id, + }, + ) + } + }; +} + +pub(super) use impl_scan_read_arms_for_convert_visitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_search.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_search.rs new file mode 100644 index 000000000..a694478a3 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_scan_search.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `PlanVisitor` method bodies for timeseries/vector/text/hybrid/spatial search variants +//! on `ConvertVisitor`. Defined as a macro and invoked once from `adapter.rs`. + +macro_rules! impl_scan_search_arms_for_convert_visitor { + () => { + fn timeseries_scan( + &mut self, + collection: &str, + time_range: (i64, i64), + bucket_interval_ms: i64, + group_by: &[String], + aggregates: &[nodedb_sql::types::query::AggregateExpr], + filters: &[nodedb_sql::types::filter::Filter], + projection: &[nodedb_sql::types::query::Projection], + gap_fill: &str, + limit: usize, + tiered: bool, + temporal: &nodedb_sql::temporal::TemporalScope, + ) -> crate::Result> { + super::super::scan::convert_timeseries_scan( + super::super::scan_params::TimeseriesScanParams { + collection, + time_range: &time_range, + bucket_interval_ms: &bucket_interval_ms, + group_by, + aggregates, + filters, + projection, + gap_fill, + limit: &limit, + tiered: &tiered, + tenant_id: self.tenant_id, + ctx: self.ctx, + temporal, + }, + ) + } + + fn timeseries_ingest( + &mut self, + collection: &str, + rows: &[Vec<(String, nodedb_sql::types_expr::SqlValue)>], + ) -> crate::Result> { + super::super::scan::convert_timeseries_ingest( + collection, + rows, + self.tenant_id, + self.ctx, + ) + } + + fn vector_search( + &mut self, + collection: &str, + field: &str, + query_vector: &[f32], + top_k: usize, + ef_search: usize, + metric: nodedb_types::vector_distance::DistanceMetric, + filters: &[nodedb_sql::types::filter::Filter], + array_prefilter: Option<&nodedb_sql::types::plan::ArrayPrefilter>, + ann_options: &nodedb_sql::types::plan::VectorAnnOptions, + skip_payload_fetch: bool, + payload_filters: &[nodedb_sql::types_expr::SqlPayloadAtom], + ) -> crate::Result> { + super::super::scan::convert_vector_search( + super::super::scan_params::VectorSearchParams { + collection, + field, + query_vector, + top_k: &top_k, + ef_search: &ef_search, + metric: &metric, + filters, + array_prefilter, + ann_options, + tenant_id: self.tenant_id, + ctx: self.ctx, + skip_payload_fetch, + payload_filters, + }, + ) + } + + fn text_search( + &mut self, + collection: &str, + query: &nodedb_sql::fts_types::FtsQuery, + top_k: usize, + _filters: &[nodedb_sql::types::filter::Filter], + score_alias: Option<&str>, + ) -> crate::Result> { + super::super::scan::convert_text_search( + collection, + query, + &top_k, + score_alias, + self.tenant_id, + self.ctx.database_id, + ) + } + + fn hybrid_search( + &mut self, + collection: &str, + query_vector: &[f32], + query_text: &str, + top_k: usize, + ef_search: usize, + vector_weight: f32, + fuzzy: bool, + score_alias: Option<&str>, + ) -> crate::Result> { + super::super::scan::convert_hybrid_search( + super::super::scan_params::HybridSearchParams { + collection, + query_vector, + query_text, + top_k: &top_k, + ef_search: &ef_search, + vector_weight: &vector_weight, + fuzzy: &fuzzy, + score_alias, + tenant_id: self.tenant_id, + database_id: self.ctx.database_id, + }, + ) + } + + fn hybrid_search_triple( + &mut self, + collection: &str, + query_vector: &[f32], + query_text: &str, + graph_seed_id: &str, + graph_depth: usize, + graph_edge_label: Option<&str>, + top_k: usize, + ef_search: usize, + fuzzy: bool, + rrf_k: (f64, f64, f64), + score_alias: Option<&str>, + ) -> crate::Result> { + let graph_edge_label_owned: Option = graph_edge_label.map(str::to_owned); + super::super::scan::convert_hybrid_search_triple( + super::super::scan_params::HybridSearchTripleParams { + collection, + query_vector, + query_text, + graph_seed_id, + graph_depth: &graph_depth, + graph_edge_label: &graph_edge_label_owned, + top_k: &top_k, + ef_search: &ef_search, + fuzzy: &fuzzy, + rrf_k: &rrf_k, + score_alias, + tenant_id: self.tenant_id, + database_id: self.ctx.database_id, + }, + ) + } + + fn spatial_scan( + &mut self, + collection: &str, + field: &str, + predicate: &nodedb_sql::types::query::SpatialPredicate, + query_geometry: &nodedb_types::geometry::Geometry, + distance_meters: f64, + attribute_filters: &[nodedb_sql::types::filter::Filter], + limit: usize, + projection: &[nodedb_sql::types::query::Projection], + ) -> crate::Result> { + super::super::scan::convert_spatial_scan(super::super::scan_params::SpatialScanParams { + collection, + field, + predicate, + query_geometry, + distance_meters: &distance_meters, + attribute_filters, + limit: &limit, + projection, + tenant_id: self.tenant_id, + database_id: self.ctx.database_id, + }) + } + }; +} + +pub(super) use impl_scan_search_arms_for_convert_visitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_set_ops.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_set_ops.rs new file mode 100644 index 000000000..ec9382933 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_set_ops.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! `PlanVisitor` method bodies for set-operation and CTE variants on `ConvertVisitor`. +//! Defined as a macro and invoked once from `adapter.rs` inside the single impl block. + +macro_rules! impl_set_ops_arms_for_convert_visitor { + () => { + fn constant_result( + &mut self, + columns: &[String], + values: &[nodedb_sql::types_expr::SqlValue], + ) -> crate::Result> { + super::super::set_ops::convert_constant_result( + columns, + values, + self.tenant_id, + self.ctx, + ) + } + + fn truncate( + &mut self, + collection: &str, + restart_identity: bool, + ) -> crate::Result> { + super::super::set_ops::convert_truncate( + collection, + restart_identity, + self.tenant_id, + self.ctx, + ) + } + + fn union( + &mut self, + inputs: &[nodedb_sql::types::SqlPlan], + distinct: bool, + ) -> crate::Result> { + super::super::set_ops::convert_union(inputs, distinct, self.tenant_id, self.ctx) + } + + fn intersect( + &mut self, + left: &nodedb_sql::types::SqlPlan, + right: &nodedb_sql::types::SqlPlan, + all: bool, + ) -> crate::Result> { + super::super::set_ops::convert_intersect(left, right, all, self.tenant_id, self.ctx) + } + + fn except( + &mut self, + left: &nodedb_sql::types::SqlPlan, + right: &nodedb_sql::types::SqlPlan, + all: bool, + ) -> crate::Result> { + super::super::set_ops::convert_except(left, right, all, self.tenant_id, self.ctx) + } + + fn insert_select( + &mut self, + target: &str, + source: &nodedb_sql::types::SqlPlan, + _limit: usize, + ) -> crate::Result> { + super::super::set_ops::convert_insert_select(target, source, self.tenant_id, self.ctx) + } + + fn cte( + &mut self, + definitions: &[(String, nodedb_sql::types::SqlPlan)], + outer: &nodedb_sql::types::SqlPlan, + ) -> crate::Result> { + super::super::set_ops::convert_cte(definitions, outer, self.tenant_id, self.ctx) + } + }; +} + +pub(super) use impl_set_ops_arms_for_convert_visitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/mod.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/mod.rs new file mode 100644 index 000000000..4dcc7be19 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/mod.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pub mod adapter; +pub mod arms_aggregate_lateral; +pub mod arms_array; +pub mod arms_dml; +pub mod arms_scan_read; +pub mod arms_scan_search; +pub mod arms_set_ops; +pub mod unsupported_arms; + +pub use adapter::ConvertVisitor; diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/unsupported_arms.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/unsupported_arms.rs new file mode 100644 index 000000000..62ca2fadd --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/unsupported_arms.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Macro that expands to unsupported `PlanVisitor` method stubs. +//! Invoked once from `adapter.rs` inside `impl PlanVisitor for ConvertVisitor`. + +macro_rules! impl_unsupported_convert_visitor_methods { + () => { + fn multi_vector_search( + &mut self, + _collection: &str, + _query_vector: &[f32], + _top_k: usize, + _ef_search: usize, + ) -> crate::Result> { + Err(crate::Error::PlanError { + detail: "unsupported SqlPlan variant: MultiVectorSearch".to_string(), + }) + } + + fn range_scan( + &mut self, + _collection: &str, + _field: &str, + _lower: Option<&nodedb_sql::types_expr::SqlValue>, + _upper: Option<&nodedb_sql::types_expr::SqlValue>, + _limit: usize, + ) -> crate::Result> { + Err(crate::Error::PlanError { + detail: "unsupported SqlPlan variant: RangeScan".to_string(), + }) + } + }; +} + +pub(super) use impl_unsupported_convert_visitor_methods; diff --git a/nodedb/src/control/security/catalog/collection_constraints.rs b/nodedb/src/control/security/catalog/collection_constraints.rs index 8f177f317..bbf31cf14 100644 --- a/nodedb/src/control/security/catalog/collection_constraints.rs +++ b/nodedb/src/control/security/catalog/collection_constraints.rs @@ -92,28 +92,9 @@ pub struct LegalHold { pub created_by: String, } -/// State transition constraint: column value can only change along declared paths. -#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone, PartialEq)] -pub struct StateTransitionDef { - pub name: String, - pub column: String, - pub transitions: Vec, -} - -/// A single allowed state transition, optionally guarded by a role. -#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone, PartialEq)] -pub struct TransitionRule { - pub from: String, - pub to: String, - pub required_role: Option, -} - -/// Transition check predicate: evaluated on UPDATE with OLD and NEW access. -#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone, PartialEq)] -pub struct TransitionCheckDef { - pub name: String, - pub predicate: SqlExpr, -} +pub use nodedb_physical::physical_plan::document::{ + StateTransitionDef, TransitionCheckDef, TransitionRule, +}; /// General CHECK constraint: SQL boolean expression evaluated on the Control Plane /// before writes are dispatched to the Data Plane. May contain subqueries. diff --git a/nodedb/src/control/security/identity/plan_permission.rs b/nodedb/src/control/security/identity/plan_permission.rs index 1e62180a9..bcd521d8b 100644 --- a/nodedb/src/control/security/identity/plan_permission.rs +++ b/nodedb/src/control/security/identity/plan_permission.rs @@ -13,7 +13,7 @@ use super::permission::Permission; /// Map a PhysicalPlan to the Permission required to execute it. pub fn required_permission(plan: &crate::bridge::envelope::PhysicalPlan) -> Permission { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::{ + use nodedb_physical::physical_plan::{ ArrayOp, ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, QueryOp, SpatialOp, TextOp, TimeseriesOp, VectorOp, }; @@ -279,12 +279,12 @@ pub fn required_permission(plan: &crate::bridge::envelope::PhysicalPlan) -> Perm // ClusterArray mirrors the local ArrayOp permission model. PhysicalPlan::ClusterArray( - crate::bridge::physical_plan::ClusterArrayOp::Slice { .. } - | crate::bridge::physical_plan::ClusterArrayOp::Agg { .. }, + nodedb_physical::physical_plan::ClusterArrayOp::Slice { .. } + | nodedb_physical::physical_plan::ClusterArrayOp::Agg { .. }, ) => Permission::Read, PhysicalPlan::ClusterArray( - crate::bridge::physical_plan::ClusterArrayOp::Put { .. } - | crate::bridge::physical_plan::ClusterArrayOp::Delete { .. }, + nodedb_physical::physical_plan::ClusterArrayOp::Put { .. } + | nodedb_physical::physical_plan::ClusterArrayOp::Delete { .. }, ) => Permission::Write, // Calvin cross-shard execution batches are write operations dispatched diff --git a/nodedb/src/control/server/broadcast.rs b/nodedb/src/control/server/broadcast.rs index 75449ac88..7e9d39939 100644 --- a/nodedb/src/control/server/broadcast.rs +++ b/nodedb/src/control/server/broadcast.rs @@ -9,10 +9,10 @@ use nodedb_query::msgpack_scan; use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Response}; -use crate::bridge::physical_plan::QueryOp; use crate::control::arrow_convert; use crate::control::state::SharedState; use crate::types::{DatabaseId, Lsn, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::QueryOp; /// Total number of `broadcast_to_all_cores` / `broadcast_count_to_all_cores` /// invocations since process start. Exposed so callers (including test diff --git a/nodedb/src/control/server/dispatch_utils.rs b/nodedb/src/control/server/dispatch_utils.rs index 1274b81cc..8a3834d45 100644 --- a/nodedb/src/control/server/dispatch_utils.rs +++ b/nodedb/src/control/server/dispatch_utils.rs @@ -6,9 +6,9 @@ use std::time::{Duration, Instant}; use crate::bridge::envelope::Payload; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Response}; -use crate::bridge::physical_plan::{DocumentOp, KvOp, TimeseriesOp}; use crate::control::state::SharedState; use crate::types::{DatabaseId, ReadConsistency, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, KvOp, TimeseriesOp}; #[derive(Debug)] pub(crate) enum DispatchCollectError { diff --git a/nodedb/src/control/server/graph_dispatch/hop.rs b/nodedb/src/control/server/graph_dispatch/hop.rs index db89ca833..912cdcdbb 100644 --- a/nodedb/src/control/server/graph_dispatch/hop.rs +++ b/nodedb/src/control/server/graph_dispatch/hop.rs @@ -23,12 +23,12 @@ use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::scatter_gather; use crate::control::state::SharedState; use crate::engine::graph::edge_store::Direction; use crate::engine::graph::traversal_options::GraphTraversalOptions; use crate::types::{TenantId, TraceId}; +use nodedb_physical::physical_plan::GraphOp; /// A fully-attributed edge crossed by the local hop: `(src, label, dst)`. pub(super) type NeighborTriple = (String, String, String); diff --git a/nodedb/src/control/server/graph_dispatch/shortest_path.rs b/nodedb/src/control/server/graph_dispatch/shortest_path.rs index 3bb341734..a5d48bacc 100644 --- a/nodedb/src/control/server/graph_dispatch/shortest_path.rs +++ b/nodedb/src/control/server/graph_dispatch/shortest_path.rs @@ -10,11 +10,11 @@ use std::collections::{HashMap, HashSet}; use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Response}; -use crate::bridge::physical_plan::GraphOp; use crate::control::scatter_gather; use crate::control::state::SharedState; use crate::engine::graph::traversal_options::GraphTraversalOptions; use crate::types::{TenantId, TraceId}; +use nodedb_physical::physical_plan::GraphOp; use super::helpers::{encode_path, ok_response}; diff --git a/nodedb/src/control/server/http/routes/crdt.rs b/nodedb/src/control/server/http/routes/crdt.rs index f1a746c4a..73c1bb7ef 100644 --- a/nodedb/src/control/server/http/routes/crdt.rs +++ b/nodedb/src/control/server/http/routes/crdt.rs @@ -11,10 +11,10 @@ use axum::http::HeaderMap; use axum::response::IntoResponse; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::server::http::auth::{ApiError, AppState, resolve_identity}; use crate::control::server::http::types::{HttpCrdtApplyRequest, HttpCrdtApplyResponse}; use crate::control::server::pgwire::types::hex_decode; +use nodedb_physical::physical_plan::CrdtOp; use super::document::{dispatch_plan, extract_request_id}; diff --git a/nodedb/src/control/server/http/routes/promql/remote.rs b/nodedb/src/control/server/http/routes/promql/remote.rs index 93f280956..8dd9642dd 100644 --- a/nodedb/src/control/server/http/routes/promql/remote.rs +++ b/nodedb/src/control/server/http/routes/promql/remote.rs @@ -11,7 +11,6 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use prost::Message; -use crate::bridge::physical_plan::{PhysicalPlan, TimeseriesOp}; use crate::control::gateway::GatewayErrorMap; use crate::control::gateway::core::QueryContext; use crate::control::promql::remote_proto::{ @@ -21,6 +20,7 @@ use crate::control::promql::remote_proto::{ use crate::control::promql::{self, types::DEFAULT_LOOKBACK_MS}; use crate::control::server::http::auth::{AppState, ResolvedIdentity}; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; /// POST `/obsv/api/v1/write` — Prometheus remote write endpoint. /// diff --git a/nodedb/src/control/server/http/routes/query.rs b/nodedb/src/control/server/http/routes/query.rs index 79edd3295..933203b36 100644 --- a/nodedb/src/control/server/http/routes/query.rs +++ b/nodedb/src/control/server/http/routes/query.rs @@ -238,7 +238,7 @@ pub async fn query( /// Append write operations to WAL before dispatch (single-node durability). fn wal_append_if_write( state: &AppState, - task: &crate::control::planner::physical::PhysicalTask, + task: &nodedb_physical::physical_task::PhysicalTask, ) -> Result<(), ApiError> { crate::control::server::wal_dispatch::wal_append_if_write( &state.shared.wal, diff --git a/nodedb/src/control/server/ilp_batch.rs b/nodedb/src/control/server/ilp_batch.rs index 5e7bf694f..601387a60 100644 --- a/nodedb/src/control/server/ilp_batch.rs +++ b/nodedb/src/control/server/ilp_batch.rs @@ -6,11 +6,11 @@ use sonic_rs; use tracing::warn; use crate::bridge::envelope::{Payload, PhysicalPlan, Response, Status}; -use crate::bridge::physical_plan::TimeseriesOp; use crate::control::gateway::GatewayErrorMap; use crate::control::gateway::core::QueryContext; use crate::control::state::SharedState; use crate::types::{DatabaseId, Lsn, RequestId, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::TimeseriesOp; /// EWMA-based rate estimator for adaptive ILP batch sizing. pub(super) struct IlpRateEstimator { diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/columnar.rs b/nodedb/src/control/server/native/dispatch/plan_builder/columnar.rs index 318cfd90a..6bc013d26 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/columnar.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/columnar.rs @@ -7,8 +7,8 @@ use nodedb_types::protocol::TextFields; use sonic_rs::{JsonContainerTrait, JsonValueTrait}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ColumnarInsertIntent, ColumnarOp}; use crate::control::server::native::dispatch::DispatchCtx; +use nodedb_physical::physical_plan::{ColumnarInsertIntent, ColumnarOp}; pub(crate) fn build_scan(fields: &TextFields, collection: &str) -> crate::Result { let limit = fields.limit.unwrap_or(10_000) as usize; diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/crdt.rs b/nodedb/src/control/server/native/dispatch/plan_builder/crdt.rs index 91cb76aba..67fd13f84 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/crdt.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/crdt.rs @@ -6,8 +6,8 @@ use nodedb_types::protocol::TextFields; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::require_doc_id; diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/document.rs b/nodedb/src/control/server/native/dispatch/plan_builder/document.rs index 808cd01b9..c31cfa2c4 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/document.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/document.rs @@ -8,7 +8,7 @@ use nodedb_types::protocol::TextFields; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{DocumentOp, KvOp, TimeseriesOp}; +use nodedb_physical::physical_plan::{DocumentOp, KvOp, TimeseriesOp}; use super::{DispatchCtx, collection_type, require_doc_id}; @@ -211,7 +211,7 @@ pub(crate) fn build_update( collection: &str, ) -> crate::Result { let doc_id = require_doc_id(fields)?; - let updates: Vec<(String, crate::bridge::physical_plan::UpdateValue)> = fields + let updates: Vec<(String, nodedb_physical::physical_plan::UpdateValue)> = fields .updates .as_ref() .ok_or_else(|| crate::Error::BadRequest { @@ -221,7 +221,7 @@ pub(crate) fn build_update( .map(|(f, b)| { ( f.clone(), - crate::bridge::physical_plan::UpdateValue::Literal(b.clone()), + nodedb_physical::physical_plan::UpdateValue::Literal(b.clone()), ) }) .collect(); @@ -293,7 +293,7 @@ pub(crate) fn build_bulk_update( detail: "missing 'filters'".to_string(), })? .clone(); - let updates: Vec<(String, crate::bridge::physical_plan::UpdateValue)> = fields + let updates: Vec<(String, nodedb_physical::physical_plan::UpdateValue)> = fields .updates .as_ref() .ok_or_else(|| crate::Error::BadRequest { @@ -303,7 +303,7 @@ pub(crate) fn build_bulk_update( .map(|(f, b)| { ( f.clone(), - crate::bridge::physical_plan::UpdateValue::Literal(b.clone()), + nodedb_physical::physical_plan::UpdateValue::Literal(b.clone()), ) }) .collect(); @@ -385,12 +385,12 @@ pub(crate) fn build_register(fields: &TextFields, collection: &str) -> crate::Re .clone() .unwrap_or_default() .into_iter() - .map(|path| crate::bridge::physical_plan::RegisteredIndex { + .map(|path| nodedb_physical::physical_plan::RegisteredIndex { name: path.clone(), path, unique: false, case_insensitive: false, - state: crate::bridge::physical_plan::RegisteredIndexState::Ready, + state: nodedb_physical::physical_plan::RegisteredIndexState::Ready, predicate: None, }) .collect(); @@ -399,8 +399,8 @@ pub(crate) fn build_register(fields: &TextFields, collection: &str) -> crate::Re collection: collection.to_string(), indexes, crdt_enabled: false, - storage_mode: crate::bridge::physical_plan::StorageMode::Schemaless, - enforcement: Box::new(crate::bridge::physical_plan::EnforcementOptions::default()), + storage_mode: nodedb_physical::physical_plan::StorageMode::Schemaless, + enforcement: Box::new(nodedb_physical::physical_plan::EnforcementOptions::default()), bitemporal: false, })) } diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/graph.rs b/nodedb/src/control/server/native/dispatch/plan_builder/graph.rs index 97741ad91..caede4026 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/graph.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/graph.rs @@ -6,9 +6,9 @@ use nodedb_types::protocol::TextFields; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::server::native::dispatch::DispatchCtx; use crate::engine::graph::traversal_options::MAX_GRAPH_TRAVERSAL_DEPTH; +use nodedb_physical::physical_plan::GraphOp; use super::parse_direction; diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/kv.rs b/nodedb/src/control/server/native/dispatch/plan_builder/kv.rs index a66e7673a..0d1d29d9b 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/kv.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/kv.rs @@ -5,7 +5,7 @@ use nodedb_types::protocol::TextFields; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::KvOp; +use nodedb_physical::physical_plan::KvOp; pub(crate) fn build_scan(fields: &TextFields, collection: &str) -> crate::Result { let cursor = fields.cursor.clone().unwrap_or_default(); diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/query.rs b/nodedb/src/control/server/native/dispatch/plan_builder/query.rs index 49c448cf3..e4002bec2 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/query.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/query.rs @@ -5,7 +5,7 @@ use nodedb_types::protocol::TextFields; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::QueryOp; +use nodedb_physical::physical_plan::QueryOp; pub(crate) fn build_recursive_scan( fields: &TextFields, diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/spatial.rs b/nodedb/src/control/server/native/dispatch/plan_builder/spatial.rs index 3511d876f..2012dcca0 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/spatial.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/spatial.rs @@ -5,7 +5,7 @@ use nodedb_types::protocol::TextFields; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{SpatialOp, SpatialPredicate}; +use nodedb_physical::physical_plan::{SpatialOp, SpatialPredicate}; pub(crate) fn build_scan(fields: &TextFields, collection: &str) -> crate::Result { let raw_bytes = fields diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/text.rs b/nodedb/src/control/server/native/dispatch/plan_builder/text.rs index 63e1e18f6..5cb4e79c9 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/text.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/text.rs @@ -5,7 +5,7 @@ use nodedb_types::protocol::TextFields; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::TextOp; +use nodedb_physical::physical_plan::TextOp; pub(crate) fn build_search(fields: &TextFields, collection: &str) -> crate::Result { let query_text = fields diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/timeseries.rs b/nodedb/src/control/server/native/dispatch/plan_builder/timeseries.rs index 125db8059..a3a69db2d 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/timeseries.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/timeseries.rs @@ -5,7 +5,7 @@ use nodedb_types::protocol::TextFields; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::TimeseriesOp; +use nodedb_physical::physical_plan::TimeseriesOp; pub(crate) fn build_scan(fields: &TextFields, collection: &str) -> crate::Result { let start = fields.time_range_start.unwrap_or(0); diff --git a/nodedb/src/control/server/native/dispatch/plan_builder/vector.rs b/nodedb/src/control/server/native/dispatch/plan_builder/vector.rs index adb08575e..f2d3e36fb 100644 --- a/nodedb/src/control/server/native/dispatch/plan_builder/vector.rs +++ b/nodedb/src/control/server/native/dispatch/plan_builder/vector.rs @@ -7,7 +7,7 @@ use nodedb_types::vector_distance::DistanceMetric; use super::super::DispatchCtx; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::VectorOp; +use nodedb_physical::physical_plan::VectorOp; pub(crate) fn build_search(fields: &TextFields, collection: &str) -> crate::Result { let query_vector = fields diff --git a/nodedb/src/control/server/native/dispatch/sql.rs b/nodedb/src/control/server/native/dispatch/sql.rs index fdb866c22..e41c97aa2 100644 --- a/nodedb/src/control/server/native/dispatch/sql.rs +++ b/nodedb/src/control/server/native/dispatch/sql.rs @@ -7,9 +7,9 @@ use nodedb_types::protocol::NativeResponse; use nodedb_types::value::Value; use crate::bridge::envelope::{Response, Status}; -use crate::control::planner::physical::PhysicalTask; use crate::control::server::pgwire::session::TransactionState; use crate::data::executor::response_codec; +use nodedb_physical::physical_task::PhysicalTask; use super::pgwire_bridge::pgwire_result_to_native; use super::sql_gateway::dispatch_task_via_gateway; @@ -257,7 +257,7 @@ async fn dispatch_task(ctx: &DispatchCtx<'_>, task: PhysicalTask) -> crate::Resu if matches!( task.plan, crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::InsertSelect { .. } + nodedb_physical::physical_plan::DocumentOp::InsertSelect { .. } ) ) { return broadcast_count_to_all_cores( @@ -274,7 +274,7 @@ async fn dispatch_task(ctx: &DispatchCtx<'_>, task: PhysicalTask) -> crate::Resu if matches!( task.plan, crate::bridge::envelope::PhysicalPlan::Array( - crate::bridge::physical_plan::ArrayOp::DropArray { .. } + nodedb_physical::physical_plan::ArrayOp::DropArray { .. } ) ) { return broadcast_count_to_all_cores( @@ -511,7 +511,7 @@ fn value_to_sql_literal(v: &Value) -> Result { #[cfg(test)] mod tests { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::{ColumnarOp, DocumentOp}; + use nodedb_physical::physical_plan::{ColumnarOp, DocumentOp}; #[test] fn columnar_scan_is_broadcast() { diff --git a/nodedb/src/control/server/native/dispatch/sql_gateway.rs b/nodedb/src/control/server/native/dispatch/sql_gateway.rs index 2c10579bc..8b7556998 100644 --- a/nodedb/src/control/server/native/dispatch/sql_gateway.rs +++ b/nodedb/src/control/server/native/dispatch/sql_gateway.rs @@ -11,9 +11,9 @@ use crate::bridge::envelope::{Payload, Response, Status}; use crate::control::gateway::GatewayErrorMap; use crate::control::gateway::core::QueryContext as GatewayQueryContext; -use crate::control::planner::physical::PhysicalTask; use crate::control::server::{dispatch_utils, wal_dispatch}; use crate::types::{Lsn, RequestId, TraceId}; +use nodedb_physical::physical_task::PhysicalTask; use super::DispatchCtx; diff --git a/nodedb/src/control/server/native/dispatch/transaction.rs b/nodedb/src/control/server/native/dispatch/transaction.rs index 7cd8e3cd2..c905a8584 100644 --- a/nodedb/src/control/server/native/dispatch/transaction.rs +++ b/nodedb/src/control/server/native/dispatch/transaction.rs @@ -7,10 +7,10 @@ use nodedb_types::id::DatabaseId; use nodedb_types::protocol::NativeResponse; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::gateway::GatewayErrorMap; use crate::control::gateway::core::QueryContext as GatewayQueryContext; -use crate::control::planner::physical::{PhysicalTask, PostSetOp}; +use nodedb_physical::physical_plan::MetaOp; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; use super::super::super::dispatch_utils; use super::{DispatchCtx, error_to_native}; diff --git a/nodedb/src/control/server/pgwire/ddl/collection/create/enforcement.rs b/nodedb/src/control/server/pgwire/ddl/collection/create/enforcement.rs index 113afc2a2..2ba0b50c2 100644 --- a/nodedb/src/control/server/pgwire/ddl/collection/create/enforcement.rs +++ b/nodedb/src/control/server/pgwire/ddl/collection/create/enforcement.rs @@ -110,7 +110,7 @@ pub fn find_materialized_sum_bindings( catalog: &SystemCatalog, tenant_id: u64, collection_name: &str, -) -> Vec { +) -> Vec { let all_collections = catalog .load_collections_for_tenant(DatabaseId::DEFAULT, tenant_id) .unwrap_or_default(); @@ -119,7 +119,7 @@ pub fn find_materialized_sum_bindings( for target_coll in &all_collections { for def in &target_coll.materialized_sums { if def.source_collection == collection_name { - bindings.push(crate::bridge::physical_plan::MaterializedSumBinding { + bindings.push(nodedb_physical::physical_plan::MaterializedSumBinding { target_collection: def.target_collection.clone(), target_column: def.target_column.clone(), join_column: def.join_column.clone(), @@ -137,7 +137,7 @@ pub fn find_materialized_sum_bindings( /// `FieldDefinition` entries (via `field_defs`). pub fn build_generated_column_specs( coll: &StoredCollection, -) -> Vec { +) -> Vec { let mut specs = Vec::new(); let schema_json = coll.timeseries_config.as_deref().unwrap_or(""); @@ -146,7 +146,7 @@ pub fn build_generated_column_specs( if let Some(ref expr_json) = col.generated_expr && let Ok(expr) = sonic_rs::from_str::(expr_json) { - specs.push(crate::bridge::physical_plan::GeneratedColumnSpec { + specs.push(nodedb_physical::physical_plan::GeneratedColumnSpec { name: col.name.clone(), expr, depends_on: col.generated_deps.clone(), @@ -162,7 +162,7 @@ pub fn build_generated_column_specs( sonic_rs::from_str::(&field_def.value_expr) && !specs.iter().any(|s| s.name == field_def.name) { - specs.push(crate::bridge::physical_plan::GeneratedColumnSpec { + specs.push(nodedb_physical::physical_plan::GeneratedColumnSpec { name: field_def.name.clone(), expr, depends_on: field_def.generated_deps.clone(), diff --git a/nodedb/src/control/server/pgwire/ddl/collection/create/register.rs b/nodedb/src/control/server/pgwire/ddl/collection/create/register.rs index 356d08433..6f774a2d0 100644 --- a/nodedb/src/control/server/pgwire/ddl/collection/create/register.rs +++ b/nodedb/src/control/server/pgwire/ddl/collection/create/register.rs @@ -109,15 +109,15 @@ pub async fn dispatch_register_from_stored( /// from the moment the collection is created. fn derive_auto_indexes<'a>( field_names: impl IntoIterator, -) -> Vec { +) -> Vec { field_names .into_iter() - .map(|n| crate::bridge::physical_plan::RegisteredIndex { + .map(|n| nodedb_physical::physical_plan::RegisteredIndex { name: n.to_string(), path: format!("$.{n}"), unique: false, case_insensitive: false, - state: crate::bridge::physical_plan::RegisteredIndexState::Ready, + state: nodedb_physical::physical_plan::RegisteredIndexState::Ready, predicate: None, }) .collect() @@ -128,19 +128,19 @@ fn derive_auto_indexes<'a>( /// catalog entry supersedes the auto-derived one: UNIQUE/COLLATE /// modifiers have to take effect. fn extend_with_catalog_indexes( - out: &mut Vec, + out: &mut Vec, coll: &StoredCollection, ) { for idx in &coll.indexes { let state = match idx.state { crate::control::security::catalog::IndexBuildState::Building => { - crate::bridge::physical_plan::RegisteredIndexState::Building + nodedb_physical::physical_plan::RegisteredIndexState::Building } crate::control::security::catalog::IndexBuildState::Ready => { - crate::bridge::physical_plan::RegisteredIndexState::Ready + nodedb_physical::physical_plan::RegisteredIndexState::Ready } }; - let spec = crate::bridge::physical_plan::RegisteredIndex { + let spec = nodedb_physical::physical_plan::RegisteredIndex { name: idx.name.clone(), path: idx.field.clone(), unique: idx.unique, @@ -160,7 +160,7 @@ async fn dispatch_register_from_stored_inner( state: &SharedState, tenant_id: crate::types::TenantId, coll: &StoredCollection, - indexes: Vec, + indexes: Vec, ) -> crate::Result<()> { let name = crate::control::planner::sql_plan_convert::convert::db_qualified( coll.database_id, @@ -175,30 +175,30 @@ async fn dispatch_register_from_stored_inner( // error here. let storage_mode = match &coll.collection_type { nodedb_types::CollectionType::Document(nodedb_types::DocumentMode::Strict(schema)) => { - crate::bridge::physical_plan::StorageMode::Strict { + nodedb_physical::physical_plan::StorageMode::Strict { schema: schema.clone(), } } nodedb_types::CollectionType::KeyValue(config) => { - crate::bridge::physical_plan::StorageMode::Strict { + nodedb_physical::physical_plan::StorageMode::Strict { schema: config.schema.clone(), } } nodedb_types::CollectionType::Document(nodedb_types::DocumentMode::Schemaless) | nodedb_types::CollectionType::Columnar(_) => { - crate::bridge::physical_plan::StorageMode::Schemaless + nodedb_physical::physical_plan::StorageMode::Schemaless } }; let crdt_enabled = false; - let enforcement = crate::bridge::physical_plan::EnforcementOptions { + let enforcement = nodedb_physical::physical_plan::EnforcementOptions { append_only: coll.append_only, hash_chain: coll.hash_chain, balanced: coll .balanced .as_ref() - .map(|b| crate::bridge::physical_plan::BalancedDef { + .map(|b| nodedb_physical::physical_plan::BalancedDef { group_key_column: b.group_key_column.clone(), entry_type_column: b.entry_type_column.clone(), debit_value: b.debit_value.clone(), @@ -206,7 +206,7 @@ async fn dispatch_register_from_stored_inner( amount_column: b.amount_column.clone(), }), period_lock: coll.period_lock.as_ref().map(|pl| { - crate::bridge::physical_plan::PeriodLockConfig { + nodedb_physical::physical_plan::PeriodLockConfig { period_column: pl.period_column.clone(), ref_table: pl.ref_table.clone(), ref_pk: pl.ref_pk.clone(), @@ -229,7 +229,7 @@ async fn dispatch_register_from_stored_inner( }; let plan = crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::Register { + nodedb_physical::physical_plan::DocumentOp::Register { collection: name.clone(), indexes, crdt_enabled, diff --git a/nodedb/src/control/server/pgwire/ddl/collection/index.rs b/nodedb/src/control/server/pgwire/ddl/collection/index.rs index 862dacea9..cbd26b30a 100644 --- a/nodedb/src/control/server/pgwire/ddl/collection/index.rs +++ b/nodedb/src/control/server/pgwire/ddl/collection/index.rs @@ -186,7 +186,7 @@ pub async fn create_index( let vshard = crate::types::VShardId::from_collection_in_database(DatabaseId::DEFAULT, collection); let backfill_plan = crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::BackfillIndex { + nodedb_physical::physical_plan::DocumentOp::BackfillIndex { collection: collection.to_string(), path: extraction_path.clone(), is_array, @@ -349,7 +349,7 @@ pub async fn drop_index( &coll.name, ); let plan = crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::DropIndex { + nodedb_physical::physical_plan::DocumentOp::DropIndex { collection: coll.name.clone(), field, }, diff --git a/nodedb/src/control/server/pgwire/ddl/collection/index_fanout.rs b/nodedb/src/control/server/pgwire/ddl/collection/index_fanout.rs index 87d5a6ef7..9bb75514c 100644 --- a/nodedb/src/control/server/pgwire/ddl/collection/index_fanout.rs +++ b/nodedb/src/control/server/pgwire/ddl/collection/index_fanout.rs @@ -20,13 +20,13 @@ use std::time::Duration; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::DocumentOp; use crate::control::state::SharedState; use crate::types::{DatabaseId, TenantId, TraceId}; +use nodedb_physical::physical_plan::DocumentOp; -use crate::bridge::physical_plan::wire as plan_wire; use nodedb_cluster::rpc_codec::{ExecuteRequest, RaftRpc}; use nodedb_cluster::topology::NodeState; +use nodedb_physical::physical_plan::wire as plan_wire; use super::super::super::types::sqlstate_error; diff --git a/nodedb/src/control/server/pgwire/ddl/collection/insert.rs b/nodedb/src/control/server/pgwire/ddl/collection/insert.rs index 898c9a75c..70e9c6890 100644 --- a/nodedb/src/control/server/pgwire/ddl/collection/insert.rs +++ b/nodedb/src/control/server/pgwire/ddl/collection/insert.rs @@ -6,9 +6,9 @@ use nodedb_types::DatabaseId; use pgwire::api::results::{Response, Tag}; use pgwire::error::PgWireResult; -use crate::bridge::physical_plan::VectorOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::VectorOp; use super::insert_parse::{ dispatch_plan, extract_vector_fields, fields_to_insert_sql, fire_before_triggers, diff --git a/nodedb/src/control/server/pgwire/ddl/collection/purge/dispatch.rs b/nodedb/src/control/server/pgwire/ddl/collection/purge/dispatch.rs index 557f0bc16..6697bd13d 100644 --- a/nodedb/src/control/server/pgwire/ddl/collection/purge/dispatch.rs +++ b/nodedb/src/control/server/pgwire/ddl/collection/purge/dispatch.rs @@ -8,9 +8,9 @@ //! collection symmetrically with the metadata row removal. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; use crate::types::{DatabaseId, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::MetaOp; /// Dispatch `MetaOp::UnregisterCollection { tenant_id, name, purge_lsn }` /// to this node's Data Plane. Best-effort: failures log at warn but diff --git a/nodedb/src/control/server/pgwire/ddl/conflict_policy.rs b/nodedb/src/control/server/pgwire/ddl/conflict_policy.rs index afbad851e..248b6cf4f 100644 --- a/nodedb/src/control/server/pgwire/ddl/conflict_policy.rs +++ b/nodedb/src/control/server/pgwire/ddl/conflict_policy.rs @@ -16,9 +16,9 @@ use nodedb_crdt::policy::{CollectionPolicy, ConflictPolicy}; use nodedb_sql::ddl_ast::alter_ops::{ConflictPolicyKind, ConstraintKindKeyword}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::types::{sqlstate_error, text_field}; use super::sync_dispatch::dispatch_async; diff --git a/nodedb/src/control/server/pgwire/ddl/continuous_agg/create.rs b/nodedb/src/control/server/pgwire/ddl/continuous_agg/create.rs index 03d0f1f2d..602b149de 100644 --- a/nodedb/src/control/server/pgwire/ddl/continuous_agg/create.rs +++ b/nodedb/src/control/server/pgwire/ddl/continuous_agg/create.rs @@ -9,13 +9,13 @@ use pgwire::api::results::Response; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::catalog::{StoredCollection, StoredContinuousAggregate}; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::ddl::{catalog_propose, collection, sync_dispatch}; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::state::SharedState; use crate::engine::timeseries::continuous_agg::ContinuousAggregateDef; +use nodedb_physical::physical_plan::MetaOp; use super::parse::{extract_with_options, parse_create_sql}; diff --git a/nodedb/src/control/server/pgwire/ddl/continuous_agg/drop.rs b/nodedb/src/control/server/pgwire/ddl/continuous_agg/drop.rs index 2910f5956..11676cc20 100644 --- a/nodedb/src/control/server/pgwire/ddl/continuous_agg/drop.rs +++ b/nodedb/src/control/server/pgwire/ddl/continuous_agg/drop.rs @@ -8,11 +8,11 @@ use pgwire::api::results::Response; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::ddl::{catalog_propose, sync_dispatch}; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::MetaOp; /// `DROP CONTINUOUS AGGREGATE `. /// diff --git a/nodedb/src/control/server/pgwire/ddl/continuous_agg/register.rs b/nodedb/src/control/server/pgwire/ddl/continuous_agg/register.rs index c683318ba..894b1dfdd 100644 --- a/nodedb/src/control/server/pgwire/ddl/continuous_agg/register.rs +++ b/nodedb/src/control/server/pgwire/ddl/continuous_agg/register.rs @@ -5,10 +5,10 @@ use std::time::Duration; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::state::SharedState; use crate::engine::timeseries::continuous_agg::ContinuousAggregateDef; +use nodedb_physical::physical_plan::MetaOp; /// Re-register every catalog-persisted continuous aggregate on the /// local Data Plane. Called at startup on paths that don't trigger diff --git a/nodedb/src/control/server/pgwire/ddl/continuous_agg/show.rs b/nodedb/src/control/server/pgwire/ddl/continuous_agg/show.rs index efa7127ae..5f16344e8 100644 --- a/nodedb/src/control/server/pgwire/ddl/continuous_agg/show.rs +++ b/nodedb/src/control/server/pgwire/ddl/continuous_agg/show.rs @@ -10,13 +10,13 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::catalog::StoredContinuousAggregate; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::server::pgwire::types::{int8_field, sqlstate_error, text_field}; use crate::control::state::SharedState; use crate::engine::timeseries::continuous_agg::{AggregateInfo, ContinuousAggregateDef}; +use nodedb_physical::physical_plan::MetaOp; /// `SHOW CONTINUOUS AGGREGATES [FOR ]`. /// diff --git a/nodedb/src/control/server/pgwire/ddl/convert.rs b/nodedb/src/control/server/pgwire/ddl/convert.rs index 859b1c822..f6237b30d 100644 --- a/nodedb/src/control/server/pgwire/ddl/convert.rs +++ b/nodedb/src/control/server/pgwire/ddl/convert.rs @@ -15,9 +15,9 @@ use pgwire::error::PgWireResult; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::MetaOp; use super::super::types::sqlstate_error; diff --git a/nodedb/src/control/server/pgwire/ddl/crdt_ops.rs b/nodedb/src/control/server/pgwire/ddl/crdt_ops.rs index af9501345..7db1fdd90 100644 --- a/nodedb/src/control/server/pgwire/ddl/crdt_ops.rs +++ b/nodedb/src/control/server/pgwire/ddl/crdt_ops.rs @@ -13,9 +13,9 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::types::{hex_decode, sqlstate_error, text_field}; diff --git a/nodedb/src/control/server/pgwire/ddl/dsl/crdt_merge.rs b/nodedb/src/control/server/pgwire/ddl/dsl/crdt_merge.rs index 7e3ce640e..bf8f39bb1 100644 --- a/nodedb/src/control/server/pgwire/ddl/dsl/crdt_merge.rs +++ b/nodedb/src/control/server/pgwire/ddl/dsl/crdt_merge.rs @@ -8,10 +8,10 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; /// CRDT MERGE INTO FROM '' TO '' pub async fn crdt_merge( diff --git a/nodedb/src/control/server/pgwire/ddl/dsl/vector_index.rs b/nodedb/src/control/server/pgwire/ddl/dsl/vector_index.rs index 0c25d049c..7f56cc106 100644 --- a/nodedb/src/control/server/pgwire/ddl/dsl/vector_index.rs +++ b/nodedb/src/control/server/pgwire/ddl/dsl/vector_index.rs @@ -11,12 +11,12 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::VectorOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::state::SharedState; use crate::types::DatabaseId; use crate::types::TraceId; +use nodedb_physical::physical_plan::VectorOp; use super::helpers::{find_param_str, find_param_usize}; diff --git a/nodedb/src/control/server/pgwire/ddl/graph_ops/algo.rs b/nodedb/src/control/server/pgwire/ddl/graph_ops/algo.rs index 3fd440fa8..f2710842d 100644 --- a/nodedb/src/control/server/pgwire/ddl/graph_ops/algo.rs +++ b/nodedb/src/control/server/pgwire/ddl/graph_ops/algo.rs @@ -9,13 +9,13 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::broadcast; use crate::control::server::pgwire::types::{sqlstate_error, text_field}; use crate::control::state::SharedState; use crate::data::executor::response_codec; use crate::types::TraceId; +use nodedb_physical::physical_plan::GraphOp; const MAX_ITERATIONS_CAP: usize = 1_000; const MAX_SAMPLE_CAP: usize = 1_000_000; diff --git a/nodedb/src/control/server/pgwire/ddl/graph_ops/edge.rs b/nodedb/src/control/server/pgwire/ddl/graph_ops/edge.rs index e1003a5ce..636dbd40f 100644 --- a/nodedb/src/control/server/pgwire/ddl/graph_ops/edge.rs +++ b/nodedb/src/control/server/pgwire/ddl/graph_ops/edge.rs @@ -13,12 +13,12 @@ use pgwire::error::PgWireResult; use nodedb_sql::ddl_ast::GraphProperties; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::server::{dispatch_utils, wal_dispatch}; use crate::control::state::SharedState; use crate::types::{TraceId, VShardId}; +use nodedb_physical::physical_plan::GraphOp; /// Maximum byte length for an edge label string. Keeps a single `TYPE` /// clause from bloating the CSR label table and the msgpack wire payload. diff --git a/nodedb/src/control/server/pgwire/ddl/graph_ops/rag_fusion.rs b/nodedb/src/control/server/pgwire/ddl/graph_ops/rag_fusion.rs index 781028a0f..34012bd4e 100644 --- a/nodedb/src/control/server/pgwire/ddl/graph_ops/rag_fusion.rs +++ b/nodedb/src/control/server/pgwire/ddl/graph_ops/rag_fusion.rs @@ -18,7 +18,6 @@ use nodedb_sql::ddl_ast::FusionParams; use nodedb_sql::ddl_ast::GraphDirection; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::server::pgwire::types::{sqlstate_error, text_field}; @@ -26,6 +25,7 @@ use crate::control::state::SharedState; use crate::data::executor::response_codec; use crate::engine::graph::edge_store::Direction; use crate::engine::graph::traversal_options::{GraphTraversalOptions, MAX_GRAPH_TRAVERSAL_DEPTH}; +use nodedb_physical::physical_plan::GraphOp; const FUSION_VECTOR_TOP_K_CAP: usize = 10_000; const FUSION_TOP_CAP: usize = 10_000; diff --git a/nodedb/src/control/server/pgwire/ddl/graph_ops/stats.rs b/nodedb/src/control/server/pgwire/ddl/graph_ops/stats.rs index 70d962190..a6c597342 100644 --- a/nodedb/src/control/server/pgwire/ddl/graph_ops/stats.rs +++ b/nodedb/src/control/server/pgwire/ddl/graph_ops/stats.rs @@ -42,13 +42,13 @@ pub fn graph_stats_calls_total() -> u64 { } use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::broadcast::broadcast_to_all_cores; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::state::SharedState; use crate::engine::graph::edge_store::stats::CollectionStats; use crate::types::TraceId; +use nodedb_physical::physical_plan::GraphOp; /// `SHOW GRAPH STATS [''] [VERBOSE] [AS OF SYSTEM TIME ]`. pub async fn show_graph_stats( diff --git a/nodedb/src/control/server/pgwire/ddl/graph_ops/traverse.rs b/nodedb/src/control/server/pgwire/ddl/graph_ops/traverse.rs index 1e14ac97c..44779a0f1 100644 --- a/nodedb/src/control/server/pgwire/ddl/graph_ops/traverse.rs +++ b/nodedb/src/control/server/pgwire/ddl/graph_ops/traverse.rs @@ -8,7 +8,6 @@ use pgwire::error::PgWireResult; use nodedb_sql::ddl_ast::GraphDirection; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::GraphOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::pgwire::types::sqlstate_error; use crate::control::state::SharedState; @@ -16,6 +15,7 @@ use crate::engine::graph::edge_store::Direction; use crate::engine::graph::traversal_options::GraphTraversalOptions; use crate::engine::graph::traversal_options::MAX_GRAPH_TRAVERSAL_DEPTH; use crate::types::TraceId; +use nodedb_physical::physical_plan::GraphOp; use super::response::payload_to_query_response; diff --git a/nodedb/src/control/server/pgwire/ddl/kv_atomic.rs b/nodedb/src/control/server/pgwire/ddl/kv_atomic.rs index e179ec59b..b623942c3 100644 --- a/nodedb/src/control/server/pgwire/ddl/kv_atomic.rs +++ b/nodedb/src/control/server/pgwire/ddl/kv_atomic.rs @@ -10,10 +10,10 @@ use futures::stream; use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; -use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; /// Handle `SELECT KV_INCR(collection, key, delta [, TTL => seconds])` /// diff --git a/nodedb/src/control/server/pgwire/ddl/kv_sorted_index.rs b/nodedb/src/control/server/pgwire/ddl/kv_sorted_index.rs index ee1aaf15b..edfa3ea4b 100644 --- a/nodedb/src/control/server/pgwire/ddl/kv_sorted_index.rs +++ b/nodedb/src/control/server/pgwire/ddl/kv_sorted_index.rs @@ -17,10 +17,10 @@ use pgwire::api::results::{DataRowEncoder, FieldInfo, QueryResponse, Response}; use pgwire::error::PgWireResult; use sonic_rs; -use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; /// Handle `CREATE SORTED INDEX name ON collection (col DIR, ...) KEY key_col [WINDOW ...]` pub async fn create_sorted_index( diff --git a/nodedb/src/control/server/pgwire/ddl/last_value.rs b/nodedb/src/control/server/pgwire/ddl/last_value.rs index 3f1f746f3..262b0718b 100644 --- a/nodedb/src/control/server/pgwire/ddl/last_value.rs +++ b/nodedb/src/control/server/pgwire/ddl/last_value.rs @@ -19,9 +19,9 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::MetaOp; use super::super::types::{int8_field, sqlstate_error, text_field}; diff --git a/nodedb/src/control/server/pgwire/ddl/maintenance/compact.rs b/nodedb/src/control/server/pgwire/ddl/maintenance/compact.rs index 569a450b7..6eae600bf 100644 --- a/nodedb/src/control/server/pgwire/ddl/maintenance/compact.rs +++ b/nodedb/src/control/server/pgwire/ddl/maintenance/compact.rs @@ -48,7 +48,7 @@ pub fn handle_compact( super::distributed::dispatch_maintenance_to_all_cores( state, tenant_id, - crate::bridge::physical_plan::MetaOp::Compact, + nodedb_physical::physical_plan::MetaOp::Compact, ); tracing::info!(%collection, "COMPACT dispatched"); diff --git a/nodedb/src/control/server/pgwire/ddl/maintenance/distributed.rs b/nodedb/src/control/server/pgwire/ddl/maintenance/distributed.rs index 1dc0940d0..1bf16f324 100644 --- a/nodedb/src/control/server/pgwire/ddl/maintenance/distributed.rs +++ b/nodedb/src/control/server/pgwire/ddl/maintenance/distributed.rs @@ -7,10 +7,10 @@ //! In single-node mode, they execute directly on the local Data Plane. use crate::bridge::envelope::{PhysicalPlan, Priority, Request}; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; use crate::event::EventSource; use crate::types::{DatabaseId, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::MetaOp; /// Dispatch a maintenance operation (COMPACT/REINDEX) to all Data Plane cores. /// diff --git a/nodedb/src/control/server/pgwire/ddl/maintenance/reindex.rs b/nodedb/src/control/server/pgwire/ddl/maintenance/reindex.rs index b83cf7edd..f362ece13 100644 --- a/nodedb/src/control/server/pgwire/ddl/maintenance/reindex.rs +++ b/nodedb/src/control/server/pgwire/ddl/maintenance/reindex.rs @@ -17,10 +17,10 @@ use nodedb_types::DatabaseId; use pgwire::api::results::{Response, Tag}; use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::TraceId; +use nodedb_physical::physical_plan::MetaOp; /// Execute a parsed `REINDEX [INDEX name] [CONCURRENTLY] collection` statement. pub async fn handle_reindex( diff --git a/nodedb/src/control/server/pgwire/ddl/maintenance/vector_index.rs b/nodedb/src/control/server/pgwire/ddl/maintenance/vector_index.rs index 0749b1082..41c5afd0d 100644 --- a/nodedb/src/control/server/pgwire/ddl/maintenance/vector_index.rs +++ b/nodedb/src/control/server/pgwire/ddl/maintenance/vector_index.rs @@ -14,11 +14,11 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response, Tag}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::VectorOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::DatabaseId; use crate::types::TraceId; +use nodedb_physical::physical_plan::VectorOp; use super::super::super::types::{sqlstate_error, text_field}; diff --git a/nodedb/src/control/server/pgwire/ddl/match_ops.rs b/nodedb/src/control/server/pgwire/ddl/match_ops.rs index 1965eba6b..275fc4b87 100644 --- a/nodedb/src/control/server/pgwire/ddl/match_ops.rs +++ b/nodedb/src/control/server/pgwire/ddl/match_ops.rs @@ -10,12 +10,12 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use sonic_rs; -use crate::bridge::physical_plan::GraphOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::broadcast; use crate::control::state::SharedState; use crate::data::executor::response_codec; use crate::types::TraceId; +use nodedb_physical::physical_plan::GraphOp; use super::super::types::{sqlstate_error, text_field}; diff --git a/nodedb/src/control/server/pgwire/ddl/query_functions/balance_as_of.rs b/nodedb/src/control/server/pgwire/ddl/query_functions/balance_as_of.rs index c53283bf7..4ea2c983c 100644 --- a/nodedb/src/control/server/pgwire/ddl/query_functions/balance_as_of.rs +++ b/nodedb/src/control/server/pgwire/ddl/query_functions/balance_as_of.rs @@ -48,7 +48,7 @@ pub async fn balance_as_of( .lookup(&collection, &pk_bytes) .map_err(|e| sqlstate_error("XX000", &format!("surrogate lookup failed: {e}")))? .unwrap_or(nodedb_types::Surrogate::ZERO); - let get_plan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::PointGet { + let get_plan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::PointGet { collection: collection.clone(), document_id: key.clone(), surrogate, @@ -91,7 +91,7 @@ pub async fn balance_as_of( // Scan the source collection for rows where join_column = key AND created_at > as_of. let source_vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &mat_def.source_collection); - let source_scan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let source_scan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: mat_def.source_collection.clone(), limit: usize::MAX, offset: 0, diff --git a/nodedb/src/control/server/pgwire/ddl/query_functions/convert_currency_lookup.rs b/nodedb/src/control/server/pgwire/ddl/query_functions/convert_currency_lookup.rs index c058c50a8..82bcaad43 100644 --- a/nodedb/src/control/server/pgwire/ddl/query_functions/convert_currency_lookup.rs +++ b/nodedb/src/control/server/pgwire/ddl/query_functions/convert_currency_lookup.rs @@ -66,7 +66,7 @@ pub async fn convert_currency_lookup( // Scan rate table to find latest rate where key_column == key_value AND time_column <= as_of. let vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &rate_table); - let scan_plan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let scan_plan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: rate_table.clone(), limit: usize::MAX, offset: 0, diff --git a/nodedb/src/control/server/pgwire/ddl/query_functions/temporal_lookup.rs b/nodedb/src/control/server/pgwire/ddl/query_functions/temporal_lookup.rs index 5dbb215fc..af28b2e36 100644 --- a/nodedb/src/control/server/pgwire/ddl/query_functions/temporal_lookup.rs +++ b/nodedb/src/control/server/pgwire/ddl/query_functions/temporal_lookup.rs @@ -42,7 +42,7 @@ pub async fn temporal_lookup( // Scan the table. let vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &table); - let scan_plan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let scan_plan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: table.clone(), limit: usize::MAX, offset: 0, diff --git a/nodedb/src/control/server/pgwire/ddl/query_functions/verify_balance.rs b/nodedb/src/control/server/pgwire/ddl/query_functions/verify_balance.rs index f24030875..834a32ce8 100644 --- a/nodedb/src/control/server/pgwire/ddl/query_functions/verify_balance.rs +++ b/nodedb/src/control/server/pgwire/ddl/query_functions/verify_balance.rs @@ -64,7 +64,7 @@ pub async fn verify_balance( // Scan all target rows. let target_vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &collection); - let target_scan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let target_scan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: collection.clone(), limit: usize::MAX, offset: 0, @@ -95,7 +95,7 @@ pub async fn verify_balance( // Scan all source rows. let source_vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &mat_def.source_collection); - let source_scan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let source_scan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: mat_def.source_collection.clone(), limit: usize::MAX, offset: 0, diff --git a/nodedb/src/control/server/pgwire/ddl/query_functions/verify_hash_chain.rs b/nodedb/src/control/server/pgwire/ddl/query_functions/verify_hash_chain.rs index feb2cf0b2..ec50ce85f 100644 --- a/nodedb/src/control/server/pgwire/ddl/query_functions/verify_hash_chain.rs +++ b/nodedb/src/control/server/pgwire/ddl/query_functions/verify_hash_chain.rs @@ -43,7 +43,7 @@ pub async fn verify_hash_chain( // Scan all documents. let vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &collection); - let scan_plan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let scan_plan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: collection.clone(), limit: usize::MAX, offset: 0, diff --git a/nodedb/src/control/server/pgwire/ddl/rate_gate.rs b/nodedb/src/control/server/pgwire/ddl/rate_gate.rs index e726a54fb..00317e670 100644 --- a/nodedb/src/control/server/pgwire/ddl/rate_gate.rs +++ b/nodedb/src/control/server/pgwire/ddl/rate_gate.rs @@ -20,10 +20,10 @@ use pgwire::error::PgWireResult; use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Status}; -use crate::bridge::physical_plan::KvOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::KvOp; /// Internal collection used for rate gate counters. const RATE_COLLECTION: &str = "_system_rate_gates"; diff --git a/nodedb/src/control/server/pgwire/ddl/router/dsl.rs b/nodedb/src/control/server/pgwire/ddl/router/dsl.rs index ea9b13270..4a7801e41 100644 --- a/nodedb/src/control/server/pgwire/ddl/router/dsl.rs +++ b/nodedb/src/control/server/pgwire/ddl/router/dsl.rs @@ -3,11 +3,11 @@ use pgwire::api::results::Response; use pgwire::error::PgWireResult; -use crate::bridge::physical_plan::DocumentOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::DatabaseId; use crate::types::TraceId; +use nodedb_physical::physical_plan::DocumentOp; pub(super) async fn dispatch( state: &SharedState, diff --git a/nodedb/src/control/server/pgwire/ddl/synonym_group.rs b/nodedb/src/control/server/pgwire/ddl/synonym_group.rs index a810b54dc..f937f3dc0 100644 --- a/nodedb/src/control/server/pgwire/ddl/synonym_group.rs +++ b/nodedb/src/control/server/pgwire/ddl/synonym_group.rs @@ -21,10 +21,10 @@ use pgwire::error::PgWireResult; use nodedb_fts::SynonymGroupRecord; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::security::catalog::StoredSynonymGroup; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::MetaOp; use super::super::types::{require_tenant_admin, sqlstate_error, text_field}; use super::sync_dispatch::dispatch_async; diff --git a/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/cutover.rs b/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/cutover.rs index 9908c63e6..bf022ab4a 100644 --- a/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/cutover.rs +++ b/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/cutover.rs @@ -21,7 +21,6 @@ use std::time::Duration; use bytes::Bytes; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::catalog_entry::CatalogEntry; use crate::control::catalog_entry::apply::apply_to; use crate::control::metadata_proposer::propose_catalog_entry; @@ -30,6 +29,7 @@ use crate::control::security::catalog::{StoredCollection, SystemCatalog}; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::state::SharedState; use crate::types::{DatabaseId, TenantId}; +use nodedb_physical::physical_plan::MetaOp; use nodedb_types::NodeDbError; /// Timeout for each Data Plane rename dispatch. diff --git a/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/snapshot.rs b/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/snapshot.rs index a4bd20941..a4109ccdb 100644 --- a/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/snapshot.rs +++ b/nodedb/src/control/server/pgwire/ddl/tenant/move_tenant/snapshot.rs @@ -14,7 +14,7 @@ use std::time::Duration; use bytes::Bytes; -use crate::bridge::physical_plan::{MetaOp, PhysicalPlan}; +use nodedb_physical::physical_plan::{MetaOp, PhysicalPlan}; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::state::SharedState; diff --git a/nodedb/src/control/server/pgwire/ddl/tenant/purge.rs b/nodedb/src/control/server/pgwire/ddl/tenant/purge.rs index 4a6aeb603..45f375c6d 100644 --- a/nodedb/src/control/server/pgwire/ddl/tenant/purge.rs +++ b/nodedb/src/control/server/pgwire/ddl/tenant/purge.rs @@ -55,7 +55,7 @@ pub async fn purge_tenant( ); let plan = crate::bridge::envelope::PhysicalPlan::Meta( - crate::bridge::physical_plan::MetaOp::PurgeTenant { tenant_id: tid }, + nodedb_physical::physical_plan::MetaOp::PurgeTenant { tenant_id: tid }, ); match super::super::sync_dispatch::dispatch_async( diff --git a/nodedb/src/control/server/pgwire/ddl/transfer.rs b/nodedb/src/control/server/pgwire/ddl/transfer.rs index 7ed8c7847..67421c252 100644 --- a/nodedb/src/control/server/pgwire/ddl/transfer.rs +++ b/nodedb/src/control/server/pgwire/ddl/transfer.rs @@ -19,10 +19,10 @@ use futures::stream; use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; -use crate::bridge::physical_plan::{KvOp, PhysicalPlan}; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; /// Handle `SELECT TRANSFER(collection, source_key, dest_key, field, amount)` pub async fn transfer( diff --git a/nodedb/src/control/server/pgwire/ddl/tree_ops/create_index.rs b/nodedb/src/control/server/pgwire/ddl/tree_ops/create_index.rs index 293d6f632..a8d43ebb8 100644 --- a/nodedb/src/control/server/pgwire/ddl/tree_ops/create_index.rs +++ b/nodedb/src/control/server/pgwire/ddl/tree_ops/create_index.rs @@ -34,13 +34,13 @@ use pgwire::error::PgWireResult; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{BatchEdge, GraphOp}; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::server::broadcast::broadcast_to_all_cores; use crate::control::server::pgwire::types::{sqlstate_error, text_field}; use crate::control::server::{dispatch_utils, wal_dispatch}; use crate::control::state::SharedState; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{BatchEdge, GraphOp}; use super::parse::parse_edge_columns; @@ -85,7 +85,7 @@ pub async fn create_graph_index( } // ── Broadcast scan: collect documents from every vshard ────────── - let scan_plan = PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::Scan { + let scan_plan = PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::Scan { collection: collection.clone(), limit: usize::MAX, offset: 0, diff --git a/nodedb/src/control/server/pgwire/ddl/tree_ops/sum.rs b/nodedb/src/control/server/pgwire/ddl/tree_ops/sum.rs index b493119af..fe4e6b716 100644 --- a/nodedb/src/control/server/pgwire/ddl/tree_ops/sum.rs +++ b/nodedb/src/control/server/pgwire/ddl/tree_ops/sum.rs @@ -120,7 +120,7 @@ pub async fn tree_sum( .map_err(|e| sqlstate_error("XX000", &format!("surrogate lookup: {e}")))? .unwrap_or(nodedb_types::Surrogate::ZERO); let get_plan = - PhysicalPlan::Document(crate::bridge::physical_plan::DocumentOp::PointGet { + PhysicalPlan::Document(nodedb_physical::physical_plan::DocumentOp::PointGet { collection: coll_name.clone(), document_id: node_id.clone(), surrogate, diff --git a/nodedb/src/control/server/pgwire/ddl/version_history/at_version.rs b/nodedb/src/control/server/pgwire/ddl/version_history/at_version.rs index 9d42acab1..1b5892432 100644 --- a/nodedb/src/control/server/pgwire/ddl/version_history/at_version.rs +++ b/nodedb/src/control/server/pgwire/ddl/version_history/at_version.rs @@ -10,9 +10,9 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::super::types::{sqlstate_error, text_field}; diff --git a/nodedb/src/control/server/pgwire/ddl/version_history/checkpoint.rs b/nodedb/src/control/server/pgwire/ddl/version_history/checkpoint.rs index fc6cf2baa..78c662c0a 100644 --- a/nodedb/src/control/server/pgwire/ddl/version_history/checkpoint.rs +++ b/nodedb/src/control/server/pgwire/ddl/version_history/checkpoint.rs @@ -13,10 +13,10 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::catalog::types::CheckpointRecord; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::super::types::sqlstate_error; diff --git a/nodedb/src/control/server/pgwire/ddl/version_history/compact.rs b/nodedb/src/control/server/pgwire/ddl/version_history/compact.rs index 982d02e05..3f24fef2b 100644 --- a/nodedb/src/control/server/pgwire/ddl/version_history/compact.rs +++ b/nodedb/src/control/server/pgwire/ddl/version_history/compact.rs @@ -8,9 +8,9 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::super::types::sqlstate_error; diff --git a/nodedb/src/control/server/pgwire/ddl/version_history/diff.rs b/nodedb/src/control/server/pgwire/ddl/version_history/diff.rs index f1be06686..209a951a2 100644 --- a/nodedb/src/control/server/pgwire/ddl/version_history/diff.rs +++ b/nodedb/src/control/server/pgwire/ddl/version_history/diff.rs @@ -10,9 +10,9 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::super::types::{int8_field, sqlstate_error, text_field}; diff --git a/nodedb/src/control/server/pgwire/ddl/version_history/restore.rs b/nodedb/src/control/server/pgwire/ddl/version_history/restore.rs index 6b4810539..743126e19 100644 --- a/nodedb/src/control/server/pgwire/ddl/version_history/restore.rs +++ b/nodedb/src/control/server/pgwire/ddl/version_history/restore.rs @@ -8,9 +8,9 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::PgWireResult; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::CrdtOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::CrdtOp; use super::super::super::types::sqlstate_error; diff --git a/nodedb/src/control/server/pgwire/ddl/weighted_pick.rs b/nodedb/src/control/server/pgwire/ddl/weighted_pick.rs index d05770786..e3e50b3fa 100644 --- a/nodedb/src/control/server/pgwire/ddl/weighted_pick.rs +++ b/nodedb/src/control/server/pgwire/ddl/weighted_pick.rs @@ -19,12 +19,12 @@ use pgwire::error::PgWireResult; use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Status}; -use crate::bridge::physical_plan::KvOp; use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::engine::random::alias::AliasTable; use crate::engine::random::csprng::SeedableRng; use crate::types::{DatabaseId, TraceId, VShardId}; +use nodedb_physical::physical_plan::KvOp; /// Handle `SELECT * FROM WEIGHTED_PICK('collection', weight => 'col', count => N, ...)` pub async fn weighted_pick( diff --git a/nodedb/src/control/server/pgwire/handler/dispatch.rs b/nodedb/src/control/server/pgwire/handler/dispatch.rs index cf496ec7f..e32e8d85a 100644 --- a/nodedb/src/control/server/pgwire/handler/dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/dispatch.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use std::time::Instant; use crate::bridge::envelope::{Priority, Request, Response}; -use crate::control::planner::physical::PhysicalTask; use crate::types::{DatabaseId, Lsn, ReadConsistency, TraceId}; +use nodedb_physical::physical_task::PhysicalTask; use sonic_rs; use super::core::NodeDbPgHandler; @@ -104,7 +104,7 @@ impl NodeDbPgHandler { if matches!( task.plan, crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::InsertSelect { .. } + nodedb_physical::physical_plan::DocumentOp::InsertSelect { .. } ) ) { return crate::control::server::broadcast::broadcast_count_to_all_cores( @@ -123,7 +123,7 @@ impl NodeDbPgHandler { if matches!( task.plan, crate::bridge::envelope::PhysicalPlan::Array( - crate::bridge::physical_plan::ArrayOp::DropArray { .. } + nodedb_physical::physical_plan::ArrayOp::DropArray { .. } ) ) { return crate::control::server::broadcast::broadcast_count_to_all_cores( @@ -149,7 +149,7 @@ impl NodeDbPgHandler { // Cross-shard HashJoin: two-phase execution. if let crate::bridge::envelope::PhysicalPlan::Query( - crate::bridge::physical_plan::QueryOp::HashJoin { + nodedb_physical::physical_plan::QueryOp::HashJoin { ref left_collection, ref right_collection, ref left_alias, @@ -172,19 +172,19 @@ impl NodeDbPgHandler { // then send both as a BroadcastJoin to a single core. if let Some(inner_plan) = inline_left { // Step 1: Execute the inner join via recursive dispatch. - let inner_task = crate::control::planner::physical::PhysicalTask { + let inner_task = nodedb_physical::physical_task::PhysicalTask { tenant_id: task.tenant_id, vshard_id: task.vshard_id, database_id: task.database_id, plan: inner_plan.as_ref().clone(), - post_set_op: crate::control::planner::physical::PostSetOp::None, + post_set_op: nodedb_physical::physical_task::PostSetOp::None, }; let inner_resp = Box::pin(self.dispatch_task(inner_task, None)).await?; let left_data: Vec = inner_resp.payload.as_ref().to_vec(); // Step 2: Broadcast-scan the right collection. let right_scan = crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::Scan { + nodedb_physical::physical_plan::DocumentOp::Scan { collection: right_collection.clone(), filters: Vec::new(), limit: (limit * 10).min(50000), @@ -212,7 +212,7 @@ impl NodeDbPgHandler { let on_keys: Vec<(String, String)> = on.iter().map(|(l, r)| (l.clone(), r.clone())).collect(); let join_plan = crate::bridge::envelope::PhysicalPlan::Query( - crate::bridge::physical_plan::QueryOp::InlineHashJoin { + nodedb_physical::physical_plan::QueryOp::InlineHashJoin { left_data, right_data, right_alias: right_alias.clone(), @@ -223,12 +223,12 @@ impl NodeDbPgHandler { post_filters: post_filters.clone(), }, ); - let join_task = crate::control::planner::physical::PhysicalTask { + let join_task = nodedb_physical::physical_task::PhysicalTask { tenant_id: task.tenant_id, vshard_id: task.vshard_id, database_id: task.database_id, plan: join_plan, - post_set_op: crate::control::planner::physical::PostSetOp::None, + post_set_op: nodedb_physical::physical_task::PostSetOp::None, }; let mut resp = self.dispatch_local(join_task, None).await?; @@ -254,7 +254,7 @@ impl NodeDbPgHandler { .await? } else { let right_scan = crate::bridge::envelope::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::Scan { + nodedb_physical::physical_plan::DocumentOp::Scan { collection: right_collection.clone(), filters: Vec::new(), limit: (limit * 10).min(50000), @@ -295,7 +295,7 @@ impl NodeDbPgHandler { let post_aggregates = post_aggregates.clone(); let broadcast_plan = crate::bridge::envelope::PhysicalPlan::Query( - crate::bridge::physical_plan::QueryOp::BroadcastJoin { + nodedb_physical::physical_plan::QueryOp::BroadcastJoin { large_collection: left_collection.clone(), small_collection: right_collection.clone(), large_alias: left_alias.clone(), @@ -537,12 +537,12 @@ impl NodeDbPgHandler { let vshard_id = super::plan::extract_collection(plan) .map(|c| crate::types::VShardId::from_collection_in_database(database_id, c)) .unwrap_or(fallback_vshard_id); - let task = crate::control::planner::physical::PhysicalTask { + let task = nodedb_physical::physical_task::PhysicalTask { tenant_id, vshard_id, database_id, plan: plan.clone(), - post_set_op: crate::control::planner::physical::PostSetOp::None, + post_set_op: nodedb_physical::physical_task::PostSetOp::None, }; let resp = Box::pin(self.dispatch_task(task, None)).await?; normalize_join_broadcast_payload(resp.payload.as_ref()) diff --git a/nodedb/src/control/server/pgwire/handler/facet.rs b/nodedb/src/control/server/pgwire/handler/facet.rs index 4fcdda79a..f71f33986 100644 --- a/nodedb/src/control/server/pgwire/handler/facet.rs +++ b/nodedb/src/control/server/pgwire/handler/facet.rs @@ -12,10 +12,10 @@ use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::QueryOp; -use crate::control::planner::physical::{PhysicalTask, PostSetOp}; use crate::control::security::identity::AuthenticatedIdentity; use crate::types::{DatabaseId, VShardId}; +use nodedb_physical::physical_plan::QueryOp; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; use super::core::NodeDbPgHandler; use super::plan::{PlanKind, payload_to_response}; diff --git a/nodedb/src/control/server/pgwire/handler/plan.rs b/nodedb/src/control/server/pgwire/handler/plan.rs index adc217e75..f1214dc53 100644 --- a/nodedb/src/control/server/pgwire/handler/plan.rs +++ b/nodedb/src/control/server/pgwire/handler/plan.rs @@ -9,13 +9,13 @@ use pgwire::api::results::{DataRowEncoder, QueryResponse, Response, Tag}; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ - ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, QueryOp, SpatialOp, TextOp, - TimeseriesOp, VectorOp, -}; use crate::data::executor::response_codec::{ ArraySliceResponse, RowsPayload, decode_payload_to_json, }; +use nodedb_physical::physical_plan::{ + ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, QueryOp, SpatialOp, TextOp, + TimeseriesOp, VectorOp, +}; use zerompk; use super::super::types::text_field; @@ -208,19 +208,19 @@ pub(super) fn describe_plan(plan: &PhysicalPlan) -> PlanKind { // is plain msgpack (decode_payload_to_json transcodes); Slice / // Project payloads use the tagged Value codec which transcodes // to a JSON array of arrays — clients receive JSON text per row. - PhysicalPlan::Array(crate::bridge::physical_plan::ArrayOp::Slice { .. }) => { + PhysicalPlan::Array(nodedb_physical::physical_plan::ArrayOp::Slice { .. }) => { PlanKind::ArraySlice } - PhysicalPlan::Array(crate::bridge::physical_plan::ArrayOp::Project { .. }) - | PhysicalPlan::Array(crate::bridge::physical_plan::ArrayOp::Aggregate { .. }) - | PhysicalPlan::Array(crate::bridge::physical_plan::ArrayOp::Elementwise { .. }) => { + PhysicalPlan::Array(nodedb_physical::physical_plan::ArrayOp::Project { .. }) + | PhysicalPlan::Array(nodedb_physical::physical_plan::ArrayOp::Aggregate { .. }) + | PhysicalPlan::Array(nodedb_physical::physical_plan::ArrayOp::Elementwise { .. }) => { PlanKind::MultiRow } // Flush / Compact return `{flushed: 1}` / `{compacted: N}` — // route as SingleDocument so the row's `document` column // carries the status JSON. - PhysicalPlan::Array(crate::bridge::physical_plan::ArrayOp::Flush { .. }) - | PhysicalPlan::Array(crate::bridge::physical_plan::ArrayOp::Compact { .. }) => { + PhysicalPlan::Array(nodedb_physical::physical_plan::ArrayOp::Flush { .. }) + | PhysicalPlan::Array(nodedb_physical::physical_plan::ArrayOp::Compact { .. }) => { PlanKind::SingleDocument } @@ -257,7 +257,7 @@ use PlanKind::DmlResult; /// corrupts the response stream) /// - Any other plan not explicitly listed above pub(super) fn is_calvin_foldable(plan: &PhysicalPlan) -> bool { - use crate::bridge::physical_plan::KvOp; + use nodedb_physical::physical_plan::KvOp; match plan { // Plain point document writes — always affects 1 row, no RETURNING. @@ -289,7 +289,7 @@ pub(super) fn is_calvin_foldable(plan: &PhysicalPlan) -> bool { /// The match arms here are kept in lockstep with that predicate so a desync /// between the two is loud rather than silent. pub(super) fn calvin_tag_for_plan(plan: &PhysicalPlan) -> Tag { - use crate::bridge::physical_plan::KvOp; + use nodedb_physical::physical_plan::KvOp; match plan { PhysicalPlan::Document(DocumentOp::PointPut { .. }) diff --git a/nodedb/src/control/server/pgwire/handler/prepared/parser_schema.rs b/nodedb/src/control/server/pgwire/handler/prepared/parser_schema.rs index c2363235d..b746548cb 100644 --- a/nodedb/src/control/server/pgwire/handler/prepared/parser_schema.rs +++ b/nodedb/src/control/server/pgwire/handler/prepared/parser_schema.rs @@ -233,11 +233,11 @@ pub(super) fn count_placeholders(sql: &str) -> usize { /// projects the RETURNING spec onto it. Returns `None` if the plan isn't a /// recognized DML type or the collection schema cannot be found. pub(super) fn result_fields_for_returning( - spec: &crate::bridge::physical_plan::ReturningSpec, + spec: &nodedb_physical::physical_plan::ReturningSpec, plan: Option<&nodedb_sql::SqlPlan>, catalog: &dyn nodedb_sql::SqlCatalog, ) -> Option> { - use crate::bridge::physical_plan::{ReturningColumns, ReturningItem}; + use nodedb_physical::physical_plan::{ReturningColumns, ReturningItem}; use pgwire::api::results::FieldFormat; let collection = match plan? { diff --git a/nodedb/src/control/server/pgwire/handler/prepared/plan_cache.rs b/nodedb/src/control/server/pgwire/handler/prepared/plan_cache.rs index 1285d147f..405c15c33 100644 --- a/nodedb/src/control/server/pgwire/handler/prepared/plan_cache.rs +++ b/nodedb/src/control/server/pgwire/handler/prepared/plan_cache.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use nodedb_cluster::DescriptorId; use crate::control::planner::descriptor_set::DescriptorVersionSet; -use crate::control::planner::physical::PhysicalTask; +use nodedb_physical::physical_task::PhysicalTask; /// Monotonic schema version counter retained for backwards /// compatibility with metrics and tracing callers that still @@ -155,10 +155,10 @@ fn hash_sql(sql: &str) -> u64 { mod tests { use super::*; use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::MetaOp; - use crate::control::planner::physical::PostSetOp; use crate::types::{TenantId, VShardId}; use nodedb_cluster::DescriptorKind; + use nodedb_physical::physical_plan::MetaOp; + use nodedb_physical::physical_task::PostSetOp; fn dummy_tasks() -> Vec { vec![PhysicalTask { diff --git a/nodedb/src/control/server/pgwire/handler/returning.rs b/nodedb/src/control/server/pgwire/handler/returning.rs index 0ba6793ad..70a8e809a 100644 --- a/nodedb/src/control/server/pgwire/handler/returning.rs +++ b/nodedb/src/control/server/pgwire/handler/returning.rs @@ -9,7 +9,7 @@ //! QueryResponse with one column per projected field. // Re-export bridge types so callers only import from this module. -pub(super) use crate::bridge::physical_plan::{ReturningColumns, ReturningItem, ReturningSpec}; +pub(super) use nodedb_physical::physical_plan::{ReturningColumns, ReturningItem, ReturningSpec}; use crate::Error; diff --git a/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs index c6f27c9d4..1475df990 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs @@ -14,9 +14,9 @@ use crate::control::planner::calvin::{ DispatchOutcome, build_dependent_tx_class, dispatch_calvin_or_fast, dispatch_dependent_read, is_dependent_predicate, predicate_class, }; -use crate::control::planner::physical::PhysicalTask; use crate::control::security::identity::AuthenticatedIdentity; use crate::types::TenantId; +use nodedb_physical::physical_task::PhysicalTask; use super::super::super::types::error_to_sqlstate; use super::super::core::NodeDbPgHandler; diff --git a/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch.rs index d2ae7430b..ea17588e7 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch.rs @@ -16,9 +16,9 @@ use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use crate::control::clone::resolver::{ CloneReadParams, ResolveOutcome, filter_tombstoned_rows, resolve_read, }; -use crate::control::planner::physical::PhysicalTask; use crate::control::server::pgwire::handler::plan::{PlanKind, payload_to_response}; use crate::types::TenantId; +use nodedb_physical::physical_task::PhysicalTask; use super::kv_wrapping::maybe_wrap_kv_point_get; @@ -454,9 +454,9 @@ fn filter_kv_tombstoned_rows( /// `FOR SYSTEM_TIME AS OF `. Returns `None` for plan types that do not /// carry a temporal qualifier (KV, DDL, writes, etc.). fn extract_system_as_of_ms( - plan: Option<&crate::bridge::physical_plan::PhysicalPlan>, + plan: Option<&nodedb_physical::physical_plan::PhysicalPlan>, ) -> Option { - use crate::bridge::physical_plan::PhysicalPlan; + use nodedb_physical::physical_plan::PhysicalPlan; // Exhaustive match — adding a new top-level engine MUST require an // explicit decision here about how `FOR SYSTEM_TIME AS OF` is plumbed // (or that it is intentionally unsupported on that engine). A @@ -486,8 +486,8 @@ fn extract_system_as_of_ms( } } -fn extract_doc_as_of(op: &crate::bridge::physical_plan::DocumentOp) -> Option { - use crate::bridge::physical_plan::DocumentOp; +fn extract_doc_as_of(op: &nodedb_physical::physical_plan::DocumentOp) -> Option { + use nodedb_physical::physical_plan::DocumentOp; match op { DocumentOp::Scan { system_as_of_ms, .. @@ -496,8 +496,8 @@ fn extract_doc_as_of(op: &crate::bridge::physical_plan::DocumentOp) -> Option Option { - use crate::bridge::physical_plan::ColumnarOp; +fn extract_columnar_as_of(op: &nodedb_physical::physical_plan::ColumnarOp) -> Option { + use nodedb_physical::physical_plan::ColumnarOp; match op { ColumnarOp::Scan { system_as_of_ms, .. @@ -506,8 +506,8 @@ fn extract_columnar_as_of(op: &crate::bridge::physical_plan::ColumnarOp) -> Opti } } -fn extract_timeseries_as_of(op: &crate::bridge::physical_plan::TimeseriesOp) -> Option { - use crate::bridge::physical_plan::TimeseriesOp; +fn extract_timeseries_as_of(op: &nodedb_physical::physical_plan::TimeseriesOp) -> Option { + use nodedb_physical::physical_plan::TimeseriesOp; match op { TimeseriesOp::Scan { system_as_of_ms, .. diff --git a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch.rs index 9f4d39980..4d11f7de3 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch.rs @@ -17,16 +17,16 @@ use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use nodedb_types::{CloneStatus, DatabaseId, Lsn, Surrogate, TenantId}; use crate::bridge::envelope::{Priority, Request, Response, Status}; -use crate::bridge::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; use crate::control::clone::copyup::{ CopyUpParams, KvCopyUpParams, perform_clone_copyup, perform_kv_clone_copyup, }; use crate::control::clone::tombstone::{ KvTombstoneParams, TombstoneParams, perform_clone_tombstone, perform_kv_clone_tombstone, }; -use crate::control::planner::physical::PhysicalTask; use crate::control::state::SharedState; use crate::types::{ReadConsistency, RequestId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; +use nodedb_physical::physical_task::PhysicalTask; use super::super::core::NodeDbPgHandler; diff --git a/nodedb/src/control/server/pgwire/handler/routing/execute.rs b/nodedb/src/control/server/pgwire/handler/routing/execute.rs index 31a43fb9c..f547cba32 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/execute.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/execute.rs @@ -7,9 +7,9 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use crate::control::planner::calvin::{DispatchClass, classify_dispatch}; -use crate::control::planner::physical::{PhysicalTask, PostSetOp}; use crate::control::security::identity::AuthenticatedIdentity; use crate::types::TenantId; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; use super::super::super::types::{error_to_sqlstate, response_status_to_sqlstate}; use super::super::core::NodeDbPgHandler; @@ -203,7 +203,7 @@ impl NodeDbPgHandler { // ClusterArray plans are handled entirely on the Control Plane by the // ArrayCoordinator — they must never reach the SPSC bridge or // trigger/DML machinery. Intercept them here and short-circuit. - if let crate::bridge::physical_plan::PhysicalPlan::ClusterArray(ref cluster_op) = + if let nodedb_physical::physical_plan::PhysicalPlan::ClusterArray(ref cluster_op) = task.plan { use crate::control::cluster::ClusterArrayExecutor; @@ -239,7 +239,7 @@ impl NodeDbPgHandler { ))) })?; let cluster_plan_kind = match cluster_op { - crate::bridge::physical_plan::ClusterArrayOp::Slice { .. } => { + nodedb_physical::physical_plan::ClusterArrayOp::Slice { .. } => { PlanKind::ArraySlice } _ => PlanKind::MultiRow, @@ -347,8 +347,8 @@ impl NodeDbPgHandler { // Extract truncate restart_identity info before task is moved. let truncate_restart_collection = - if let crate::bridge::physical_plan::PhysicalPlan::Document( - crate::bridge::physical_plan::DocumentOp::Truncate { + if let nodedb_physical::physical_plan::PhysicalPlan::Document( + nodedb_physical::physical_plan::DocumentOp::Truncate { collection, restart_identity: true, }, diff --git a/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs index cd7aafde2..c44ab359a 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs @@ -15,8 +15,8 @@ use pgwire::api::results::{Response, Tag}; use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use crate::control::gateway::GatewayErrorMap; -use crate::control::planner::physical::PhysicalTask; use crate::types::{ReadConsistency, TenantId, TraceId}; +use nodedb_physical::physical_task::PhysicalTask; use super::super::core::NodeDbPgHandler; use super::super::plan::{PlanKind, payload_to_response}; diff --git a/nodedb/src/control/server/pgwire/handler/routing/kv_wrapping.rs b/nodedb/src/control/server/pgwire/handler/routing/kv_wrapping.rs index 2b8bce802..33388ed38 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/kv_wrapping.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/kv_wrapping.rs @@ -4,7 +4,7 @@ //! stored value map before the pgwire layer turns it into a SQL row. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::KvOp; +use nodedb_physical::physical_plan::KvOp; use nodedb_query::msgpack_scan; /// When `plan` is a KV point-get, turn the engine's stored bytes into diff --git a/nodedb/src/control/server/pgwire/handler/routing/ollp_helpers.rs b/nodedb/src/control/server/pgwire/handler/routing/ollp_helpers.rs index 8cfa24b89..43af31a23 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/ollp_helpers.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/ollp_helpers.rs @@ -5,7 +5,7 @@ //! These helpers are called from `execute_planned_sql_inner` when a //! multi-shard strict transaction contains a value-dependent predicate. -use crate::bridge::physical_plan::{DocumentOp, PhysicalPlan}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; /// Extract the collection name and serialized filter bytes from a /// `BulkUpdate` or `BulkDelete` plan. diff --git a/nodedb/src/control/server/pgwire/handler/routing/planning.rs b/nodedb/src/control/server/pgwire/handler/routing/planning.rs index 188ac0822..271e8035b 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/planning.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/planning.rs @@ -7,9 +7,9 @@ use std::sync::Arc; use pgwire::api::results::Tag; use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; -use crate::control::planner::physical::PhysicalTask; use crate::control::security::identity::AuthenticatedIdentity; use crate::types::TenantId; +use nodedb_physical::physical_task::PhysicalTask; use super::super::super::types::error_to_sqlstate; use super::super::core::NodeDbPgHandler; @@ -223,10 +223,10 @@ pub(super) fn consistency_for_tasks(tasks: &[PhysicalTask]) -> crate::types::Rea /// affected. All other plan variants are left unchanged. pub(super) fn inject_returning_spec( plan: &mut crate::bridge::envelope::PhysicalPlan, - spec: crate::bridge::physical_plan::ReturningSpec, + spec: nodedb_physical::physical_plan::ReturningSpec, ) { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; match plan { PhysicalPlan::Document(DocumentOp::PointUpdate { returning, .. }) => { diff --git a/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs b/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs index 908f4f80c..aa3ef0069 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs @@ -4,7 +4,7 @@ //! //! Operates on raw msgpack payloads — no decode/re-encode round-trip. -use crate::control::planner::physical::PostSetOp; +use nodedb_physical::physical_task::PostSetOp; use super::super::plan::{PlanKind, payload_to_response}; diff --git a/nodedb/src/control/server/pgwire/handler/transaction_cmds.rs b/nodedb/src/control/server/pgwire/handler/transaction_cmds.rs index c208b5aed..9e3cb1a9e 100644 --- a/nodedb/src/control/server/pgwire/handler/transaction_cmds.rs +++ b/nodedb/src/control/server/pgwire/handler/transaction_cmds.rs @@ -126,14 +126,14 @@ impl NodeDbPgHandler { let plans: Vec = buffered.iter().map(|t| t.plan.clone()).collect(); - let batch_task = crate::control::planner::physical::PhysicalTask { + let batch_task = nodedb_physical::physical_task::PhysicalTask { tenant_id, vshard_id, database_id: crate::types::DatabaseId::DEFAULT, plan: crate::bridge::envelope::PhysicalPlan::Meta( - crate::bridge::physical_plan::MetaOp::TransactionBatch { plans }, + nodedb_physical::physical_plan::MetaOp::TransactionBatch { plans }, ), - post_set_op: crate::control::planner::physical::PostSetOp::None, + post_set_op: nodedb_physical::physical_task::PostSetOp::None, }; if let Err(e) = self.dispatch_task_no_wal(batch_task, None).await { tracing::warn!(error = %e, "transaction batch dispatch failed"); @@ -242,16 +242,16 @@ impl NodeDbPgHandler { } for (vshard_u32, plans) in by_vshard { let vshard_id = nodedb_types::id::VShardId::new(vshard_u32); - let batch_task = crate::control::planner::physical::PhysicalTask { + let batch_task = nodedb_physical::physical_task::PhysicalTask { tenant_id, vshard_id, database_id: crate::types::DatabaseId::DEFAULT, plan: crate::bridge::envelope::PhysicalPlan::Meta( - crate::bridge::physical_plan::MetaOp::TransactionBatch { + nodedb_physical::physical_plan::MetaOp::TransactionBatch { plans, }, ), - post_set_op: crate::control::planner::physical::PostSetOp::None, + post_set_op: nodedb_physical::physical_task::PostSetOp::None, }; if let Err(e) = self.dispatch_task_no_wal(batch_task, None).await { tracing::warn!( diff --git a/nodedb/src/control/server/pgwire/pg_catalog/dropped_collections.rs b/nodedb/src/control/server/pgwire/pg_catalog/dropped_collections.rs index f66f5d469..dbc6fcb02 100644 --- a/nodedb/src/control/server/pgwire/pg_catalog/dropped_collections.rs +++ b/nodedb/src/control/server/pgwire/pg_catalog/dropped_collections.rs @@ -125,8 +125,8 @@ async fn query_collection_size( collection: &str, ) -> Option { use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; - use crate::bridge::physical_plan::MetaOp; use crate::types::{DatabaseId, ReadConsistency, TenantId, VShardId}; + use nodedb_physical::physical_plan::MetaOp; let request_id = state.next_request_id(); let timeout = std::time::Duration::from_millis(500); diff --git a/nodedb/src/control/server/pgwire/session/state.rs b/nodedb/src/control/server/pgwire/session/state.rs index bf3dd0ccd..bccf6959b 100644 --- a/nodedb/src/control/server/pgwire/session/state.rs +++ b/nodedb/src/control/server/pgwire/session/state.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; -use crate::control::planner::physical::PhysicalTask; use crate::types::{DatabaseId, Lsn}; +use nodedb_physical::physical_task::PhysicalTask; /// PostgreSQL transaction state for ReadyForQuery status byte. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/nodedb/src/control/server/pgwire/session/store.rs b/nodedb/src/control/server/pgwire/session/store.rs index 3dc824721..7bd482c45 100644 --- a/nodedb/src/control/server/pgwire/session/store.rs +++ b/nodedb/src/control/server/pgwire/session/store.rs @@ -79,7 +79,7 @@ impl SessionStore { sql: &str, current_version: F, ) -> Option<( - Vec, + Vec, crate::control::planner::descriptor_set::DescriptorVersionSet, )> where @@ -98,7 +98,7 @@ impl SessionStore { &self, addr: &SocketAddr, sql: &str, - tasks: Vec, + tasks: Vec, versions: crate::control::planner::descriptor_set::DescriptorVersionSet, ) { let mut sessions = self.sessions.write().unwrap_or_else(|p| p.into_inner()); diff --git a/nodedb/src/control/server/pgwire/session/transaction.rs b/nodedb/src/control/server/pgwire/session/transaction.rs index 9b08ebd2b..8b9b502af 100644 --- a/nodedb/src/control/server/pgwire/session/transaction.rs +++ b/nodedb/src/control/server/pgwire/session/transaction.rs @@ -4,8 +4,8 @@ use std::net::SocketAddr; -use crate::control::planner::physical::PhysicalTask; use crate::types::Lsn; +use nodedb_physical::physical_task::PhysicalTask; use super::state::TransactionState; use super::store::SessionStore; diff --git a/nodedb/src/control/server/resp/handler.rs b/nodedb/src/control/server/resp/handler.rs index c51816ab6..53c9b6d35 100644 --- a/nodedb/src/control/server/resp/handler.rs +++ b/nodedb/src/control/server/resp/handler.rs @@ -5,9 +5,9 @@ use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Status}; -use crate::bridge::physical_plan::KvOp; use crate::control::security::audit::ArcAuditEmitter; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::KvOp; use super::codec::RespValue; use super::command::RespCommand; diff --git a/nodedb/src/control/server/resp/handler_hash.rs b/nodedb/src/control/server/resp/handler_hash.rs index 6990ebefa..39f55531c 100644 --- a/nodedb/src/control/server/resp/handler_hash.rs +++ b/nodedb/src/control/server/resp/handler_hash.rs @@ -5,8 +5,8 @@ use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Status}; -use crate::bridge::physical_plan::KvOp; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::KvOp; use super::codec::RespValue; use super::command::RespCommand; diff --git a/nodedb/src/control/server/resp/handler_kv.rs b/nodedb/src/control/server/resp/handler_kv.rs index 4b0c8966e..855c1bc0c 100644 --- a/nodedb/src/control/server/resp/handler_kv.rs +++ b/nodedb/src/control/server/resp/handler_kv.rs @@ -5,8 +5,8 @@ use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Status}; -use crate::bridge::physical_plan::KvOp; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::KvOp; use super::codec::RespValue; use super::command::RespCommand; diff --git a/nodedb/src/control/server/resp/handler_sorted.rs b/nodedb/src/control/server/resp/handler_sorted.rs index fd0dcf1e3..886a5ae9f 100644 --- a/nodedb/src/control/server/resp/handler_sorted.rs +++ b/nodedb/src/control/server/resp/handler_sorted.rs @@ -10,8 +10,8 @@ use sonic_rs; use crate::bridge::envelope::{PhysicalPlan, Status}; -use crate::bridge::physical_plan::KvOp; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::KvOp; use super::codec::RespValue; use super::command::RespCommand; diff --git a/nodedb/src/control/server/response_translate/vector.rs b/nodedb/src/control/server/response_translate/vector.rs index 140445409..f714c9dda 100644 --- a/nodedb/src/control/server/response_translate/vector.rs +++ b/nodedb/src/control/server/response_translate/vector.rs @@ -19,9 +19,9 @@ use nodedb_types::Surrogate; use serde::{Deserialize, Serialize}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::VectorOp; use crate::bridge::scan_filter::ScanFilter; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::VectorOp; #[derive(Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack)] #[msgpack(map)] diff --git a/nodedb/src/control/server/session.rs b/nodedb/src/control/server/session.rs index 227a0eeb5..b769c929b 100644 --- a/nodedb/src/control/server/session.rs +++ b/nodedb/src/control/server/session.rs @@ -10,9 +10,9 @@ use tokio::net::TcpStream; use tracing::{debug, instrument, warn}; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use crate::bridge::physical_plan::{CrdtOp, DocumentOp, GraphOp, VectorOp}; use crate::control::state::SharedState; use crate::types::{DatabaseId, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, GraphOp, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; /// Maximum frame size: 16 MiB. diff --git a/nodedb/src/control/server/sync/async_dispatch.rs b/nodedb/src/control/server/sync/async_dispatch.rs index bfdf42043..210111398 100644 --- a/nodedb/src/control/server/sync/async_dispatch.rs +++ b/nodedb/src/control/server/sync/async_dispatch.rs @@ -21,9 +21,9 @@ pub(super) async fn handle_shape_subscribe_async( frame: &SyncFrame, ) -> Option { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::DocumentOp; use crate::control::server::pgwire::ddl::sync_dispatch::dispatch_async; use crate::types::TenantId; + use nodedb_physical::physical_plan::DocumentOp; let msg: super::shape::handler::ShapeSubscribeMsg = frame.decode_body()?; let tenant_id = session.tenant_id.map(|t| t.as_u64()).unwrap_or(0); @@ -180,9 +180,9 @@ pub(super) async fn validate_delta_constraints( ack_frame: SyncFrame, ) -> Option { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::CrdtOp; use crate::control::server::pgwire::ddl::sync_dispatch::dispatch_async_with_source; use crate::types::TenantId; + use nodedb_physical::physical_plan::CrdtOp; // Dispatch a CrdtApply plan to the Data Plane. If the CRDT engine // rejects it (constraint violation), we get an error back. diff --git a/nodedb/src/control/server/sync/columnar_handler.rs b/nodedb/src/control/server/sync/columnar_handler.rs index 6ff9d16cf..cccf52a38 100644 --- a/nodedb/src/control/server/sync/columnar_handler.rs +++ b/nodedb/src/control/server/sync/columnar_handler.rs @@ -60,9 +60,9 @@ impl<'a> ColumnarDispatcher for SharedStateColumnarDispatcher<'a> { schema_bytes: Vec, ) -> crate::Result { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::columnar::{ColumnarInsertIntent, ColumnarOp}; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::columnar::{ColumnarInsertIntent, ColumnarOp}; use nodedb_types::columnar::ColumnarSchema; use nodedb_types::value::Value; use std::collections::HashMap; diff --git a/nodedb/src/control/server/sync/fts_handler.rs b/nodedb/src/control/server/sync/fts_handler.rs index db2a2faa2..1b806dbbf 100644 --- a/nodedb/src/control/server/sync/fts_handler.rs +++ b/nodedb/src/control/server/sync/fts_handler.rs @@ -64,9 +64,9 @@ impl<'a> FtsDispatcher for SharedStateFtsDispatcher<'a> { text: String, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::TextOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::TextOp; let plan = PhysicalPlan::Text(TextOp::FtsIndexDoc { collection, @@ -94,9 +94,9 @@ impl<'a> FtsDispatcher for SharedStateFtsDispatcher<'a> { surrogate: Surrogate, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::TextOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::TextOp; let plan = PhysicalPlan::Text(TextOp::FtsDeleteDoc { collection, diff --git a/nodedb/src/control/server/sync/spatial_handler.rs b/nodedb/src/control/server/sync/spatial_handler.rs index 10ff48a06..082d6883a 100644 --- a/nodedb/src/control/server/sync/spatial_handler.rs +++ b/nodedb/src/control/server/sync/spatial_handler.rs @@ -73,9 +73,9 @@ impl<'a> SpatialDispatcher for SharedStateSpatialDispatcher<'a> { geometry: Geometry, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::SpatialOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::SpatialOp; let plan = PhysicalPlan::Spatial(SpatialOp::Insert { collection, @@ -105,9 +105,9 @@ impl<'a> SpatialDispatcher for SharedStateSpatialDispatcher<'a> { surrogate: Surrogate, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::SpatialOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::SpatialOp; let plan = PhysicalPlan::Spatial(SpatialOp::Delete { collection, diff --git a/nodedb/src/control/server/sync/timeseries_handler.rs b/nodedb/src/control/server/sync/timeseries_handler.rs index 734461f4c..29b43aef4 100644 --- a/nodedb/src/control/server/sync/timeseries_handler.rs +++ b/nodedb/src/control/server/sync/timeseries_handler.rs @@ -54,9 +54,9 @@ impl<'a> TimeseriesDispatcher for SharedStateDispatcher<'a> { ilp_payload: String, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::TimeseriesOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::TimeseriesOp; let plan = PhysicalPlan::Timeseries(TimeseriesOp::Ingest { collection, diff --git a/nodedb/src/control/server/sync/vector_handler.rs b/nodedb/src/control/server/sync/vector_handler.rs index 3e75399c7..0a7c995f5 100644 --- a/nodedb/src/control/server/sync/vector_handler.rs +++ b/nodedb/src/control/server/sync/vector_handler.rs @@ -74,9 +74,9 @@ impl<'a> VectorDispatcher for SharedStateVectorDispatcher<'a> { params: VectorInsertParams, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::VectorOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::VectorOp; let plan = PhysicalPlan::Vector(VectorOp::Insert { collection: params.collection, @@ -107,9 +107,9 @@ impl<'a> VectorDispatcher for SharedStateVectorDispatcher<'a> { field_name: String, ) -> crate::Result<()> { use crate::bridge::envelope::PhysicalPlan; - use crate::bridge::physical_plan::VectorOp; use crate::control::server::dispatch_utils::dispatch_to_data_plane_with_source; use crate::event::EventSource; + use nodedb_physical::physical_plan::VectorOp; let plan = PhysicalPlan::Vector(VectorOp::DeleteBySurrogate { collection, diff --git a/nodedb/src/control/server/wal_dispatch.rs b/nodedb/src/control/server/wal_dispatch.rs index 729800cbe..3dde3248d 100644 --- a/nodedb/src/control/server/wal_dispatch.rs +++ b/nodedb/src/control/server/wal_dispatch.rs @@ -6,9 +6,6 @@ //! WAL record type. Read operations are no-ops. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{ - ArrayOp, CrdtOp, DocumentOp, GraphOp, KvOp, TimeseriesOp, VectorOp, -}; use crate::control::security::credential::CredentialStore; use crate::engine::array::wal::{ ArrayDeleteCell, ArrayDeletePayload, ArrayPutPayload, encode_delete_with_version, @@ -16,6 +13,9 @@ use crate::engine::array::wal::{ }; use crate::types::{DatabaseId, TenantId, VShardId}; use crate::wal::manager::WalManager; +use nodedb_physical::physical_plan::{ + ArrayOp, CrdtOp, DocumentOp, GraphOp, KvOp, TimeseriesOp, VectorOp, +}; /// Append a write operation to the WAL for single-node durability. /// @@ -203,7 +203,7 @@ pub fn wal_append_if_write_with_creds( })?; wal.append_vector_params(tenant_id, vshard_id, database_id, &entry)?; } - PhysicalPlan::Columnar(crate::bridge::physical_plan::ColumnarOp::Insert { + PhysicalPlan::Columnar(nodedb_physical::physical_plan::ColumnarOp::Insert { collection, payload, format: _, diff --git a/nodedb/src/control/surrogate/mod.rs b/nodedb/src/control/surrogate/mod.rs index e6dcb875b..bec61420c 100644 --- a/nodedb/src/control/surrogate/mod.rs +++ b/nodedb/src/control/surrogate/mod.rs @@ -10,6 +10,7 @@ pub mod assign; pub mod bootstrap; pub mod persist; +pub mod physical_impl; pub mod registry; pub mod wal_appender; diff --git a/nodedb/src/control/surrogate/physical_impl.rs b/nodedb/src/control/surrogate/physical_impl.rs new file mode 100644 index 000000000..cc9ab0793 --- /dev/null +++ b/nodedb/src/control/surrogate/physical_impl.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Wires Origin's concrete [`SurrogateAssigner`] into the +//! `nodedb_physical::SurrogateAssigner` trait so the shared converter +//! can allocate surrogates without depending on Origin internals. + +use nodedb_physical::{SurrogateAssignError, SurrogateAssigner as PhysicalSurrogateAssigner}; + +use super::assign::SurrogateAssigner; + +impl PhysicalSurrogateAssigner for SurrogateAssigner { + fn current_hwm(&self) -> u32 { + Self::current_hwm(self) + } + + fn assign( + &self, + collection: &str, + pk_bytes: &[u8], + ) -> Result { + Self::assign(self, collection, pk_bytes) + .map_err(|e| SurrogateAssignError::Backend(e.to_string())) + } +} diff --git a/nodedb/src/control/trigger/dml_hook.rs b/nodedb/src/control/trigger/dml_hook.rs index b17195b1b..d38c1dd28 100644 --- a/nodedb/src/control/trigger/dml_hook.rs +++ b/nodedb/src/control/trigger/dml_hook.rs @@ -17,9 +17,9 @@ use std::collections::HashMap; use sonic_rs; -use crate::bridge::physical_plan::DocumentOp; use crate::control::state::SharedState; use crate::types::{TenantId, TraceId}; +use nodedb_physical::physical_plan::DocumentOp; use super::registry::DmlEvent; @@ -221,7 +221,7 @@ fn deserialize_value_to_fields(value: &[u8]) -> HashMap, ) { use crate::bridge::envelope::PhysicalPlan; @@ -252,7 +252,7 @@ pub fn patch_task_with_mutated_fields( nodedb_types::value_to_msgpack(v).ok().map(|b| { ( k.clone(), - crate::bridge::physical_plan::UpdateValue::Literal(b), + nodedb_physical::physical_plan::UpdateValue::Literal(b), ) }) }) diff --git a/nodedb/src/control/wal_catchup.rs b/nodedb/src/control/wal_catchup.rs index ae7833888..d209b51bc 100644 --- a/nodedb/src/control/wal_catchup.rs +++ b/nodedb/src/control/wal_catchup.rs @@ -18,9 +18,9 @@ use std::sync::atomic::Ordering; use tracing::{debug, info}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::TimeseriesOp; use crate::control::state::SharedState; use crate::types::{TenantId, TraceId, VShardId}; +use nodedb_physical::physical_plan::TimeseriesOp; use nodedb_types::Lsn; /// Max WAL records to read per catch-up cycle. Bounds memory to diff --git a/nodedb/src/control/wal_replication/decode.rs b/nodedb/src/control/wal_replication/decode.rs index 926a853ff..900f826f5 100644 --- a/nodedb/src/control/wal_replication/decode.rs +++ b/nodedb/src/control/wal_replication/decode.rs @@ -4,9 +4,9 @@ use super::types::{ReplicatedEntry, ReplicatedWrite}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, VectorOp}; use crate::control::surrogate::SurrogateAssigner; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, VectorOp}; /// /// Returns `None` if the data is not a valid ReplicatedEntry (e.g., ConfChange or no-op). diff --git a/nodedb/src/control/wal_replication/encode.rs b/nodedb/src/control/wal_replication/encode.rs index 4caf84600..8f7cad2eb 100644 --- a/nodedb/src/control/wal_replication/encode.rs +++ b/nodedb/src/control/wal_replication/encode.rs @@ -4,8 +4,8 @@ use super::types::{ReplicatedEntry, ReplicatedWrite}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, VectorOp}; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, VectorOp}; pub fn to_replicated_entry( tenant_id: TenantId, diff --git a/nodedb/src/control/wal_replication/tests.rs b/nodedb/src/control/wal_replication/tests.rs index 5708220f0..0428659d6 100644 --- a/nodedb/src/control/wal_replication/tests.rs +++ b/nodedb/src/control/wal_replication/tests.rs @@ -2,8 +2,8 @@ use super::*; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::DocumentOp; use crate::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::DocumentOp; #[test] fn replicated_entry_roundtrip() { diff --git a/nodedb/src/control/wal_replication/types.rs b/nodedb/src/control/wal_replication/types.rs index c447a10c1..9888f55c2 100644 --- a/nodedb/src/control/wal_replication/types.rs +++ b/nodedb/src/control/wal_replication/types.rs @@ -79,7 +79,7 @@ pub enum ReplicatedWrite { PointUpdate { collection: String, document_id: String, - updates: Vec<(String, crate::bridge::physical_plan::UpdateValue)>, + updates: Vec<(String, nodedb_physical::physical_plan::UpdateValue)>, }, VectorInsert { collection: String, diff --git a/nodedb/src/data/executor/core_loop/accessors.rs b/nodedb/src/data/executor/core_loop/accessors.rs index cca9326d8..304659a0f 100644 --- a/nodedb/src/data/executor/core_loop/accessors.rs +++ b/nodedb/src/data/executor/core_loop/accessors.rs @@ -133,8 +133,8 @@ impl CoreLoop { let key = (TenantId::new(tid), collection.to_string()); let config = self.doc_configs.get(&key)?; match &config.storage_mode { - crate::bridge::physical_plan::StorageMode::Strict { schema } => Some(schema.version), - crate::bridge::physical_plan::StorageMode::Schemaless => None, + nodedb_physical::physical_plan::StorageMode::Strict { schema } => Some(schema.version), + nodedb_physical::physical_plan::StorageMode::Schemaless => None, } } diff --git a/nodedb/src/data/executor/core_loop/event_emit.rs b/nodedb/src/data/executor/core_loop/event_emit.rs index b16a5d5cb..b03ffe192 100644 --- a/nodedb/src/data/executor/core_loop/event_emit.rs +++ b/nodedb/src/data/executor/core_loop/event_emit.rs @@ -19,7 +19,7 @@ impl CoreLoop { ) -> Option> { let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let config = self.doc_configs.get(&config_key)?; - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = config.storage_mode { crate::data::executor::strict_format::binary_tuple_to_msgpack(stored_bytes, schema) diff --git a/nodedb/src/data/executor/core_loop/pressure.rs b/nodedb/src/data/executor/core_loop/pressure.rs index 41980cc98..1b3414f4d 100644 --- a/nodedb/src/data/executor/core_loop/pressure.rs +++ b/nodedb/src/data/executor/core_loop/pressure.rs @@ -156,10 +156,10 @@ mod tests { use super::*; use crate::bridge::envelope::{ErrorCode, PhysicalPlan, Priority, Request}; - use crate::bridge::physical_plan::VectorOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; use crate::types::*; + use nodedb_physical::physical_plan::VectorOp; use nodedb_types::Surrogate; fn make_core() -> ( diff --git a/nodedb/src/data/executor/core_loop/priority_queues.rs b/nodedb/src/data/executor/core_loop/priority_queues.rs index b82ce9c7e..56c736e53 100644 --- a/nodedb/src/data/executor/core_loop/priority_queues.rs +++ b/nodedb/src/data/executor/core_loop/priority_queues.rs @@ -248,11 +248,11 @@ mod tests { use super::*; use crate::bridge::envelope::{Priority, Request}; - use crate::bridge::physical_plan::PhysicalPlan; - use crate::bridge::physical_plan::meta::MetaOp; use crate::data::executor::task::ExecutionTask; use crate::event::EventSource; use crate::types::{DatabaseId, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; + use nodedb_physical::physical_plan::PhysicalPlan; + use nodedb_physical::physical_plan::meta::MetaOp; fn make_task(priority: Priority) -> ExecutionTask { ExecutionTask::new(Request { diff --git a/nodedb/src/data/executor/core_loop/tests.rs b/nodedb/src/data/executor/core_loop/tests.rs index 52489b6b2..c8824921a 100644 --- a/nodedb/src/data/executor/core_loop/tests.rs +++ b/nodedb/src/data/executor/core_loop/tests.rs @@ -3,9 +3,9 @@ use super::*; use crate::bridge::dispatch::{BridgeRequest, BridgeResponse}; use crate::bridge::envelope::{ErrorCode, PhysicalPlan, Priority, Request, Status}; -use crate::bridge::physical_plan::{DocumentOp, MetaOp}; use crate::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{DocumentOp, MetaOp}; use nodedb_types::{Surrogate, SurrogateBitmap}; use std::time::{Duration, Instant}; diff --git a/nodedb/src/data/executor/dispatch/array/aggregate.rs b/nodedb/src/data/executor/dispatch/array/aggregate.rs index 76997101c..a6b92e9f1 100644 --- a/nodedb/src/data/executor/dispatch/array/aggregate.rs +++ b/nodedb/src/data/executor/dispatch/array/aggregate.rs @@ -18,9 +18,9 @@ use nodedb_cluster::distributed_array::merge::ArrayAggPartial; use nodedb_types::SurrogateBitmap; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::ArrayReducer; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::ArrayReducer; use super::aggregate_helpers::{ AggCell, agg_result_to_partial, apply_surrogate_filter, coord_to_agg_cell, coord_to_group_key, diff --git a/nodedb/src/data/executor/dispatch/array/aggregate_helpers.rs b/nodedb/src/data/executor/dispatch/array/aggregate_helpers.rs index 701f1c0ee..8ef276239 100644 --- a/nodedb/src/data/executor/dispatch/array/aggregate_helpers.rs +++ b/nodedb/src/data/executor/dispatch/array/aggregate_helpers.rs @@ -11,9 +11,9 @@ use nodedb_cluster::distributed_array::merge::ArrayAggPartial; use nodedb_types::SurrogateBitmap; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::ArrayReducer; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::ArrayReducer; /// Standard-msgpack-friendly cell value for aggregate rows. pub(super) enum AggCell { diff --git a/nodedb/src/data/executor/dispatch/array/elementwise.rs b/nodedb/src/data/executor/dispatch/array/elementwise.rs index 982f1fd62..0d1579a92 100644 --- a/nodedb/src/data/executor/dispatch/array/elementwise.rs +++ b/nodedb/src/data/executor/dispatch/array/elementwise.rs @@ -16,9 +16,9 @@ use nodedb_array::types::ArrayId; use nodedb_types::{SurrogateBitmap, Value}; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::ArrayBinaryOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::ArrayBinaryOp; use super::convert::sparse_tile_to_array_cells; use super::encode::encode_value_rows; diff --git a/nodedb/src/data/executor/dispatch/array/entry.rs b/nodedb/src/data/executor/dispatch/array/entry.rs index b47de706e..0659f7b49 100644 --- a/nodedb/src/data/executor/dispatch/array/entry.rs +++ b/nodedb/src/data/executor/dispatch/array/entry.rs @@ -8,8 +8,8 @@ use nodedb_array::schema::ArraySchema; use nodedb_array::types::ArrayId; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::ArrayOp; use nodedb_mem; +use nodedb_physical::physical_plan::ArrayOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::dispatch::array::aggregate::AggParams; diff --git a/nodedb/src/data/executor/dispatch/array/mutate.rs b/nodedb/src/data/executor/dispatch/array/mutate.rs index c1f371837..f4e64e22f 100644 --- a/nodedb/src/data/executor/dispatch/array/mutate.rs +++ b/nodedb/src/data/executor/dispatch/array/mutate.rs @@ -168,10 +168,10 @@ mod tests { use crate::bridge::dispatch::{BridgeRequest, BridgeResponse}; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; - use crate::bridge::physical_plan::ArrayOp; use crate::data::executor::core_loop::CoreLoop; use crate::engine::array::wal::ArrayPutCell; use crate::types::*; + use nodedb_physical::physical_plan::ArrayOp; fn make_request(plan: PhysicalPlan, id: u64) -> Request { Request { diff --git a/nodedb/src/data/executor/dispatch/array/tests_dispatch.rs b/nodedb/src/data/executor/dispatch/array/tests_dispatch.rs index be9c274ae..c9d4470a2 100644 --- a/nodedb/src/data/executor/dispatch/array/tests_dispatch.rs +++ b/nodedb/src/data/executor/dispatch/array/tests_dispatch.rs @@ -27,10 +27,10 @@ use nodedb_types::{Surrogate, SurrogateBitmap}; use crate::bridge::dispatch::{BridgeRequest, BridgeResponse}; use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use crate::bridge::physical_plan::{ArrayBinaryOp, ArrayOp, ArrayReducer}; use crate::data::executor::core_loop::CoreLoop; use crate::engine::array::wal::ArrayPutCell; use crate::types::*; +use nodedb_physical::physical_plan::{ArrayBinaryOp, ArrayOp, ArrayReducer}; fn make_request(plan: PhysicalPlan, id: u64) -> Request { Request { @@ -561,7 +561,7 @@ fn elementwise_schema_hash_mismatch_errors() { #[test] fn vector_search_with_array_surrogate_prefilter() { - use crate::bridge::physical_plan::VectorOp; + use nodedb_physical::physical_plan::VectorOp; use nodedb_types::vector_distance::DistanceMetric; // 2D array tiling chr × pos, cells bound to surrogates 1..=10. diff --git a/nodedb/src/data/executor/dispatch/bitmap/hashjoin_inline.rs b/nodedb/src/data/executor/dispatch/bitmap/hashjoin_inline.rs index 7db1a43ae..1af350931 100644 --- a/nodedb/src/data/executor/dispatch/bitmap/hashjoin_inline.rs +++ b/nodedb/src/data/executor/dispatch/bitmap/hashjoin_inline.rs @@ -16,9 +16,9 @@ use nodedb_types::SurrogateBitmap; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::DocumentOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::DocumentOp; use super::materialize::collect_surrogates; diff --git a/nodedb/src/data/executor/dispatch/columnar.rs b/nodedb/src/data/executor/dispatch/columnar.rs index 8fcf38391..91d9011ad 100644 --- a/nodedb/src/data/executor/dispatch/columnar.rs +++ b/nodedb/src/data/executor/dispatch/columnar.rs @@ -3,7 +3,7 @@ //! Dispatch for ColumnarOp variants (scan, insert, update, delete). use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::ColumnarOp; +use nodedb_physical::physical_plan::ColumnarOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::handlers::columnar_read::ColumnarScanParams; diff --git a/nodedb/src/data/executor/dispatch/crdt.rs b/nodedb/src/data/executor/dispatch/crdt.rs index 7ffeb3776..fd7d5dd27 100644 --- a/nodedb/src/data/executor/dispatch/crdt.rs +++ b/nodedb/src/data/executor/dispatch/crdt.rs @@ -3,7 +3,7 @@ //! CRDT operation dispatch. use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::CrdtOp; +use nodedb_physical::physical_plan::CrdtOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/document.rs b/nodedb/src/data/executor/dispatch/document.rs index b0e5060ee..fb36ad415 100644 --- a/nodedb/src/data/executor/dispatch/document.rs +++ b/nodedb/src/data/executor/dispatch/document.rs @@ -3,8 +3,8 @@ //! Document operation dispatch. use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::DocumentOp; use nodedb_mem; +use nodedb_physical::physical_plan::DocumentOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/graph.rs b/nodedb/src/data/executor/dispatch/graph.rs index 76be61c66..5b3779b77 100644 --- a/nodedb/src/data/executor/dispatch/graph.rs +++ b/nodedb/src/data/executor/dispatch/graph.rs @@ -3,8 +3,8 @@ //! Graph operation dispatch. use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::GraphOp; use nodedb_mem; +use nodedb_physical::physical_plan::GraphOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/kv.rs b/nodedb/src/data/executor/dispatch/kv.rs index 37fea77d7..a33ef3f50 100644 --- a/nodedb/src/data/executor/dispatch/kv.rs +++ b/nodedb/src/data/executor/dispatch/kv.rs @@ -3,7 +3,7 @@ //! Dispatch for KvOp variants (engine pressure check + delegation to execute_kv). use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::KvOp; +use nodedb_physical::physical_plan::KvOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/meta.rs b/nodedb/src/data/executor/dispatch/meta.rs index 689c33c2d..92ae6a984 100644 --- a/nodedb/src/data/executor/dispatch/meta.rs +++ b/nodedb/src/data/executor/dispatch/meta.rs @@ -3,7 +3,7 @@ //! Dispatch for MetaOp variants (WAL, snapshots, retention, continuous aggregates). use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::MetaOp; +use nodedb_physical::physical_plan::MetaOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::response_codec; diff --git a/nodedb/src/data/executor/dispatch/meta_retention/handlers.rs b/nodedb/src/data/executor/dispatch/meta_retention/handlers.rs index 1f7c21db2..0b2fe1606 100644 --- a/nodedb/src/data/executor/dispatch/meta_retention/handlers.rs +++ b/nodedb/src/data/executor/dispatch/meta_retention/handlers.rs @@ -15,10 +15,10 @@ use nodedb_types::TenantId; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::MetaOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::response_codec; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::MetaOp; impl CoreLoop { /// Shared entry point for every `MetaOp::TemporalPurge*` variant. diff --git a/nodedb/src/data/executor/dispatch/mod.rs b/nodedb/src/data/executor/dispatch/mod.rs index d9b9a3034..9698cc296 100644 --- a/nodedb/src/data/executor/dispatch/mod.rs +++ b/nodedb/src/data/executor/dispatch/mod.rs @@ -17,9 +17,10 @@ pub mod spatial; pub mod text; pub mod timeseries; pub mod vector; +pub mod visitor; use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::PhysicalPlan; use super::core_loop::CoreLoop; use super::task::ExecutionTask; @@ -40,25 +41,14 @@ impl CoreLoop { // Record the tenant → database association so maintenance budget // tracking can resolve per-database caps when iterating collections. self.record_tenant_database(task.request.tenant_id, task.request.database_id); - match plan { - PhysicalPlan::Document(op) => self.dispatch_document(task, op), - PhysicalPlan::Vector(op) => self.dispatch_vector(task, op), - PhysicalPlan::Crdt(op) => self.dispatch_crdt(task, op), - PhysicalPlan::Graph(op) => self.dispatch_graph(task, op), - PhysicalPlan::Text(op) => self.dispatch_text(task, op), - PhysicalPlan::Array(op) => self.dispatch_array(task, op), - PhysicalPlan::Query(op) => self.dispatch_query(task, tid, op), - PhysicalPlan::Meta(op) => self.dispatch_meta(task, tid, op), - PhysicalPlan::Columnar(op) => self.dispatch_columnar(task, op), - PhysicalPlan::Timeseries(op) => self.dispatch_timeseries(task, op), - PhysicalPlan::Spatial(op) => self.dispatch_spatial(task, tid, op), - PhysicalPlan::Kv(op) => self.dispatch_kv(task, tid, op), - - // ClusterArray variants are handled exclusively on the Control Plane. - // They must never reach the Data Plane dispatcher. - PhysicalPlan::ClusterArray(_) => { - unreachable!("ClusterArray plans must not be dispatched to the Data Plane") - } + let mut v = visitor::DataPlaneVisitor { + core_loop: self, + task, + tid, + }; + match nodedb_physical::dispatch(&mut v, plan) { + Ok(response) => response, + Err(never) => match never {}, } } } diff --git a/nodedb/src/data/executor/dispatch/query.rs b/nodedb/src/data/executor/dispatch/query.rs index 7b0d33f9f..04cb3776e 100644 --- a/nodedb/src/data/executor/dispatch/query.rs +++ b/nodedb/src/data/executor/dispatch/query.rs @@ -3,7 +3,7 @@ //! Dispatch for QueryOp variants (aggregates, joins, recursive scans, facets). use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::QueryOp; +use nodedb_physical::physical_plan::QueryOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::handlers::join::{ diff --git a/nodedb/src/data/executor/dispatch/spatial.rs b/nodedb/src/data/executor/dispatch/spatial.rs index 43adee55d..744b24501 100644 --- a/nodedb/src/data/executor/dispatch/spatial.rs +++ b/nodedb/src/data/executor/dispatch/spatial.rs @@ -3,7 +3,7 @@ //! Dispatch for SpatialOp variants (scan, insert, delete). use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::SpatialOp; +use nodedb_physical::physical_plan::SpatialOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/text.rs b/nodedb/src/data/executor/dispatch/text.rs index 625239aeb..30d2be5f1 100644 --- a/nodedb/src/data/executor/dispatch/text.rs +++ b/nodedb/src/data/executor/dispatch/text.rs @@ -3,7 +3,7 @@ //! Text (FTS) operation dispatch. use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::TextOp; +use nodedb_physical::physical_plan::TextOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/timeseries.rs b/nodedb/src/data/executor/dispatch/timeseries.rs index f8c0bdee6..39b89d8c3 100644 --- a/nodedb/src/data/executor/dispatch/timeseries.rs +++ b/nodedb/src/data/executor/dispatch/timeseries.rs @@ -3,7 +3,7 @@ //! Dispatch for TimeseriesOp variants (scan, ingest). use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::TimeseriesOp; +use nodedb_physical::physical_plan::TimeseriesOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::handlers::timeseries::TimeseriesScanParams; diff --git a/nodedb/src/data/executor/dispatch/vector.rs b/nodedb/src/data/executor/dispatch/vector.rs index 00e8e5712..512d5940c 100644 --- a/nodedb/src/data/executor/dispatch/vector.rs +++ b/nodedb/src/data/executor/dispatch/vector.rs @@ -3,8 +3,8 @@ //! Vector operation dispatch. use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::VectorOp; use nodedb_mem; +use nodedb_physical::physical_plan::VectorOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; diff --git a/nodedb/src/data/executor/dispatch/visitor.rs b/nodedb/src/data/executor/dispatch/visitor.rs new file mode 100644 index 000000000..fb43f5a21 --- /dev/null +++ b/nodedb/src/data/executor/dispatch/visitor.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! `PhysicalTaskVisitor` adapter for the Data Plane `CoreLoop`. +//! +//! Bridges `nodedb_physical::dispatch` to the per-engine `dispatch_*` methods +//! on `CoreLoop`. Each trait method is a one-liner; all routing logic lives +//! in the individual sub-dispatchers. + +use crate::bridge::envelope::Response; +use crate::data::executor::core_loop::CoreLoop; +use crate::data::executor::task::ExecutionTask; +use nodedb_physical::PhysicalTaskVisitor; +use nodedb_physical::physical_plan::{ + ArrayOp, ClusterArrayOp, ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, QueryOp, + SpatialOp, TextOp, TimeseriesOp, VectorOp, +}; + +/// Adapter that implements [`PhysicalTaskVisitor`] for the Data Plane. +/// +/// Holds mutable access to the `CoreLoop` plus the per-request context +/// needed by every handler: the `ExecutionTask` and the pre-extracted +/// tenant id (`tid`). +pub(super) struct DataPlaneVisitor<'a, 'b> { + pub(super) core_loop: &'a mut CoreLoop, + pub(super) task: &'b ExecutionTask, + pub(super) tid: u64, +} + +impl<'a, 'b> PhysicalTaskVisitor for DataPlaneVisitor<'a, 'b> { + type Output = Response; + type Error = std::convert::Infallible; + + fn document(&mut self, op: &DocumentOp) -> Result { + Ok(self.core_loop.dispatch_document(self.task, op)) + } + + fn vector(&mut self, op: &VectorOp) -> Result { + Ok(self.core_loop.dispatch_vector(self.task, op)) + } + + fn crdt(&mut self, op: &CrdtOp) -> Result { + Ok(self.core_loop.dispatch_crdt(self.task, op)) + } + + fn graph(&mut self, op: &GraphOp) -> Result { + Ok(self.core_loop.dispatch_graph(self.task, op)) + } + + fn text(&mut self, op: &TextOp) -> Result { + Ok(self.core_loop.dispatch_text(self.task, op)) + } + + fn array(&mut self, op: &ArrayOp) -> Result { + Ok(self.core_loop.dispatch_array(self.task, op)) + } + + fn query(&mut self, op: &QueryOp) -> Result { + Ok(self.core_loop.dispatch_query(self.task, self.tid, op)) + } + + fn meta(&mut self, op: &MetaOp) -> Result { + Ok(self.core_loop.dispatch_meta(self.task, self.tid, op)) + } + + fn columnar(&mut self, op: &ColumnarOp) -> Result { + Ok(self.core_loop.dispatch_columnar(self.task, op)) + } + + fn timeseries(&mut self, op: &TimeseriesOp) -> Result { + Ok(self.core_loop.dispatch_timeseries(self.task, op)) + } + + fn spatial(&mut self, op: &SpatialOp) -> Result { + Ok(self.core_loop.dispatch_spatial(self.task, self.tid, op)) + } + + fn kv(&mut self, op: &KvOp) -> Result { + Ok(self.core_loop.dispatch_kv(self.task, self.tid, op)) + } + + fn cluster_array(&mut self, _op: &ClusterArrayOp) -> Result { + unreachable!("ClusterArray plans must not be dispatched to the Data Plane") + } +} diff --git a/nodedb/src/data/executor/enforcement/append_only.rs b/nodedb/src/data/executor/enforcement/append_only.rs index 67341104f..89f9284e5 100644 --- a/nodedb/src/data/executor/enforcement/append_only.rs +++ b/nodedb/src/data/executor/enforcement/append_only.rs @@ -3,7 +3,7 @@ //! Append-only enforcement: reject UPDATE and DELETE on append-only collections. use crate::bridge::envelope::ErrorCode; -use crate::bridge::physical_plan::EnforcementOptions; +use nodedb_physical::physical_plan::EnforcementOptions; /// Check whether an UPDATE is allowed on this collection. /// diff --git a/nodedb/src/data/executor/enforcement/balanced.rs b/nodedb/src/data/executor/enforcement/balanced.rs index 48947712f..b3bce1acf 100644 --- a/nodedb/src/data/executor/enforcement/balanced.rs +++ b/nodedb/src/data/executor/enforcement/balanced.rs @@ -11,7 +11,7 @@ use rust_decimal::Decimal; use std::collections::HashMap; use crate::bridge::envelope::ErrorCode; -use crate::bridge::physical_plan::BalancedDef; +use nodedb_physical::physical_plan::BalancedDef; /// A single insert tracked for balance validation. pub struct InsertEntry { diff --git a/nodedb/src/data/executor/enforcement/materialized_sum.rs b/nodedb/src/data/executor/enforcement/materialized_sum.rs index bbaf5dc5e..652b57266 100644 --- a/nodedb/src/data/executor/enforcement/materialized_sum.rs +++ b/nodedb/src/data/executor/enforcement/materialized_sum.rs @@ -12,8 +12,8 @@ use rust_decimal::Decimal; use crate::bridge::envelope::ErrorCode; -use crate::bridge::physical_plan::MaterializedSumBinding; use crate::engine::sparse::btree::SparseEngine; +use nodedb_physical::physical_plan::MaterializedSumBinding; /// Apply materialized sum updates for all bindings on a source INSERT. /// diff --git a/nodedb/src/data/executor/enforcement/period_lock.rs b/nodedb/src/data/executor/enforcement/period_lock.rs index fcdc5c1a7..be627ffb3 100644 --- a/nodedb/src/data/executor/enforcement/period_lock.rs +++ b/nodedb/src/data/executor/enforcement/period_lock.rs @@ -9,8 +9,8 @@ use sonic_rs; use crate::bridge::envelope::ErrorCode; -use crate::bridge::physical_plan::PeriodLockConfig; use crate::engine::sparse::btree::SparseEngine; +use nodedb_physical::physical_plan::PeriodLockConfig; /// Check whether a write is allowed given the period lock configuration. /// diff --git a/nodedb/src/data/executor/enforcement/retention.rs b/nodedb/src/data/executor/enforcement/retention.rs index 8589640b1..482574994 100644 --- a/nodedb/src/data/executor/enforcement/retention.rs +++ b/nodedb/src/data/executor/enforcement/retention.rs @@ -6,7 +6,7 @@ use sonic_rs; use crate::bridge::envelope::ErrorCode; -use crate::bridge::physical_plan::EnforcementOptions; +use nodedb_physical::physical_plan::EnforcementOptions; /// Check whether a DELETE is allowed given retention and legal hold policies. /// @@ -49,45 +49,7 @@ pub fn check_delete_allowed( Ok(()) } -/// Parsed retention duration with calendar-accurate units. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - serde::Serialize, - serde::Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub struct RetentionDuration { - pub count: u32, - pub unit: RetentionUnit, -} - -/// Calendar-accurate duration units. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - serde::Serialize, - serde::Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -#[msgpack(c_enum)] -pub enum RetentionUnit { - Seconds, - Minutes, - Hours, - Days, - Weeks, - Months, - Years, -} +pub use nodedb_physical::physical_plan::document::{RetentionDuration, RetentionUnit}; /// Parse a human-readable retention period string. /// diff --git a/nodedb/src/data/executor/handlers/accum/feed.rs b/nodedb/src/data/executor/handlers/accum/feed.rs index 585598869..b4f4c5341 100644 --- a/nodedb/src/data/executor/handlers/accum/feed.rs +++ b/nodedb/src/data/executor/handlers/accum/feed.rs @@ -5,7 +5,7 @@ use std::collections::hash_map::Entry; use super::state::{ARRAY_AGG_CAP, AggAccum}; -use crate::bridge::physical_plan::AggregateSpec; +use nodedb_physical::physical_plan::AggregateSpec; impl AggAccum { /// Feed one document into this accumulator. diff --git a/nodedb/src/data/executor/handlers/accum/finalize.rs b/nodedb/src/data/executor/handlers/accum/finalize.rs index b9f79e3d5..5e7505cf1 100644 --- a/nodedb/src/data/executor/handlers/accum/finalize.rs +++ b/nodedb/src/data/executor/handlers/accum/finalize.rs @@ -3,7 +3,7 @@ //! `AggAccum::finalize` — consume the accumulator and produce the result `Value`. use super::state::AggAccum; -use crate::bridge::physical_plan::AggregateSpec; +use nodedb_physical::physical_plan::AggregateSpec; use nodedb_types::Value; impl AggAccum { diff --git a/nodedb/src/data/executor/handlers/accum/new.rs b/nodedb/src/data/executor/handlers/accum/new.rs index dafa826ab..65a966bf8 100644 --- a/nodedb/src/data/executor/handlers/accum/new.rs +++ b/nodedb/src/data/executor/handlers/accum/new.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use super::state::AggAccum; -use crate::bridge::physical_plan::AggregateSpec; +use nodedb_physical::physical_plan::AggregateSpec; impl AggAccum { pub(crate) fn new(agg: &AggregateSpec) -> Self { diff --git a/nodedb/src/data/executor/handlers/accum/state.rs b/nodedb/src/data/executor/handlers/accum/state.rs index 7e4d447ef..e22fd4b17 100644 --- a/nodedb/src/data/executor/handlers/accum/state.rs +++ b/nodedb/src/data/executor/handlers/accum/state.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet}; -use crate::bridge::physical_plan::AggregateSpec; +use nodedb_physical::physical_plan::AggregateSpec; use nodedb_types::Value; /// Maximum items collected by materializing aggregates (`array_agg`, diff --git a/nodedb/src/data/executor/handlers/accum/tests.rs b/nodedb/src/data/executor/handlers/accum/tests.rs index 361d09e03..714288abe 100644 --- a/nodedb/src/data/executor/handlers/accum/tests.rs +++ b/nodedb/src/data/executor/handlers/accum/tests.rs @@ -3,7 +3,7 @@ //! Round-trip tests: feed → finalize, and feed → split → merge → finalize. use super::state::AggAccum; -use crate::bridge::physical_plan::AggregateSpec; +use nodedb_physical::physical_plan::AggregateSpec; use nodedb_types::Value; fn make_spec(func: &str, field: &str) -> AggregateSpec { diff --git a/nodedb/src/data/executor/handlers/aggregate.rs b/nodedb/src/data/executor/handlers/aggregate.rs index b94a89aa5..9f978724f 100644 --- a/nodedb/src/data/executor/handlers/aggregate.rs +++ b/nodedb/src/data/executor/handlers/aggregate.rs @@ -16,10 +16,10 @@ use tracing::debug; use super::accum::GroupState; use super::spill::groupby::GroupBySpiller; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::AggregateSpec; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::AggregateSpec; use nodedb_query::agg_key::canonical_agg_key; use nodedb_query::msgpack_scan; diff --git a/nodedb/src/data/executor/handlers/bulk_dml.rs b/nodedb/src/data/executor/handlers/bulk_dml.rs index d7bad6f83..9c5a690f0 100644 --- a/nodedb/src/data/executor/handlers/bulk_dml.rs +++ b/nodedb/src/data/executor/handlers/bulk_dml.rs @@ -8,13 +8,13 @@ use tracing::{debug, warn}; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::ReturningSpec; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::doc_format; use crate::data::executor::handlers::returning_rows; use crate::data::executor::response_codec; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::ReturningSpec; impl CoreLoop { /// Scan documents in a collection matching the given filters. @@ -47,7 +47,8 @@ impl CoreLoop { // Check if this is a strict (Binary Tuple) collection. let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { @@ -85,7 +86,7 @@ impl CoreLoop { pub(in crate::data::executor) struct BulkUpdateParams<'a> { pub collection: &'a str, pub filter_bytes: &'a [u8], - pub updates: &'a [(String, crate::bridge::physical_plan::UpdateValue)], + pub updates: &'a [(String, nodedb_physical::physical_plan::UpdateValue)], pub returning: Option<&'a ReturningSpec>, pub ollp_predicted_surrogates: Option<&'a [u32]>, } @@ -169,7 +170,8 @@ impl CoreLoop { // Check if this is a strict (Binary Tuple) collection. let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { @@ -211,13 +213,13 @@ impl CoreLoop { if let Some(obj) = doc.as_object_mut() { for (field, update_val) in updates { let val: serde_json::Value = match update_val { - crate::bridge::physical_plan::UpdateValue::Literal(bytes) => { + nodedb_physical::physical_plan::UpdateValue::Literal(bytes) => { match nodedb_types::json_from_msgpack(bytes) { Ok(v) => v, Err(_) => continue, } } - crate::bridge::physical_plan::UpdateValue::Expr(expr) => { + nodedb_physical::physical_plan::UpdateValue::Expr(expr) => { let result: nodedb_types::Value = expr.eval(&eval_doc); result.into() } diff --git a/nodedb/src/data/executor/handlers/columnar_write/insert.rs b/nodedb/src/data/executor/handlers/columnar_write/insert.rs index 177fcaf10..2135a620c 100644 --- a/nodedb/src/data/executor/handlers/columnar_write/insert.rs +++ b/nodedb/src/data/executor/handlers/columnar_write/insert.rs @@ -9,12 +9,12 @@ use nodedb_types::surrogate::Surrogate; use nodedb_types::value::Value; use crate::bridge::envelope::{ErrorCode, Payload, Response, Status}; -use crate::bridge::physical_plan::ColumnarInsertIntent; -use crate::bridge::physical_plan::document::UpdateValue; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::handlers::upsert::apply_on_conflict_updates; use crate::data::executor::response_codec; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::ColumnarInsertIntent; +use nodedb_physical::physical_plan::document::UpdateValue; use super::schema::{ infer_schema_from_value, ndb_field_to_value, prepend_bitemporal_columns, row_values_to_object, diff --git a/nodedb/src/data/executor/handlers/control/calvin.rs b/nodedb/src/data/executor/handlers/control/calvin.rs index c13fea4d3..24b28729f 100644 --- a/nodedb/src/data/executor/handlers/control/calvin.rs +++ b/nodedb/src/data/executor/handlers/control/calvin.rs @@ -30,12 +30,12 @@ use nodedb_cluster::calvin::types::PassiveReadKey; use nodedb_types::Value; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::PhysicalPlan; -use crate::bridge::physical_plan::meta::PassiveReadKeyId; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::response_codec; use crate::data::executor::task::ExecutionTask; use crate::types::TenantId; +use nodedb_physical::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::meta::PassiveReadKeyId; use std::collections::BTreeMap; diff --git a/nodedb/src/data/executor/handlers/document/read/scan.rs b/nodedb/src/data/executor/handlers/document/read/scan.rs index 05a0eab08..f2fca4342 100644 --- a/nodedb/src/data/executor/handlers/document/read/scan.rs +++ b/nodedb/src/data/executor/handlers/document/read/scan.rs @@ -81,7 +81,8 @@ impl CoreLoop { let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/document/write.rs b/nodedb/src/data/executor/handlers/document/write.rs index e845f4a11..c295b5358 100644 --- a/nodedb/src/data/executor/handlers/document/write.rs +++ b/nodedb/src/data/executor/handlers/document/write.rs @@ -118,15 +118,15 @@ impl CoreLoop { task: &ExecutionTask, tid: u64, collection: &str, - indexes: &[crate::bridge::physical_plan::RegisteredIndex], + indexes: &[nodedb_physical::physical_plan::RegisteredIndex], crdt_enabled: bool, - storage_mode: &crate::bridge::physical_plan::StorageMode, - enforcement: &crate::bridge::physical_plan::EnforcementOptions, + storage_mode: &nodedb_physical::physical_plan::StorageMode, + enforcement: &nodedb_physical::physical_plan::EnforcementOptions, bitemporal: bool, ) -> Response { let mode_label = match storage_mode { - crate::bridge::physical_plan::StorageMode::Schemaless => "document_schemaless", - crate::bridge::physical_plan::StorageMode::Strict { .. } => "document_strict", + nodedb_physical::physical_plan::StorageMode::Schemaless => "document_schemaless", + nodedb_physical::physical_plan::StorageMode::Strict { .. } => "document_strict", }; debug!( core = self.core_id, @@ -252,7 +252,8 @@ impl CoreLoop { // result framing (encode_raw_document_rows) sees valid msgpack. let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/generated.rs b/nodedb/src/data/executor/handlers/generated.rs index c3412d94c..3de03264e 100644 --- a/nodedb/src/data/executor/handlers/generated.rs +++ b/nodedb/src/data/executor/handlers/generated.rs @@ -6,7 +6,7 @@ //! Handles topological ordering when generated columns depend on each other. use crate::bridge::envelope::ErrorCode; -use crate::bridge::physical_plan::GeneratedColumnSpec; +use nodedb_physical::physical_plan::GeneratedColumnSpec; /// Evaluate all generated columns and inject computed values into the document. /// diff --git a/nodedb/src/data/executor/handlers/graph.rs b/nodedb/src/data/executor/handlers/graph.rs index be250f620..dadf30ad1 100644 --- a/nodedb/src/data/executor/handlers/graph.rs +++ b/nodedb/src/data/executor/handlers/graph.rs @@ -111,7 +111,7 @@ impl CoreLoop { &mut self, task: &ExecutionTask, tid: u64, - edges: &[crate::bridge::physical_plan::BatchEdge], + edges: &[nodedb_physical::physical_plan::BatchEdge], ) -> Response { debug!(core = self.core_id, count = edges.len(), "edge put batch"); for (idx, edge) in edges.iter().enumerate() { @@ -182,7 +182,7 @@ impl CoreLoop { &mut self, task: &ExecutionTask, tid: u64, - edges: &[crate::bridge::physical_plan::BatchEdge], + edges: &[nodedb_physical::physical_plan::BatchEdge], ) -> Response { debug!( core = self.core_id, diff --git a/nodedb/src/data/executor/handlers/grouping_sets_exec.rs b/nodedb/src/data/executor/handlers/grouping_sets_exec.rs index c1c3d2edc..1f170114a 100644 --- a/nodedb/src/data/executor/handlers/grouping_sets_exec.rs +++ b/nodedb/src/data/executor/handlers/grouping_sets_exec.rs @@ -16,10 +16,10 @@ use sonic_rs; use super::accum::GroupState; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::AggregateSpec; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::AggregateSpec; use nodedb_query::msgpack_scan; /// Hidden column name carrying the grouping bitmask for `GROUPING()` support. diff --git a/nodedb/src/data/executor/handlers/join/lateral/loop_handler.rs b/nodedb/src/data/executor/handlers/join/lateral/loop_handler.rs index 83adb27f5..f1621af08 100644 --- a/nodedb/src/data/executor/handlers/join/lateral/loop_handler.rs +++ b/nodedb/src/data/executor/handlers/join/lateral/loop_handler.rs @@ -5,10 +5,10 @@ use tracing::debug; use crate::bridge::envelope::{ErrorCode, PhysicalPlan, Response}; -use crate::bridge::physical_plan::JoinProjection; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::response_codec; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::JoinProjection; use super::shared::{ MAX_RESULT_ROWS, bind_outer_values, build_row, build_scan_plan, extract_outer_field, diff --git a/nodedb/src/data/executor/handlers/join/lateral/shared.rs b/nodedb/src/data/executor/handlers/join/lateral/shared.rs index 1740572b5..5647b9a3f 100644 --- a/nodedb/src/data/executor/handlers/join/lateral/shared.rs +++ b/nodedb/src/data/executor/handlers/join/lateral/shared.rs @@ -3,9 +3,9 @@ //! Shared helpers for LATERAL join handlers. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::{DocumentOp, JoinProjection}; use crate::bridge::scan_filter::{FilterOp, ScanFilter}; use crate::data::executor::handlers::join::{binary_row_project, merge_join_docs_binary}; +use nodedb_physical::physical_plan::{DocumentOp, JoinProjection}; use nodedb_query::msgpack_scan; pub(super) const MAX_RESULT_ROWS: usize = 100_000; diff --git a/nodedb/src/data/executor/handlers/join/lateral/top_k.rs b/nodedb/src/data/executor/handlers/join/lateral/top_k.rs index ed6f7336d..c3dc92623 100644 --- a/nodedb/src/data/executor/handlers/join/lateral/top_k.rs +++ b/nodedb/src/data/executor/handlers/join/lateral/top_k.rs @@ -5,11 +5,11 @@ use tracing::debug; use crate::bridge::envelope::{ErrorCode, PhysicalPlan, Response}; -use crate::bridge::physical_plan::JoinProjection; use crate::bridge::scan_filter::{FilterOp, ScanFilter}; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::response_codec; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::JoinProjection; use super::shared::{ MAX_RESULT_ROWS, build_row, build_scan_plan, extract_outer_field, flatten_outer_row, diff --git a/nodedb/src/data/executor/handlers/join/mod.rs b/nodedb/src/data/executor/handlers/join/mod.rs index 5aefb3bbb..0230fad46 100644 --- a/nodedb/src/data/executor/handlers/join/mod.rs +++ b/nodedb/src/data/executor/handlers/join/mod.rs @@ -221,7 +221,7 @@ pub(super) fn binary_row_matches_filters( /// using the unqualified name as the output key. pub(super) fn binary_row_project( row: &[u8], - projection: &[crate::bridge::physical_plan::JoinProjection], + projection: &[nodedb_physical::physical_plan::JoinProjection], ) -> Vec { let Some((count, pos)) = msgpack_scan::map_header(row, 0) else { return row.to_vec(); @@ -318,11 +318,11 @@ mod tests { let projected = binary_row_project( &merged, &[ - crate::bridge::physical_plan::JoinProjection { + nodedb_physical::physical_plan::JoinProjection { source: "a.name".into(), output: "a.name".into(), }, - crate::bridge::physical_plan::JoinProjection { + nodedb_physical::physical_plan::JoinProjection { source: "b.name".into(), output: "b.name".into(), }, @@ -347,15 +347,15 @@ mod tests { let projected = binary_row_project( &merged, &[ - crate::bridge::physical_plan::JoinProjection { + nodedb_physical::physical_plan::JoinProjection { source: "a.name".into(), output: "emp1".into(), }, - crate::bridge::physical_plan::JoinProjection { + nodedb_physical::physical_plan::JoinProjection { source: "b.name".into(), output: "emp2".into(), }, - crate::bridge::physical_plan::JoinProjection { + nodedb_physical::physical_plan::JoinProjection { source: "a.dept".into(), output: "a.dept".into(), }, diff --git a/nodedb/src/data/executor/handlers/join/params.rs b/nodedb/src/data/executor/handlers/join/params.rs index 1e1108dd7..014fc631e 100644 --- a/nodedb/src/data/executor/handlers/join/params.rs +++ b/nodedb/src/data/executor/handlers/join/params.rs @@ -3,9 +3,9 @@ //! Shared parameter structs for join execution handlers. use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::JoinProjection; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::JoinProjection; /// Common join configuration shared across hash, inline-hash, and broadcast joins. pub(crate) struct JoinParams<'a> { diff --git a/nodedb/src/data/executor/handlers/kv/crud.rs b/nodedb/src/data/executor/handlers/kv/crud.rs index c871a36a1..00c8943ef 100644 --- a/nodedb/src/data/executor/handlers/kv/crud.rs +++ b/nodedb/src/data/executor/handlers/kv/crud.rs @@ -18,7 +18,7 @@ pub(in crate::data::executor) struct KvInsertOnConflictUpdateParams<'a> { pub key: &'a [u8], pub value: &'a [u8], pub ttl_ms: u64, - pub updates: &'a [(String, crate::bridge::physical_plan::UpdateValue)], + pub updates: &'a [(String, nodedb_physical::physical_plan::UpdateValue)], pub surrogate: Surrogate, } diff --git a/nodedb/src/data/executor/handlers/kv/dispatch.rs b/nodedb/src/data/executor/handlers/kv/dispatch.rs index 53b26826e..47819f7ea 100644 --- a/nodedb/src/data/executor/handlers/kv/dispatch.rs +++ b/nodedb/src/data/executor/handlers/kv/dispatch.rs @@ -3,9 +3,9 @@ //! KV operation dispatch: routes `KvOp` variants to their handler methods. use crate::bridge::envelope::Response; -use crate::bridge::physical_plan::KvOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::KvOp; impl CoreLoop { /// Dispatch a KV operation to the appropriate handler. diff --git a/nodedb/src/data/executor/handlers/merge.rs b/nodedb/src/data/executor/handlers/merge.rs index b1738bab2..6cf5c5bf2 100644 --- a/nodedb/src/data/executor/handlers/merge.rs +++ b/nodedb/src/data/executor/handlers/merge.rs @@ -21,13 +21,13 @@ use super::merge_helpers::{ apply_action, apply_insert_action, build_merged, find_arm, json_to_str, }; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::document::merge_types::{ - MergeClauseKind as MergeClauseKindOp, MergeClauseOp, -}; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::doc_format; use crate::data::executor::response_codec::encode_json; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::document::merge_types::{ + MergeClauseKind as MergeClauseKindOp, MergeClauseOp, +}; /// Parameters for `execute_merge`. pub(in crate::data::executor) struct MergeParams<'a> { @@ -83,7 +83,8 @@ impl CoreLoop { target_collection.to_string(), ); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { @@ -299,7 +300,8 @@ impl CoreLoop { let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/merge_helpers.rs b/nodedb/src/data/executor/handlers/merge_helpers.rs index 447c6edee..b80c2b8d5 100644 --- a/nodedb/src/data/executor/handlers/merge_helpers.rs +++ b/nodedb/src/data/executor/handlers/merge_helpers.rs @@ -2,13 +2,13 @@ //! Pure helper functions for MERGE statement execution (arm selection, action application). -use crate::bridge::physical_plan::UpdateValue; -use crate::bridge::physical_plan::document::merge_types::{ - MergeActionOp, MergeClauseKind as MergeClauseKindOp, MergeClauseOp, -}; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::doc_format; +use nodedb_physical::physical_plan::UpdateValue; +use nodedb_physical::physical_plan::document::merge_types::{ + MergeActionOp, MergeClauseKind as MergeClauseKindOp, MergeClauseOp, +}; /// Find the first clause of the given kind whose extra_predicate is satisfied /// against `context_doc`. diff --git a/nodedb/src/data/executor/handlers/point/apply_put.rs b/nodedb/src/data/executor/handlers/point/apply_put.rs index 77029b6eb..d4c6f788d 100644 --- a/nodedb/src/data/executor/handlers/point/apply_put.rs +++ b/nodedb/src/data/executor/handlers/point/apply_put.rs @@ -90,7 +90,7 @@ impl CoreLoop { let value_with_rowid: Vec; let (value, stored): (&[u8], Vec) = if let Some(config) = self.doc_configs.get(&config_key) - && let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = + && let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = config.storage_mode { let encoded_input: &[u8] = if schema @@ -300,7 +300,7 @@ impl CoreLoop { .doc_configs .get(&config_key) .and_then(|config| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = config.storage_mode { let fields: Vec<_> = schema diff --git a/nodedb/src/data/executor/handlers/point/delete.rs b/nodedb/src/data/executor/handlers/point/delete.rs index c325aa15c..43200760d 100644 --- a/nodedb/src/data/executor/handlers/point/delete.rs +++ b/nodedb/src/data/executor/handlers/point/delete.rs @@ -6,12 +6,12 @@ use tracing::{debug, warn}; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::ReturningSpec; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::doc_format; use crate::data::executor::handlers::returning_rows; use crate::data::executor::task::ExecutionTask; use crate::engine::document::store::surrogate_to_doc_id; +use nodedb_physical::physical_plan::ReturningSpec; use nodedb_types::Surrogate; impl CoreLoop { diff --git a/nodedb/src/data/executor/handlers/point/get.rs b/nodedb/src/data/executor/handlers/point/get.rs index b2f8c71f7..399d3b91e 100644 --- a/nodedb/src/data/executor/handlers/point/get.rs +++ b/nodedb/src/data/executor/handlers/point/get.rs @@ -52,7 +52,8 @@ impl CoreLoop { // Check if this is a strict collection — affects decode format. let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/point/update.rs b/nodedb/src/data/executor/handlers/point/update.rs index 25513e8bb..eb5b65776 100644 --- a/nodedb/src/data/executor/handlers/point/update.rs +++ b/nodedb/src/data/executor/handlers/point/update.rs @@ -10,12 +10,12 @@ use tracing::debug; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::{ReturningSpec, UpdateValue}; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::doc_format; use crate::data::executor::handlers::returning_rows; use crate::data::executor::task::ExecutionTask; use crate::engine::document::store::surrogate_to_doc_id; +use nodedb_physical::physical_plan::{ReturningSpec, UpdateValue}; use nodedb_types::Surrogate; impl CoreLoop { @@ -45,7 +45,7 @@ impl CoreLoop { let is_strict = self.doc_configs.get(&config_key).is_some_and(|c| { matches!( c.storage_mode, - crate::bridge::physical_plan::StorageMode::Strict { .. } + nodedb_physical::physical_plan::StorageMode::Strict { .. } ) }); @@ -101,8 +101,9 @@ impl CoreLoop { // Strict, generated, or expression RHS: decode → mutate → re-encode. let mut doc = if is_strict { if let Some(config) = self.doc_configs.get(&config_key) - && let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = - config.storage_mode + && let nodedb_physical::physical_plan::StorageMode::Strict { + ref schema, + } = config.storage_mode { match super::super::super::strict_format::binary_tuple_to_json( ¤t_bytes, @@ -191,8 +192,9 @@ impl CoreLoop { // Re-encode. if is_strict { if let Some(config) = self.doc_configs.get(&config_key) - && let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = - config.storage_mode + && let nodedb_physical::physical_plan::StorageMode::Strict { + ref schema, + } = config.storage_mode { let ndb_val: nodedb_types::Value = doc.clone().into(); let result = if bitemporal && schema.bitemporal { diff --git a/nodedb/src/data/executor/handlers/recursive.rs b/nodedb/src/data/executor/handlers/recursive.rs index 083ab3b8a..8aa3a7974 100644 --- a/nodedb/src/data/executor/handlers/recursive.rs +++ b/nodedb/src/data/executor/handlers/recursive.rs @@ -80,7 +80,8 @@ impl CoreLoop { // Check if the collection uses strict (Binary Tuple) encoding. let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/returning_rows.rs b/nodedb/src/data/executor/handlers/returning_rows.rs index e664ed69a..39a3b0452 100644 --- a/nodedb/src/data/executor/handlers/returning_rows.rs +++ b/nodedb/src/data/executor/handlers/returning_rows.rs @@ -6,8 +6,8 @@ //! when the plan carries a `ReturningSpec`. Produces a `RowsPayload` msgpack //! blob that the Control Plane decodes into multi-column pgwire rows. -use crate::bridge::physical_plan::{ReturningColumns, ReturningSpec}; use crate::data::executor::response_codec::RowsPayload; +use nodedb_physical::physical_plan::{ReturningColumns, ReturningSpec}; /// Project a slice of documents per `spec` and encode as a `RowsPayload` msgpack blob. /// diff --git a/nodedb/src/data/executor/handlers/spatial.rs b/nodedb/src/data/executor/handlers/spatial.rs index f30bf7471..3dfccceaa 100644 --- a/nodedb/src/data/executor/handlers/spatial.rs +++ b/nodedb/src/data/executor/handlers/spatial.rs @@ -12,10 +12,10 @@ use tracing::debug; use super::super::response_codec; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::SpatialPredicate; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::SpatialPredicate; use nodedb_types::{Surrogate, SurrogateBitmap, Value}; impl CoreLoop { @@ -365,12 +365,12 @@ fn expand_bbox(bbox: &nodedb_types::BoundingBox, meters: f64) -> nodedb_types::B #[cfg(test)] mod tests { use crate::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; - use crate::bridge::physical_plan::SpatialOp; use crate::data::executor::task::ExecutionTask; use crate::engine::spatial::RTreeEntry; use crate::types::{DatabaseId, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; use crate::util::fnv1a_hash; use nodedb_bridge::buffer::RingBuffer; + use nodedb_physical::physical_plan::SpatialOp; use nodedb_types::{Surrogate, SurrogateBitmap}; use std::time::{Duration, Instant}; @@ -462,7 +462,7 @@ mod tests { PhysicalPlan::Spatial(SpatialOp::Scan { collection: "places".into(), field: "loc".into(), - predicate: crate::bridge::physical_plan::SpatialPredicate::DWithin, + predicate: nodedb_physical::physical_plan::SpatialPredicate::DWithin, query_geometry: origin_point(), distance_meters: 1_000_000.0, attribute_filters: Vec::new(), @@ -519,7 +519,7 @@ mod tests { tid, collection, field, - &crate::bridge::physical_plan::SpatialPredicate::DWithin, + &nodedb_physical::physical_plan::SpatialPredicate::DWithin, &origin_point(), 1_000_000.0, &[], diff --git a/nodedb/src/data/executor/handlers/spill/groupby.rs b/nodedb/src/data/executor/handlers/spill/groupby.rs index 4c82690df..9186880df 100644 --- a/nodedb/src/data/executor/handlers/spill/groupby.rs +++ b/nodedb/src/data/executor/handlers/spill/groupby.rs @@ -22,8 +22,8 @@ use std::path::PathBuf; use std::sync::Arc; use super::core::SpillCore; -use crate::bridge::physical_plan::AggregateSpec; use crate::data::executor::handlers::accum::GroupState; +use nodedb_physical::physical_plan::AggregateSpec; /// Spill-to-disk manager for the schemaless GROUP BY path. pub(in crate::data::executor::handlers) struct GroupBySpiller { @@ -128,8 +128,8 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - use crate::bridge::physical_plan::AggregateSpec; use crate::data::executor::handlers::accum::GroupState; + use nodedb_physical::physical_plan::AggregateSpec; use nodedb_types::Value; use super::GroupBySpiller; diff --git a/nodedb/src/data/executor/handlers/text_search.rs b/nodedb/src/data/executor/handlers/text_search.rs index b00c2267e..0777410aa 100644 --- a/nodedb/src/data/executor/handlers/text_search.rs +++ b/nodedb/src/data/executor/handlers/text_search.rs @@ -100,7 +100,8 @@ impl CoreLoop { ) -> Option { let key = (tenant_id, collection.to_string()); self.doc_configs.get(&key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { @@ -280,7 +281,8 @@ impl CoreLoop { // Retrieve the strict schema (if any) so binary-tuple rows decode correctly. let config_key = (tenant_id, collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/timeseries_wal.rs b/nodedb/src/data/executor/handlers/timeseries_wal.rs index 1cc666c28..f2645a707 100644 --- a/nodedb/src/data/executor/handlers/timeseries_wal.rs +++ b/nodedb/src/data/executor/handlers/timeseries_wal.rs @@ -7,7 +7,6 @@ //! per partition (not max_ts — safe with out-of-order data). use crate::bridge::envelope::{PhysicalPlan, Priority, Request}; -use crate::bridge::physical_plan::{ColumnarInsertIntent, ColumnarOp, TimeseriesOp}; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::{ExecutionTask, TaskState}; use crate::engine::timeseries::columnar_memtable::{ @@ -15,6 +14,7 @@ use crate::engine::timeseries::columnar_memtable::{ }; use crate::types::DatabaseId; use crate::types::ReadConsistency; +use nodedb_physical::physical_plan::{ColumnarInsertIntent, ColumnarOp, TimeseriesOp}; use nodedb_types::timeseries::MetricSample; /// Default timeseries memtable configuration for replay and auto-creation. diff --git a/nodedb/src/data/executor/handlers/transaction/batch.rs b/nodedb/src/data/executor/handlers/transaction/batch.rs index 6bb6d3dbc..924841f1f 100644 --- a/nodedb/src/data/executor/handlers/transaction/batch.rs +++ b/nodedb/src/data/executor/handlers/transaction/batch.rs @@ -12,9 +12,9 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use tracing::{debug, error, warn}; use crate::bridge::envelope::{ErrorCode, Response, Status}; -use crate::bridge::physical_plan::PhysicalPlan; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::PhysicalPlan; use super::undo::UndoEntry; diff --git a/nodedb/src/data/executor/handlers/transaction/sub_plan.rs b/nodedb/src/data/executor/handlers/transaction/sub_plan.rs index cc1379324..97849304e 100644 --- a/nodedb/src/data/executor/handlers/transaction/sub_plan.rs +++ b/nodedb/src/data/executor/handlers/transaction/sub_plan.rs @@ -3,12 +3,12 @@ //! Per-sub-plan execution within a transaction batch. use crate::bridge::envelope::{ErrorCode, PhysicalPlan, Response, Status}; -use crate::bridge::physical_plan::{ - ColumnarOp, CrdtOp, DocumentOp, GraphOp, MetaOp, TimeseriesOp, VectorOp, -}; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; use crate::types::{DatabaseId, TenantId, TraceId}; +use nodedb_physical::physical_plan::{ + ColumnarOp, CrdtOp, DocumentOp, GraphOp, MetaOp, TimeseriesOp, VectorOp, +}; use super::undo::UndoEntry; diff --git a/nodedb/src/data/executor/handlers/transaction/sub_plan_doc.rs b/nodedb/src/data/executor/handlers/transaction/sub_plan_doc.rs index a3a8bd77c..36f51eed7 100644 --- a/nodedb/src/data/executor/handlers/transaction/sub_plan_doc.rs +++ b/nodedb/src/data/executor/handlers/transaction/sub_plan_doc.rs @@ -69,7 +69,7 @@ impl CoreLoop { let encode_for_storage = |bytes: &[u8]| -> Result, ErrorCode> { if let Some(config) = self.doc_configs.get(&config_key) - && let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = + && let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = config.storage_mode { strict_format::bytes_to_binary_tuple(bytes, schema).map_err(|e| { diff --git a/nodedb/src/data/executor/handlers/transaction/sub_plan_kv.rs b/nodedb/src/data/executor/handlers/transaction/sub_plan_kv.rs index 6689b3216..9d97d0a17 100644 --- a/nodedb/src/data/executor/handlers/transaction/sub_plan_kv.rs +++ b/nodedb/src/data/executor/handlers/transaction/sub_plan_kv.rs @@ -10,11 +10,11 @@ use nodedb_columnar::pk_index::RowLocation; use crate::bridge::envelope::{ErrorCode, Response, Status}; -use crate::bridge::physical_plan::ColumnarInsertIntent; -use crate::bridge::physical_plan::document::UpdateValue; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; use crate::types::TenantId; +use nodedb_physical::physical_plan::ColumnarInsertIntent; +use nodedb_physical::physical_plan::document::UpdateValue; use super::undo::UndoEntry; diff --git a/nodedb/src/data/executor/handlers/transaction/sub_plan_kv_ops.rs b/nodedb/src/data/executor/handlers/transaction/sub_plan_kv_ops.rs index 8e49fd28a..6445af7dd 100644 --- a/nodedb/src/data/executor/handlers/transaction/sub_plan_kv_ops.rs +++ b/nodedb/src/data/executor/handlers/transaction/sub_plan_kv_ops.rs @@ -3,10 +3,10 @@ //! KV operation dispatch for transaction batches. use crate::bridge::envelope::{ErrorCode, Response, Status}; -use crate::bridge::physical_plan::KvOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::task::ExecutionTask; use crate::engine::kv::current_ms; +use nodedb_physical::physical_plan::KvOp; use super::undo::UndoEntry; diff --git a/nodedb/src/data/executor/handlers/update_from_join.rs b/nodedb/src/data/executor/handlers/update_from_join.rs index dede373bd..9856524a7 100644 --- a/nodedb/src/data/executor/handlers/update_from_join.rs +++ b/nodedb/src/data/executor/handlers/update_from_join.rs @@ -12,13 +12,13 @@ use tracing::debug; use crate::bridge::envelope::{ErrorCode, Response}; -use crate::bridge::physical_plan::{ReturningSpec, UpdateValue}; use crate::bridge::scan_filter::ScanFilter; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::doc_format; use crate::data::executor::handlers::returning_rows; use crate::data::executor::response_codec::encode_json; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::{ReturningSpec, UpdateValue}; /// Parameters for `execute_update_from_join`. pub(in crate::data::executor) struct UpdateFromJoinParams<'a> { @@ -109,7 +109,8 @@ impl CoreLoop { target_collection.to_string(), ); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { @@ -360,7 +361,8 @@ impl CoreLoop { // Check if the source collection is strict-mode. let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/data/executor/handlers/upsert.rs b/nodedb/src/data/executor/handlers/upsert.rs index ae2332837..dee181134 100644 --- a/nodedb/src/data/executor/handlers/upsert.rs +++ b/nodedb/src/data/executor/handlers/upsert.rs @@ -32,7 +32,7 @@ impl CoreLoop { document_id: &str, surrogate: Surrogate, value: &[u8], - on_conflict_updates: &[(String, crate::bridge::physical_plan::UpdateValue)], + on_conflict_updates: &[(String, nodedb_physical::physical_plan::UpdateValue)], ) -> Response { let row_key = surrogate_to_doc_id(surrogate); let row_key = row_key.as_str(); @@ -47,7 +47,7 @@ impl CoreLoop { // Detect strict storage mode for this collection. let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|config| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = config.storage_mode { Some(schema.clone()) @@ -291,7 +291,7 @@ impl CoreLoop { pub(in crate::data::executor) fn apply_on_conflict_updates( existing: nodedb_types::Value, excluded: &nodedb_types::Value, - updates: &[(String, crate::bridge::physical_plan::UpdateValue)], + updates: &[(String, nodedb_physical::physical_plan::UpdateValue)], ) -> nodedb_types::Value { let mut obj = match existing { nodedb_types::Value::Object(map) => map, @@ -306,13 +306,13 @@ pub(in crate::data::executor) fn apply_on_conflict_updates( let snapshot = nodedb_types::Value::Object(obj.clone()); for (field, update_val) in updates { let new_val: nodedb_types::Value = match update_val { - crate::bridge::physical_plan::UpdateValue::Literal(bytes) => { + nodedb_physical::physical_plan::UpdateValue::Literal(bytes) => { match nodedb_types::value_from_msgpack(bytes) { Ok(v) => v, Err(_) => continue, } } - crate::bridge::physical_plan::UpdateValue::Expr(expr) => { + nodedb_physical::physical_plan::UpdateValue::Expr(expr) => { expr.eval_with_excluded(&snapshot, excluded) } }; diff --git a/nodedb/src/data/executor/handlers/write_batch.rs b/nodedb/src/data/executor/handlers/write_batch.rs index 5231a543d..a70316a7d 100644 --- a/nodedb/src/data/executor/handlers/write_batch.rs +++ b/nodedb/src/data/executor/handlers/write_batch.rs @@ -5,10 +5,10 @@ use tracing::debug; use crate::bridge::envelope::{ErrorCode, PhysicalPlan}; -use crate::bridge::physical_plan::DocumentOp; use crate::data::executor::core_loop::CoreLoop; use crate::data::executor::handlers::point::apply_put::PointPutParams; use crate::data::executor::task::ExecutionTask; +use nodedb_physical::physical_plan::DocumentOp; impl CoreLoop { /// Batch-coalesce consecutive PointPut tasks from the front of the task queue. diff --git a/nodedb/src/data/executor/scan_normalize.rs b/nodedb/src/data/executor/scan_normalize.rs index 1beadd7c3..0b486d8d3 100644 --- a/nodedb/src/data/executor/scan_normalize.rs +++ b/nodedb/src/data/executor/scan_normalize.rs @@ -227,7 +227,8 @@ impl CoreLoop { let config_key = (crate::types::TenantId::new(tid), collection.to_string()); let strict_schema = self.doc_configs.get(&config_key).and_then(|c| { - if let crate::bridge::physical_plan::StorageMode::Strict { ref schema } = c.storage_mode + if let nodedb_physical::physical_plan::StorageMode::Strict { ref schema } = + c.storage_mode { Some(schema.clone()) } else { diff --git a/nodedb/src/engine/bitemporal/enforcement.rs b/nodedb/src/engine/bitemporal/enforcement.rs index 5133862de..75458b217 100644 --- a/nodedb/src/engine/bitemporal/enforcement.rs +++ b/nodedb/src/engine/bitemporal/enforcement.rs @@ -26,8 +26,8 @@ use tracing::{info, warn}; use super::registry::{BitemporalEngineKind, BitemporalRetentionRegistry, Entry}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; +use nodedb_physical::physical_plan::MetaOp; /// Default tick interval when no shorter deadline is needed. One hour /// matches the timeseries retention loop's default; operators can lower diff --git a/nodedb/src/engine/document/predicate.rs b/nodedb/src/engine/document/predicate.rs index e224e6dc7..db99a53f3 100644 --- a/nodedb/src/engine/document/predicate.rs +++ b/nodedb/src/engine/document/predicate.rs @@ -5,7 +5,7 @@ //! A partial index declared as `CREATE INDEX ... WHERE ` is //! populated only with rows where `` evaluates to true. The //! predicate text travels over the wire (see -//! [`crate::bridge::physical_plan::RegisteredIndex::predicate`]) and +//! [`nodedb_physical::physical_plan::RegisteredIndex::predicate`]) and //! gets parsed once when the Data Plane installs the index via //! `DocumentOp::Register` or runs the initial backfill. //! diff --git a/nodedb/src/engine/document/store/config.rs b/nodedb/src/engine/document/store/config.rs index c6c50709d..7dfb0ee0b 100644 --- a/nodedb/src/engine/document/store/config.rs +++ b/nodedb/src/engine/document/store/config.rs @@ -13,9 +13,9 @@ pub struct CollectionConfig { /// Whether this collection uses CRDT-backed storage (Loro). pub crdt_enabled: bool, /// Storage encoding mode (schemaless MessagePack or strict Binary Tuple). - pub storage_mode: crate::bridge::physical_plan::StorageMode, + pub storage_mode: nodedb_physical::physical_plan::StorageMode, /// Collection enforcement options (append-only, period lock, retention, etc.). - pub enforcement: crate::bridge::physical_plan::EnforcementOptions, + pub enforcement: nodedb_physical::physical_plan::EnforcementOptions, /// Bitemporal storage: every write goes to the versioned document /// table, keyed by `system_from_ms`. Enables `FOR SYSTEM_TIME AS OF` /// queries. @@ -28,8 +28,8 @@ impl CollectionConfig { name: name.to_string(), index_paths: Vec::new(), crdt_enabled: false, - storage_mode: crate::bridge::physical_plan::StorageMode::Schemaless, - enforcement: crate::bridge::physical_plan::EnforcementOptions::default(), + storage_mode: nodedb_physical::physical_plan::StorageMode::Schemaless, + enforcement: nodedb_physical::physical_plan::EnforcementOptions::default(), bitemporal: false, } } @@ -49,7 +49,7 @@ impl CollectionConfig { self } - pub fn with_storage_mode(mut self, mode: crate::bridge::physical_plan::StorageMode) -> Self { + pub fn with_storage_mode(mut self, mode: nodedb_physical::physical_plan::StorageMode) -> Self { self.storage_mode = mode; self } diff --git a/nodedb/src/engine/document/store/index_path.rs b/nodedb/src/engine/document/store/index_path.rs index a7a4ba8ab..0a36f9c74 100644 --- a/nodedb/src/engine/document/store/index_path.rs +++ b/nodedb/src/engine/document/store/index_path.rs @@ -2,7 +2,7 @@ //! Index path declaration for secondary indexes. -use crate::bridge::physical_plan::RegisteredIndexState; +use nodedb_physical::physical_plan::RegisteredIndexState; /// Index path declaration for automatic secondary index extraction. /// @@ -55,7 +55,7 @@ impl IndexPath { /// `DocumentOp::Register`. Parses the optional partial-index /// predicate eagerly so every write-path invocation reuses the /// same parsed AST. - pub fn from_registered(spec: &crate::bridge::physical_plan::RegisteredIndex) -> Self { + pub fn from_registered(spec: &nodedb_physical::physical_plan::RegisteredIndex) -> Self { let is_array = spec.path.ends_with("[]"); let path = spec .path diff --git a/nodedb/src/engine/graph/algo/params.rs b/nodedb/src/engine/graph/algo/params.rs index 11d93f2e0..1cc225ab2 100644 --- a/nodedb/src/engine/graph/algo/params.rs +++ b/nodedb/src/engine/graph/algo/params.rs @@ -1,275 +1,3 @@ // SPDX-License-Identifier: BUSL-1.1 - -//! Graph algorithm enum and parameter bag. -//! -//! `GraphAlgorithm` identifies which algorithm to run. -//! `AlgoParams` carries the union of all algorithm parameters — each -//! algorithm validates and extracts what it needs. - -use serde::{Deserialize, Serialize}; - -/// Supported graph algorithms. -/// -/// Each variant maps to a standalone algorithm implementation under -/// `src/engine/graph/algo/`. Used by `PhysicalPlan::GraphAlgo` to -/// identify which algorithm to dispatch. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -#[msgpack(c_enum)] -pub enum GraphAlgorithm { - /// PageRank — link analysis (power iteration). - PageRank, - /// Weakly Connected Components — union-find. - Wcc, - /// Community Detection — label propagation. - LabelPropagation, - /// Local Clustering Coefficient — per-node triangle density. - Lcc, - /// Single-Source Shortest Path — weighted Dijkstra. - Sssp, - /// Betweenness Centrality — Brandes' algorithm. - Betweenness, - /// Closeness Centrality — inverse distance sum. - Closeness, - /// Harmonic Centrality — inverse distance harmonic mean. - Harmonic, - /// Degree Centrality — normalized degree. - Degree, - /// Louvain Community Detection — modularity optimization. - Louvain, - /// Triangle Counting — global or per-node. - Triangles, - /// Graph Diameter / Eccentricity. - Diameter, - /// k-Core Decomposition — peeling algorithm. - KCore, -} - -impl GraphAlgorithm { - /// Human-readable name for progress reporting and result column headers. - pub fn name(&self) -> &'static str { - match self { - Self::PageRank => "pagerank", - Self::Wcc => "wcc", - Self::LabelPropagation => "label_propagation", - Self::Lcc => "lcc", - Self::Sssp => "sssp", - Self::Betweenness => "betweenness", - Self::Closeness => "closeness", - Self::Harmonic => "harmonic", - Self::Degree => "degree", - Self::Louvain => "louvain", - Self::Triangles => "triangles", - Self::Diameter => "diameter", - Self::KCore => "kcore", - } - } - - /// Whether this algorithm is iterative (emits progress per iteration). - pub fn is_iterative(&self) -> bool { - matches!( - self, - Self::PageRank | Self::LabelPropagation | Self::Louvain - ) - } - - /// Result column schema: `(column_name, column_type)`. - /// - /// Used by the Arrow result builder to construct RecordBatches and by - /// the DDL layer to advertise result columns. - pub fn result_schema(&self) -> &'static [(&'static str, AlgoColumnType)] { - use AlgoColumnType::*; - match self { - Self::PageRank => &[("node_id", Text), ("rank", Float64)], - Self::Wcc => &[("node_id", Text), ("component_id", Int64)], - Self::LabelPropagation => &[("node_id", Text), ("community_id", Int64)], - Self::Lcc => &[("node_id", Text), ("coefficient", Float64)], - Self::Sssp => &[("node_id", Text), ("distance", Float64)], - Self::Betweenness => &[("node_id", Text), ("centrality", Float64)], - Self::Closeness => &[("node_id", Text), ("centrality", Float64)], - Self::Harmonic => &[("node_id", Text), ("centrality", Float64)], - Self::Degree => &[("node_id", Text), ("centrality", Float64)], - Self::Louvain => &[ - ("node_id", Text), - ("community_id", Int64), - ("modularity", Float64), - ], - Self::Triangles => &[("node_id", Text), ("triangles", Int64)], - Self::Diameter => &[("diameter", Int64), ("radius", Int64)], - Self::KCore => &[("node_id", Text), ("coreness", Int64)], - } - } -} - -/// Column type for algorithm result schemas. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AlgoColumnType { - Text, - Float64, - Int64, -} - -/// Generic parameter bag for all graph algorithms. -/// -/// Each algorithm validates and extracts the parameters it needs, -/// ignoring the rest. Unknown parameters are silently ignored rather -/// than rejected — this allows forward-compatible DDL extensions. -#[derive( - Debug, - Clone, - Default, - PartialEq, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub struct AlgoParams { - /// Target collection name. - pub collection: String, - - /// Optional edge label filter — only edges with this label are traversed. - pub edge_label: Option, - - /// PageRank damping factor (default: 0.85). - pub damping: Option, - - /// Maximum iterations for iterative algorithms (PageRank, LabelProp, Louvain). - pub max_iterations: Option, - - /// Convergence tolerance for PageRank (default: 1e-7). - pub tolerance: Option, - - /// Source node for SSSP. - pub source_node: Option, - - /// Sample size for approximate centrality (betweenness, closeness). - /// `None` = exact computation. - pub sample_size: Option, - - /// Direction for degree centrality: "in", "out", "both". - pub direction: Option, - - /// Resolution parameter for Louvain (default: 1.0). - pub resolution: Option, - - /// Mode for triangle counting / diameter: "global", "per_node", "exact", "approximate". - pub mode: Option, -} - -impl AlgoParams { - /// PageRank damping factor, validated to (0.0, 1.0). - pub fn damping_factor(&self) -> f64 { - self.damping.unwrap_or(0.85).clamp(0.01, 0.99) - } - - /// Max iterations with sensible default per algorithm. - /// - /// Defence-in-depth: the pgwire handler clamps `ITERATIONS` to - /// `MAX_ITERATIONS_CAP` before dispatch, but any alternate entry - /// point (native protocol, internal dispatch) also lands here, so - /// we enforce the ceiling at the engine boundary too. - pub fn iterations(&self, default: usize) -> usize { - const ITERATIONS_HARD_CAP: usize = 1_000; - self.max_iterations - .unwrap_or(default) - .clamp(1, ITERATIONS_HARD_CAP) - } - - /// Convergence tolerance, validated to positive. - pub fn convergence_tolerance(&self) -> f64 { - let t = self.tolerance.unwrap_or(1e-7); - if t > 0.0 { t } else { 1e-7 } - } - - /// Louvain resolution parameter, validated to positive. - pub fn louvain_resolution(&self) -> f64 { - let r = self.resolution.unwrap_or(1.0); - if r > 0.0 { r } else { 1.0 } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sonic_rs; - - #[test] - fn algorithm_names() { - assert_eq!(GraphAlgorithm::PageRank.name(), "pagerank"); - assert_eq!(GraphAlgorithm::Wcc.name(), "wcc"); - assert_eq!(GraphAlgorithm::KCore.name(), "kcore"); - } - - #[test] - fn iterative_algorithms() { - assert!(GraphAlgorithm::PageRank.is_iterative()); - assert!(GraphAlgorithm::LabelPropagation.is_iterative()); - assert!(GraphAlgorithm::Louvain.is_iterative()); - assert!(!GraphAlgorithm::Wcc.is_iterative()); - assert!(!GraphAlgorithm::Sssp.is_iterative()); - } - - #[test] - fn result_schema_columns() { - let schema = GraphAlgorithm::PageRank.result_schema(); - assert_eq!(schema.len(), 2); - assert_eq!(schema[0], ("node_id", AlgoColumnType::Text)); - assert_eq!(schema[1], ("rank", AlgoColumnType::Float64)); - } - - #[test] - fn louvain_schema_has_three_columns() { - let schema = GraphAlgorithm::Louvain.result_schema(); - assert_eq!(schema.len(), 3); - } - - #[test] - fn params_defaults() { - let p = AlgoParams::default(); - assert_eq!(p.damping_factor(), 0.85); - assert_eq!(p.iterations(20), 20); - assert_eq!(p.convergence_tolerance(), 1e-7); - assert_eq!(p.louvain_resolution(), 1.0); - } - - #[test] - fn params_clamping() { - let p = AlgoParams { - damping: Some(2.0), - tolerance: Some(-1.0), - resolution: Some(0.0), - ..Default::default() - }; - assert_eq!(p.damping_factor(), 0.99); - assert_eq!(p.convergence_tolerance(), 1e-7); - assert_eq!(p.louvain_resolution(), 1.0); - } - - #[test] - fn params_serde_roundtrip() { - let p = AlgoParams { - collection: "users".into(), - damping: Some(0.9), - max_iterations: Some(30), - source_node: Some("alice".into()), - ..Default::default() - }; - let json = sonic_rs::to_string(&p).unwrap(); - let p2: AlgoParams = sonic_rs::from_str(&json).unwrap(); - assert_eq!(p2.collection, "users"); - assert_eq!(p2.damping, Some(0.9)); - assert_eq!(p2.max_iterations, Some(30)); - assert_eq!(p2.source_node, Some("alice".into())); - } -} +//! Re-export shim — types live in `nodedb-graph`. +pub use nodedb_graph::params::{AlgoColumnType, AlgoParams, GraphAlgorithm}; diff --git a/nodedb/src/engine/graph/traversal_options.rs b/nodedb/src/engine/graph/traversal_options.rs index f3f3e8b2b..25235a1e7 100644 --- a/nodedb/src/engine/graph/traversal_options.rs +++ b/nodedb/src/engine/graph/traversal_options.rs @@ -1,236 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 - -//! Per-query graph traversal configuration. -//! -//! Adaptive fan-out uses a two-tier limit with optional graceful -//! degradation instead of a hard kill. - -/// Largest accepted value for any graph-DSL depth parameter -/// (`DEPTH`, `MAX_DEPTH`, `EXPANSION_DEPTH`). -/// -/// Enforced at every ingress (pgwire, native protocol) and at the -/// engine boundary as defence-in-depth so a single statement cannot -/// saturate `cross_core_bfs`, `csr.shortest_path`, or the subgraph -/// materializer with an unbounded fan-out per hop. -pub const MAX_GRAPH_TRAVERSAL_DEPTH: usize = 64; - -use serde::{Deserialize, Serialize}; - -/// Per-query graph traversal configuration. -/// -/// Controls fan-out limits, partial result handling, and visited node caps -/// for scatter-gather graph queries across shards. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub struct GraphTraversalOptions { - /// Soft warning threshold (shards per hop). - /// - /// When the number of shards reached in a single hop exceeds this value, - /// a fan-out warning is emitted but execution continues. - /// Default: 12 - pub fan_out_soft: u16, - - /// Hard limit (shards per hop). - /// - /// Maximum number of shards that can be queried in a single hop. - /// If exceeded and `fan_out_partial` is false, returns FAN_OUT_EXCEEDED error. - /// Default: 16 - pub fan_out_hard: u16, - - /// If true, return partial results instead of FAN_OUT_EXCEEDED error. - /// - /// When the hard limit is exceeded, instead of failing with FAN_OUT_EXCEEDED, - /// this flag allows the response to be marked as truncated with partial results. - /// Default: false - pub fan_out_partial: bool, - - /// Cap on total visited nodes across all shards. - /// - /// Once this limit is reached, no further node exploration occurs. - /// Default: 100_000 - pub max_visited: usize, -} - -impl Default for GraphTraversalOptions { - fn default() -> Self { - Self { - fan_out_soft: 12, - fan_out_hard: 16, - fan_out_partial: false, - max_visited: 100_000, - } - } -} - -impl GraphTraversalOptions { - /// Create a new `GraphTraversalOptions` with default values. - pub fn new() -> Self { - Self::default() - } -} - -/// Response metadata for scatter-gather graph query results. -/// -/// Tracks how many shards were reached, skipped, and whether results are -/// complete or truncated due to adaptive fan-out limits. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GraphResponseMeta { - /// Number of shards that were queried and returned results. - pub shards_reached: u16, - - /// Number of shards that were skipped due to fan-out limits. - pub shards_skipped: u16, - - /// Whether results are incomplete (true) or complete (false). - pub truncated: bool, - - /// Fan-out warning message if soft limit was exceeded. - /// - /// Format: "X/Y" where X is shards_reached and Y is fan_out_hard. - /// None if no warning. - pub fan_out_warning: Option, - - /// Whether results gathered beyond the soft limit are approximate. - /// - /// Set to true when shards_reached > fan_out_soft. - pub approximate: bool, -} - -impl GraphResponseMeta { - /// Check if this response has no warnings or truncation. - /// - /// Returns true if: - /// - No fan-out warning - /// - Not truncated - /// - Not approximate - pub fn is_clean(&self) -> bool { - self.fan_out_warning.is_none() && !self.truncated && !self.approximate - } - - /// Create response metadata with a fan-out warning. - /// - /// Indicates that the soft limit was exceeded but execution continued. - /// Creates a warning string like "12/16" showing reached vs hard limit. - pub fn with_warning(shards_reached: u16, shards_skipped: u16, fan_out_hard: u16) -> Self { - Self { - shards_reached, - shards_skipped, - truncated: false, - fan_out_warning: Some(format!("{}/{}", shards_reached, fan_out_hard)), - approximate: true, - } - } - - /// Create response metadata for truncated results. - /// - /// Indicates that results were incomplete due to fan-out limits. - pub fn with_truncation(shards_reached: u16, shards_skipped: u16) -> Self { - Self { - shards_reached, - shards_skipped, - truncated: true, - fan_out_warning: None, - approximate: true, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sonic_rs; - - #[test] - fn default_options_have_expected_values() { - let opts = GraphTraversalOptions::default(); - assert_eq!(opts.fan_out_soft, 12); - assert_eq!(opts.fan_out_hard, 16); - assert!(!opts.fan_out_partial); - assert_eq!(opts.max_visited, 100_000); - } - - #[test] - fn new_returns_defaults() { - let opts = GraphTraversalOptions::new(); - assert_eq!(opts, GraphTraversalOptions::default()); - } - - #[test] - fn default_meta_is_clean() { - let meta = GraphResponseMeta::default(); - assert!(meta.is_clean()); - assert_eq!(meta.shards_reached, 0); - assert_eq!(meta.shards_skipped, 0); - assert!(!meta.truncated); - assert!(meta.fan_out_warning.is_none()); - assert!(!meta.approximate); - } - - #[test] - fn with_warning_generates_correct_string() { - let meta = GraphResponseMeta::with_warning(12, 4, 16); - assert_eq!(meta.shards_reached, 12); - assert_eq!(meta.shards_skipped, 4); - assert!(!meta.truncated); - assert_eq!(meta.fan_out_warning, Some("12/16".to_string())); - assert!(meta.approximate); - } - - #[test] - fn with_truncation_sets_flags() { - let meta = GraphResponseMeta::with_truncation(10, 6); - assert_eq!(meta.shards_reached, 10); - assert_eq!(meta.shards_skipped, 6); - assert!(meta.truncated); - assert!(meta.fan_out_warning.is_none()); - assert!(meta.approximate); - } - - #[test] - fn with_warning_is_not_clean() { - let meta = GraphResponseMeta::with_warning(12, 4, 16); - assert!(!meta.is_clean()); - } - - #[test] - fn with_truncation_is_not_clean() { - let meta = GraphResponseMeta::with_truncation(10, 6); - assert!(!meta.is_clean()); - } - - #[test] - fn serialization_roundtrip() { - let opts = GraphTraversalOptions { - fan_out_soft: 8, - fan_out_hard: 12, - fan_out_partial: true, - max_visited: 50_000, - }; - let json = sonic_rs::to_string(&opts).unwrap(); - let deserialized: GraphTraversalOptions = sonic_rs::from_str(&json).unwrap(); - assert_eq!(opts.fan_out_soft, deserialized.fan_out_soft); - assert_eq!(opts.fan_out_hard, deserialized.fan_out_hard); - assert_eq!(opts.fan_out_partial, deserialized.fan_out_partial); - assert_eq!(opts.max_visited, deserialized.max_visited); - } - - #[test] - fn meta_serialization_roundtrip() { - let meta = GraphResponseMeta::with_warning(15, 1, 16); - let json = sonic_rs::to_string(&meta).unwrap(); - let deserialized: GraphResponseMeta = sonic_rs::from_str(&json).unwrap(); - assert_eq!(meta.shards_reached, deserialized.shards_reached); - assert_eq!(meta.shards_skipped, deserialized.shards_skipped); - assert_eq!(meta.truncated, deserialized.truncated); - assert_eq!(meta.fan_out_warning, deserialized.fan_out_warning); - assert_eq!(meta.approximate, deserialized.approximate); - } -} +//! Re-export shim — types live in `nodedb-graph`. +pub use nodedb_graph::traversal_options::{ + GraphResponseMeta, GraphTraversalOptions, MAX_GRAPH_TRAVERSAL_DEPTH, +}; diff --git a/nodedb/src/engine/timeseries/retention_policy/autowire.rs b/nodedb/src/engine/timeseries/retention_policy/autowire.rs index ad351e3bb..9aafb2d37 100644 --- a/nodedb/src/engine/timeseries/retention_policy/autowire.rs +++ b/nodedb/src/engine/timeseries/retention_policy/autowire.rs @@ -9,11 +9,11 @@ use std::time::Duration; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; use crate::engine::timeseries::continuous_agg::{ContinuousAggregateDef, RefreshPolicy}; use crate::engine::timeseries::retention_policy::types::RetentionPolicyDef; use crate::types::TenantId; +use nodedb_physical::physical_plan::MetaOp; /// Register continuous aggregates for all downsample tiers in a retention policy. /// diff --git a/nodedb/src/engine/timeseries/retention_policy/enforcement.rs b/nodedb/src/engine/timeseries/retention_policy/enforcement.rs index 8f6916500..0bfeaf02f 100644 --- a/nodedb/src/engine/timeseries/retention_policy/enforcement.rs +++ b/nodedb/src/engine/timeseries/retention_policy/enforcement.rs @@ -20,10 +20,10 @@ use tokio::sync::watch; use tracing::{info, warn}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::MetaOp; use crate::control::state::SharedState; use crate::engine::timeseries::retention_policy::RetentionPolicyRegistry; use crate::types::TenantId; +use nodedb_physical::physical_plan::MetaOp; /// Spawn the retention policy enforcement loop as a background Tokio task. /// diff --git a/nodedb/src/error.rs b/nodedb/src/error.rs index 750edf628..e78019967 100644 --- a/nodedb/src/error.rs +++ b/nodedb/src/error.rs @@ -379,6 +379,41 @@ pub enum Error { }, } +impl From for Error { + fn from(e: nodedb_physical::physical_plan::wire::WireError) -> Self { + Error::Internal { + detail: e.to_string(), + } + } +} + +impl From for Error { + fn from(e: nodedb_physical::ConvertError) -> Self { + use nodedb_physical::ConvertError; + match e { + ConvertError::PlanError(detail) => Error::PlanError { detail }, + ConvertError::BadRequest(detail) => Error::BadRequest { detail }, + ConvertError::LimitExceeded { + limit_name, + value, + max, + } => Error::LimitExceeded { + limit_name, + value, + max, + }, + ConvertError::Surrogate(s) => Error::Internal { + detail: s.to_string(), + }, + ConvertError::Serialization(detail) => Error::Serialization { + format: "msgpack".into(), + detail, + }, + ConvertError::Other(detail) => Error::Internal { detail }, + } + } +} + /// Result alias for NodeDB operations. pub type Result = std::result::Result; diff --git a/nodedb/src/event/alert/executor.rs b/nodedb/src/event/alert/executor.rs index 8ba67a735..d6b4bf42d 100644 --- a/nodedb/src/event/alert/executor.rs +++ b/nodedb/src/event/alert/executor.rs @@ -19,10 +19,10 @@ use tokio::sync::watch; use tracing::{debug, info, warn}; use crate::bridge::envelope::PhysicalPlan; -use crate::bridge::physical_plan::timeseries::TimeseriesOp; use crate::control::server::pgwire::ddl::sync_dispatch; use crate::control::state::SharedState; use crate::types::TenantId; +use nodedb_physical::physical_plan::timeseries::TimeseriesOp; use super::hysteresis::HysteresisTransition; use super::notify::dispatch_notifications; diff --git a/nodedb/src/types/id.rs b/nodedb/src/types/id.rs index 54c2b543f..852186954 100644 --- a/nodedb/src/types/id.rs +++ b/nodedb/src/types/id.rs @@ -1,42 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 -use std::fmt; - -use serde::{Deserialize, Serialize}; - // ── Re-export shared types from nodedb-types ── -pub use nodedb_types::id::{DatabaseId, DocumentId, TenantId, VShardId}; - -/// Globally unique request identifier. Monotonic per connection, unique for >= 24h. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - zerompk::ToMessagePack, - zerompk::FromMessagePack, -)] -pub struct RequestId(u64); - -impl RequestId { - pub const fn new(id: u64) -> Self { - Self(id) - } - - pub const fn as_u64(self) -> u64 { - self.0 - } -} - -impl fmt::Display for RequestId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "req:{}", self.0) - } -} +pub use nodedb_types::id::{DatabaseId, DocumentId, RequestId, TenantId, VShardId}; #[cfg(test)] mod tests { From 3e5bcc2a38ca2b6adfef17a399e0a476f48f8e7f Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Sun, 17 May 2026 07:37:37 +0800 Subject: [PATCH 02/11] feat(vector): add multi-dtype storage support for HNSW indexes Introduce `VectorStorageDtype` (F32, F16, BF16, I8) as a first-class parameter on `HnswParams`, allowing vectors to be stored and searched in reduced-precision formats without an intermediate conversion step. Key changes: - `nodedb-types/src/vector_dtype.rs`: new dtype enum with serde support and backward-compatible default (F32) - `nodedb-vector/src/dtype/`: cast_from_f32 for encoding on insert, cast_to_f32 for decoding on results - `nodedb-vector/src/distance/typed_scalar.rs`: pure-Rust F16/BF16 distance kernels (L2, cosine, inner product) using the `half` crate - `nodedb-vector/src/distance/dispatch.rs`: unified distance dispatcher that routes to the correct dtype kernel via SimdRuntime - `nodedb-vector/src/distance/simd/`: per-arch typed SIMD kernels (AVX2, AVX-512, NEON, WASM-SIMD128) for F16 and BF16 - `nodedb-vector/src/hnsw/graph/`: HNSW graph split into index.rs and types.rs; `NodeStorage` enum replaces bare `Vec` on Node, storing either F32 or packed bytes for half/int8 dtypes - `nodedb-vector/src/rerank/`: re-ranking pipeline (gating, pipeline, sidecar, codecs) for approximate-then-exact search flows - `nodedb-vector/src/collection/sidecar_build.rs`: sidecar index construction helpers - Executor handlers updated to propagate `dtype` through HnswParams during lifecycle ops and WAL replay --- nodedb-types/src/hnsw.rs | 6 + nodedb-types/src/lib.rs | 1 + nodedb-types/src/vector_dtype.rs | 168 +++++ nodedb-vector/src/adaptive_filter.rs | 1 + nodedb-vector/src/collection/checkpoint.rs | 1 + nodedb-vector/src/collection/sidecar_build.rs | 147 ++++ nodedb-vector/src/distance/compute.rs | 58 ++ nodedb-vector/src/distance/dispatch.rs | 275 +++++++ nodedb-vector/src/distance/mod.rs | 2 + nodedb-vector/src/distance/simd/avx2_typed.rs | 412 +++++++++++ .../src/distance/simd/avx512_typed.rs | 490 +++++++++++++ nodedb-vector/src/distance/simd/neon_typed.rs | 454 ++++++++++++ nodedb-vector/src/distance/simd/runtime.rs | 36 + .../src/distance/simd/wasm_simd128.rs | 233 ++++++ nodedb-vector/src/distance/typed_scalar.rs | 290 ++++++++ nodedb-vector/src/dtype/cast.rs | 310 ++++++++ nodedb-vector/src/dtype/mod.rs | 5 + nodedb-vector/src/hnsw/build.rs | 56 +- nodedb-vector/src/hnsw/checkpoint.rs | 8 +- .../src/hnsw/{graph.rs => graph/index.rs} | 272 ++++--- nodedb-vector/src/hnsw/graph/mod.rs | 24 + nodedb-vector/src/hnsw/graph/types.rs | 108 +++ nodedb-vector/src/hnsw/search.rs | 34 +- nodedb-vector/src/lib.rs | 1 + nodedb-vector/src/navix/acorn.rs | 1 + nodedb-vector/src/navix/traversal.rs | 2 + nodedb-vector/src/rerank/codec.rs | 123 ++++ nodedb-vector/src/rerank/codecs/bbq.rs | 365 ++++++++++ nodedb-vector/src/rerank/codecs/binary.rs | 224 ++++++ nodedb-vector/src/rerank/codecs/mod.rs | 13 + nodedb-vector/src/rerank/codecs/pq.rs | 321 +++++++++ nodedb-vector/src/rerank/codecs/rabitq.rs | 338 +++++++++ nodedb-vector/src/rerank/codecs/sq8.rs | 239 ++++++ nodedb-vector/src/rerank/gating.rs | 373 ++++++++++ nodedb-vector/src/rerank/mod.rs | 17 + nodedb-vector/src/rerank/pipeline.rs | 679 ++++++++++++++++++ nodedb-vector/src/rerank/recall.rs | 112 +++ nodedb-vector/src/rerank/sidecar.rs | 435 +++++++++++ nodedb-vector/src/rerank/types.rs | 26 + nodedb-vector/src/sieve/collection.rs | 1 + nodedb-vector/src/sieve/router.rs | 1 + nodedb-vector/tests/collection_pq_config.rs | 1 + nodedb-vector/tests/hnsw_layer_cap.rs | 2 + nodedb/src/data/executor/handlers/vector.rs | 1 + .../executor/handlers/vector_lifecycle.rs | 1 + nodedb/src/data/executor/wal_replay.rs | 1 + 46 files changed, 6545 insertions(+), 123 deletions(-) create mode 100644 nodedb-types/src/vector_dtype.rs create mode 100644 nodedb-vector/src/collection/sidecar_build.rs create mode 100644 nodedb-vector/src/distance/compute.rs create mode 100644 nodedb-vector/src/distance/dispatch.rs create mode 100644 nodedb-vector/src/distance/simd/avx2_typed.rs create mode 100644 nodedb-vector/src/distance/simd/avx512_typed.rs create mode 100644 nodedb-vector/src/distance/simd/neon_typed.rs create mode 100644 nodedb-vector/src/distance/simd/wasm_simd128.rs create mode 100644 nodedb-vector/src/distance/typed_scalar.rs create mode 100644 nodedb-vector/src/dtype/cast.rs create mode 100644 nodedb-vector/src/dtype/mod.rs rename nodedb-vector/src/hnsw/{graph.rs => graph/index.rs} (59%) create mode 100644 nodedb-vector/src/hnsw/graph/mod.rs create mode 100644 nodedb-vector/src/hnsw/graph/types.rs create mode 100644 nodedb-vector/src/rerank/codec.rs create mode 100644 nodedb-vector/src/rerank/codecs/bbq.rs create mode 100644 nodedb-vector/src/rerank/codecs/binary.rs create mode 100644 nodedb-vector/src/rerank/codecs/mod.rs create mode 100644 nodedb-vector/src/rerank/codecs/pq.rs create mode 100644 nodedb-vector/src/rerank/codecs/rabitq.rs create mode 100644 nodedb-vector/src/rerank/codecs/sq8.rs create mode 100644 nodedb-vector/src/rerank/gating.rs create mode 100644 nodedb-vector/src/rerank/mod.rs create mode 100644 nodedb-vector/src/rerank/pipeline.rs create mode 100644 nodedb-vector/src/rerank/recall.rs create mode 100644 nodedb-vector/src/rerank/sidecar.rs create mode 100644 nodedb-vector/src/rerank/types.rs diff --git a/nodedb-types/src/hnsw.rs b/nodedb-types/src/hnsw.rs index 037416246..724c08fb0 100644 --- a/nodedb-types/src/hnsw.rs +++ b/nodedb-types/src/hnsw.rs @@ -3,6 +3,7 @@ //! Shared HNSW types used by both Origin and Lite vector engines. use crate::vector_distance::DistanceMetric; +use crate::vector_dtype::VectorStorageDtype; use serde::{Deserialize, Serialize}; /// HNSW index parameters shared between Origin and Lite. @@ -16,6 +17,10 @@ pub struct HnswParams { pub ef_construction: usize, /// Distance metric for similarity computation. pub metric: DistanceMetric, + /// On-disk + in-memory vector storage dtype. Defaults to F32 for + /// backward compatibility with indexes created before this field existed. + #[serde(default)] + pub dtype: VectorStorageDtype, } impl Default for HnswParams { @@ -25,6 +30,7 @@ impl Default for HnswParams { m0: 32, ef_construction: 200, metric: DistanceMetric::Cosine, + dtype: VectorStorageDtype::F32, } } } diff --git a/nodedb-types/src/lib.rs b/nodedb-types/src/lib.rs index 6c54efec9..826575755 100644 --- a/nodedb-types/src/lib.rs +++ b/nodedb-types/src/lib.rs @@ -56,6 +56,7 @@ pub mod typeguard; pub mod value; pub mod vector_ann; pub mod vector_distance; +pub mod vector_dtype; pub mod vector_index_stats; pub mod vector_model; pub mod wire_version; diff --git a/nodedb-types/src/vector_dtype.rs b/nodedb-types/src/vector_dtype.rs new file mode 100644 index 000000000..04d54617b --- /dev/null +++ b/nodedb-types/src/vector_dtype.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Vector storage precision tag. +//! +//! Selects the on-disk + in-memory dtype for vector storage on a per-collection +//! basis. Independent of quantization (see [`crate::vector_ann::VectorQuantization`]): +//! a collection can be `(F32, None)`, `(BF16, None)`, `(F32, RaBitQ)`, +//! `(BF16, RaBitQ)`, etc. Storage dtype controls the durable form; quantization +//! is an optional search-time overlay on top. + +/// Vector storage dtype for HNSW + flat indexes. +/// +/// `F32` is the default and the historical NodeDB storage form. `F16` and `BF16` +/// give 2x memory + disk savings with negligible recall loss for typical +/// embedding workloads, at the cost of a slightly more expensive distance kernel +/// (F16/BF16 must up-convert to F32 for arithmetic on hardware without native +/// half-precision FMA, e.g., pre-AVX-512-FP16 x86). +/// +/// `FP8` (E4M3 / E5M2) is deliberately omitted from this release; it is rare in +/// vector-search workloads relative to the conversion-surface cost of supporting +/// it, and the recall hit on typical embeddings (1.5-bit-ish effective mantissa +/// precision) is severe. Reconsider when there is concrete user demand. +#[repr(u8)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Hash, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +#[msgpack(c_enum)] +#[non_exhaustive] +pub enum VectorStorageDtype { + /// 32-bit IEEE 754 single precision. Default; 4 bytes per dim. + #[default] + F32 = 0, + /// 16-bit IEEE 754 half precision. 2 bytes per dim. ~3 decimal digits of + /// precision; ~6e-5 to 65504 range. + F16 = 1, + /// 16-bit Brain Float (Google bfloat16). 2 bytes per dim. Same exponent + /// range as F32 (~1e-38 to 3.4e38) but only ~7-bit mantissa. Better + /// dynamic range than F16; preferred for embedding workloads. + BF16 = 2, +} + +impl VectorStorageDtype { + /// Bytes occupied per vector dimension at this dtype. + pub const fn bytes_per_dim(self) -> usize { + match self { + Self::F32 => 4, + Self::F16 => 2, + Self::BF16 => 2, + } + } + + /// Total bytes needed to store `dim`-dimensional vector in this dtype. + pub const fn bytes_for_dim(self, dim: usize) -> usize { + dim * self.bytes_per_dim() + } + + /// Stable lowercase string identifier — used in DDL parsing + /// (`WITH (storage_dtype='bf16')`) and in error messages. + pub const fn as_str(self) -> &'static str { + match self { + Self::F32 => "f32", + Self::F16 => "f16", + Self::BF16 => "bf16", + } + } + + /// Parse from the lowercase identifier. Returns `None` for unknown values; + /// the caller wraps that in a typed error (e.g., `NodeDbError::bad_request`) + /// with a precise message naming the offending value. + pub fn parse(s: &str) -> Option { + match s { + "f32" => Some(Self::F32), + "f16" => Some(Self::F16), + "bf16" => Some(Self::BF16), + _ => None, + } + } +} + +impl core::str::FromStr for VectorStorageDtype { + type Err = (); + + fn from_str(s: &str) -> Result { + Self::parse(s).ok_or(()) + } +} + +impl core::fmt::Display for VectorStorageDtype { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_f32() { + assert_eq!(VectorStorageDtype::default(), VectorStorageDtype::F32); + } + + #[test] + fn bytes_per_dim_matches_iec_widths() { + assert_eq!(VectorStorageDtype::F32.bytes_per_dim(), 4); + assert_eq!(VectorStorageDtype::F16.bytes_per_dim(), 2); + assert_eq!(VectorStorageDtype::BF16.bytes_per_dim(), 2); + } + + #[test] + fn bytes_for_dim_is_dim_times_width() { + assert_eq!(VectorStorageDtype::F32.bytes_for_dim(128), 512); + assert_eq!(VectorStorageDtype::BF16.bytes_for_dim(1536), 3072); + assert_eq!(VectorStorageDtype::F16.bytes_for_dim(256), 512); + } + + #[test] + fn as_str_roundtrips_from_str() { + for v in [ + VectorStorageDtype::F32, + VectorStorageDtype::F16, + VectorStorageDtype::BF16, + ] { + assert_eq!(VectorStorageDtype::parse(v.as_str()), Some(v)); + } + } + + #[test] + fn from_str_unknown_returns_none() { + assert_eq!(VectorStorageDtype::parse("fp8"), None); + assert_eq!(VectorStorageDtype::parse("F32"), None); + assert_eq!(VectorStorageDtype::parse(""), None); + } + + #[test] + fn display_matches_as_str() { + for v in [ + VectorStorageDtype::F32, + VectorStorageDtype::F16, + VectorStorageDtype::BF16, + ] { + assert_eq!(format!("{}", v), v.as_str()); + } + } + + #[test] + fn msgpack_roundtrip() { + for v in [ + VectorStorageDtype::F32, + VectorStorageDtype::F16, + VectorStorageDtype::BF16, + ] { + let bytes = zerompk::to_msgpack_vec(&v).unwrap(); + let restored: VectorStorageDtype = zerompk::from_msgpack(&bytes).unwrap(); + assert_eq!(restored, v); + } + } +} diff --git a/nodedb-vector/src/adaptive_filter.rs b/nodedb-vector/src/adaptive_filter.rs index d044b3f24..9b3c59430 100644 --- a/nodedb-vector/src/adaptive_filter.rs +++ b/nodedb-vector/src/adaptive_filter.rs @@ -138,6 +138,7 @@ mod tests { m0: 16, ef_construction: 50, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 42, ); diff --git a/nodedb-vector/src/collection/checkpoint.rs b/nodedb-vector/src/collection/checkpoint.rs index bca69f015..f3a849039 100644 --- a/nodedb-vector/src/collection/checkpoint.rs +++ b/nodedb-vector/src/collection/checkpoint.rs @@ -287,6 +287,7 @@ impl VectorCollection { m0: snap.params_m0, ef_construction: snap.params_ef_construction, metric, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }; let mut growing = FlatIndex::new(snap.dim, metric); diff --git a/nodedb-vector/src/collection/sidecar_build.rs b/nodedb-vector/src/collection/sidecar_build.rs new file mode 100644 index 000000000..177bec5a6 --- /dev/null +++ b/nodedb-vector/src/collection/sidecar_build.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Factory function for building a [`CodecSidecar`] from a collection's live vectors. +//! +//! Called from `complete_build` and from the checkpoint restore path. Instantiates +//! the appropriate `RerankCodec` wrapper, trains it when needed, encodes all provided +//! (id, vec) pairs into the sidecar, and returns it. + +use std::sync::Arc; + +use nodedb_types::VectorQuantization; + +use crate::error::VectorError; +use crate::rerank::codec::RerankCodec; +use crate::rerank::codecs::bbq::DEFAULT_OVERSAMPLE; +use crate::rerank::codecs::rabitq::DEFAULT_ROTATION_SEED; +use crate::rerank::codecs::{BbqRerank, BinaryRerank, PqRerank, RaBitQRerank, Sq8Rerank}; +use crate::rerank::sidecar::CodecSidecar; + +/// Maximum training samples fed to codecs that run k-means internally. +/// Larger sets add training time with diminishing accuracy returns. +const MAX_TRAINING_SAMPLES: usize = 10_000; + +/// Build a [`CodecSidecar`] for the given quantization over all provided (id, vec) pairs. +/// +/// - `quantization == None` → returns `Ok(None)` (no sidecar needed). +/// - `Sq8` / `Binary` → no external training needed; codec is functional after `new`. +/// - `Pq` / `RaBitQ` / `Bbq` → trains from the provided sample vectors (capped at +/// `MAX_TRAINING_SAMPLES` for efficiency), then encodes all vectors. +/// - `Ternary` / `Opq` → returns `Err(VectorError::BadInput(...))` matching the +/// existing `validate_options` gate: these quantization variants have no HNSW-integrated +/// path yet. +/// +/// After training, every (id, vec) pair is encoded into the sidecar. Individual +/// encode failures emit a `tracing::warn` and are skipped; the sidecar may be +/// partially populated in that case, and affected rows degrade to FP32 rerank. +pub(crate) fn build_sidecar( + quantization: VectorQuantization, + dim: usize, + samples: &[(u32, Vec)], +) -> Result, VectorError> { + if samples.is_empty() { + // Nothing to train on or encode — return an empty sidecar for non-None quantizations + // so the collection is marked as having one (future inserts will populate it). + if quantization == VectorQuantization::None { + return Ok(None); + } + } + + let codec: Arc = match quantization { + VectorQuantization::None => return Ok(None), + + VectorQuantization::Sq8 => { + let mut codec = Sq8Rerank::new(dim); + if !samples.is_empty() { + let vecs: Vec<&[f32]> = samples + .iter() + .take(MAX_TRAINING_SAMPLES) + .map(|(_, v)| v.as_slice()) + .collect(); + codec + .train(&vecs) + .map_err(|e| VectorError::BadInput(format!("sq8 sidecar train failed: {e}")))?; + } + Arc::new(codec) + } + + VectorQuantization::Binary => { + // Binary has no learned state — new() is fully functional. + Arc::new(BinaryRerank::new(dim)) + } + + VectorQuantization::Pq => { + let mut codec = PqRerank::new(dim, 8, 256); + if !samples.is_empty() { + let vecs: Vec<&[f32]> = samples + .iter() + .take(MAX_TRAINING_SAMPLES) + .map(|(_, v)| v.as_slice()) + .collect(); + codec + .train(&vecs) + .map_err(|e| VectorError::BadInput(format!("pq sidecar train failed: {e}")))?; + } + Arc::new(codec) + } + + VectorQuantization::RaBitQ => { + let mut codec = RaBitQRerank::new(dim, DEFAULT_ROTATION_SEED); + if !samples.is_empty() { + let vecs: Vec<&[f32]> = samples + .iter() + .take(MAX_TRAINING_SAMPLES) + .map(|(_, v)| v.as_slice()) + .collect(); + codec.train(&vecs).map_err(|e| { + VectorError::BadInput(format!("rabitq sidecar train failed: {e}")) + })?; + } + Arc::new(codec) + } + + VectorQuantization::Bbq => { + let mut codec = BbqRerank::new(dim, DEFAULT_OVERSAMPLE); + if !samples.is_empty() { + let vecs: Vec<&[f32]> = samples + .iter() + .take(MAX_TRAINING_SAMPLES) + .map(|(_, v)| v.as_slice()) + .collect(); + codec + .train(&vecs) + .map_err(|e| VectorError::BadInput(format!("bbq sidecar train failed: {e}")))?; + } + Arc::new(codec) + } + + VectorQuantization::Ternary | VectorQuantization::Opq => { + return Err(VectorError::BadInput(format!( + "quantization {:?} has no HNSW-integrated sidecar path yet", + quantization + ))); + } + + // Exhaustive match: any new variant added to VectorQuantization must be handled here. + _ => { + return Err(VectorError::BadInput(format!( + "quantization {:?} is not handled by the sidecar builder", + quantization + ))); + } + }; + + let mut sidecar = CodecSidecar::new(codec); + + for (id, vec) in samples { + if let Err(e) = sidecar.encode_and_insert(*id, vec) { + tracing::warn!( + id, + error = %e, + "sidecar build: encode_and_insert failed; this vector will fall back to FP32 rerank" + ); + } + } + + Ok(Some(sidecar)) +} diff --git a/nodedb-vector/src/distance/compute.rs b/nodedb-vector/src/distance/compute.rs new file mode 100644 index 000000000..eb4ec5ef1 --- /dev/null +++ b/nodedb-vector/src/distance/compute.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::distance::scalar::*; +use crate::distance::simd; +use nodedb_types::vector_distance::DistanceMetric; + +/// Compute distance between two vectors using the specified metric. +/// +/// Dispatches to SIMD kernels (AVX-512, AVX2+FMA, NEON) where available; +/// falls back to scalar implementations on other architectures. +#[inline] +pub fn distance(a: &[f32], b: &[f32], metric: DistanceMetric) -> f32 { + assert_eq!( + a.len(), + b.len(), + "distance: length mismatch (a.len()={}, b.len()={})", + a.len(), + b.len() + ); + let rt = simd::runtime(); + match metric { + DistanceMetric::L2 => (rt.l2_squared)(a, b), + DistanceMetric::Cosine => (rt.cosine_distance)(a, b), + DistanceMetric::InnerProduct => (rt.neg_inner_product)(a, b), + DistanceMetric::Manhattan => manhattan(a, b), + DistanceMetric::Chebyshev => chebyshev(a, b), + DistanceMetric::Hamming => hamming_f32(a, b), + DistanceMetric::Jaccard => jaccard(a, b), + DistanceMetric::Pearson => pearson(a, b), + // DistanceMetric is #[non_exhaustive]; unknown future variants fall back to L2. + _ => (rt.l2_squared)(a, b), + } +} + +/// Batch distance: compute distances from `query` to each candidate. +/// +/// Returns `(index, distance)` pairs sorted ascending, truncated to `top_k`. +pub fn batch_distances( + query: &[f32], + candidates: &[&[f32]], + metric: DistanceMetric, + top_k: usize, +) -> Vec<(usize, f32)> { + let mut dists: Vec<(usize, f32)> = candidates + .iter() + .enumerate() + .map(|(i, c)| (i, distance(query, c, metric))) + .collect(); + + if top_k < dists.len() { + dists.select_nth_unstable_by(top_k, |a, b| { + a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal) + }); + dists.truncate(top_k); + } + dists.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + dists +} diff --git a/nodedb-vector/src/distance/dispatch.rs b/nodedb-vector/src/distance/dispatch.rs new file mode 100644 index 000000000..f510d82c3 --- /dev/null +++ b/nodedb-vector/src/distance/dispatch.rs @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Dtype-aware distance dispatch shim. +//! +//! Routes byte-encoded vector pairs to the correct distance kernel based on +//! dtype and metric. F16 / BF16 inputs on the three hot metrics (L2, Cosine, +//! InnerProduct) use fused decode-and-compute kernels from `typed_scalar`, +//! eliminating the intermediate `Vec` allocation. Rare metrics (Manhattan, +//! Chebyshev, Hamming, Jaccard, Pearson) fall through to `cast_to_f32` + +//! `distance()` — correctness is identical and they are not on the hot path. +//! The F32 path is always a straight cast + delegate (no conversion needed). + +use nodedb_types::vector_distance::DistanceMetric; +use nodedb_types::vector_dtype::VectorStorageDtype; + +use crate::dtype::{DtypeError, cast_to_f32, validate_byte_len}; + +/// Error type for [`distance_typed`]. +#[derive(thiserror::Error, Debug)] +pub enum DistanceError { + /// The two input buffers encode different numbers of dimensions. + #[error("distance: dim mismatch (a: {a_dim}, b: {b_dim})")] + DimMismatch { a_dim: usize, b_dim: usize }, + + /// A byte buffer has the wrong length for the given dtype and dim. + #[error("distance: dtype byte-length error: {0}")] + Dtype(#[from] DtypeError), +} + +/// Compute distance between two byte-encoded vectors of the given dtype. +/// +/// Both buffers must encode `dim` dimensions in `dtype`. +/// +/// - **F16 / BF16 + L2 / Cosine / InnerProduct**: fused decode-and-compute via +/// [`typed_scalar`] — no intermediate `Vec` allocation. +/// - **F16 / BF16 + other metrics**: up-converts to F32 via [`cast_to_f32`] +/// then delegates to [`crate::distance::distance`]. These metrics are not +/// used in embedding search hot paths; the allocation is acceptable. +/// - **F32**: up-converts via [`cast_to_f32`] (memcopy) then delegates. +/// +/// # Errors +/// +/// - [`DistanceError::Dtype`] — if either buffer's length does not match +/// `dtype.bytes_for_dim(dim)`. +/// - [`DistanceError::DimMismatch`] — reserved for asymmetric validation; +/// currently both sides are checked against the same `dim`, so this variant +/// is unreachable in normal use but is preserved for future asymmetric checks. +pub fn distance_typed( + metric: DistanceMetric, + dtype: VectorStorageDtype, + a_bytes: &[u8], + b_bytes: &[u8], + dim: usize, +) -> Result { + validate_byte_len(a_bytes, dtype, dim)?; + validate_byte_len(b_bytes, dtype, dim)?; + + match dtype { + VectorStorageDtype::F32 => { + let a_f32 = cast_to_f32(a_bytes, dtype, dim)?; + let b_f32 = cast_to_f32(b_bytes, dtype, dim)?; + Ok(crate::distance::distance(&a_f32, &b_f32, metric)) + } + VectorStorageDtype::F16 => Ok(match metric { + DistanceMetric::L2 => { + (crate::distance::simd::runtime().l2_squared_f16)(a_bytes, b_bytes, dim) + } + DistanceMetric::Cosine => { + (crate::distance::simd::runtime().cosine_distance_f16)(a_bytes, b_bytes, dim) + } + DistanceMetric::InnerProduct => { + (crate::distance::simd::runtime().neg_inner_product_f16)(a_bytes, b_bytes, dim) + } + // Non-hot metrics (Manhattan, Chebyshev, Hamming, Jaccard, Pearson) + // are not used in embedding search; cast+delegate is correct and the + // allocation overhead is acceptable on this path. + _ => { + let a_f32 = cast_to_f32(a_bytes, dtype, dim)?; + let b_f32 = cast_to_f32(b_bytes, dtype, dim)?; + crate::distance::distance(&a_f32, &b_f32, metric) + } + }), + VectorStorageDtype::BF16 => Ok(match metric { + DistanceMetric::L2 => { + (crate::distance::simd::runtime().l2_squared_bf16)(a_bytes, b_bytes, dim) + } + DistanceMetric::Cosine => { + (crate::distance::simd::runtime().cosine_distance_bf16)(a_bytes, b_bytes, dim) + } + DistanceMetric::InnerProduct => { + (crate::distance::simd::runtime().neg_inner_product_bf16)(a_bytes, b_bytes, dim) + } + // Non-hot metrics fall through to cast+delegate (same rationale as F16 arm). + _ => { + let a_f32 = cast_to_f32(a_bytes, dtype, dim)?; + let b_f32 = cast_to_f32(b_bytes, dtype, dim)?; + crate::distance::distance(&a_f32, &b_f32, metric) + } + }), + // `VectorStorageDtype` is #[non_exhaustive]; any future variant falls + // back to the cast path, which will return DtypeError if it cannot handle + // the variant. This arm is not reachable with any currently-defined dtype. + _ => { + let a_f32 = cast_to_f32(a_bytes, dtype, dim)?; + let b_f32 = cast_to_f32(b_bytes, dtype, dim)?; + Ok(crate::distance::distance(&a_f32, &b_f32, metric)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dtype::cast_from_f32; + + const EPS_F32: f32 = 1e-6; + const EPS_F16: f32 = 1e-2; + const EPS_BF16: f32 = 1e-1; + + // Fixed vector pair used across all tests. + const A: [f32; 4] = [1.0, 2.0, 3.0, 4.0]; + const B: [f32; 4] = [4.0, 3.0, 2.0, 1.0]; + + fn f32_ref(metric: DistanceMetric) -> f32 { + crate::distance::distance(&A, &B, metric) + } + + // ── F32 path: byte-exact match with direct distance() ──────────────────── + + #[test] + fn f32_path_matches_direct_distance() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::F32); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F32); + for metric in [ + DistanceMetric::L2, + DistanceMetric::Cosine, + DistanceMetric::InnerProduct, + ] { + let via_typed = distance_typed(metric, VectorStorageDtype::F32, &a_bytes, &b_bytes, 4) + .expect("F32 typed distance must not fail"); + let via_direct = f32_ref(metric); + assert_eq!( + via_typed, via_direct, + "F32 typed vs direct mismatch for {metric:?}" + ); + } + } + + // ── F16 round-trip within tolerance ────────────────────────────────────── + + #[test] + fn f16_round_trip_within_tolerance() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::F16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F16); + for metric in [ + DistanceMetric::L2, + DistanceMetric::Cosine, + DistanceMetric::InnerProduct, + ] { + let via_typed = distance_typed(metric, VectorStorageDtype::F16, &a_bytes, &b_bytes, 4) + .expect("F16 typed distance must not fail"); + let reference = f32_ref(metric); + assert!( + (via_typed - reference).abs() < EPS_F16, + "F16 typed distance for {metric:?}: got {via_typed}, ref {reference}, diff {}", + (via_typed - reference).abs() + ); + } + } + + // ── BF16 round-trip within tolerance ───────────────────────────────────── + + #[test] + fn bf16_round_trip_within_tolerance() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::BF16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::BF16); + for metric in [ + DistanceMetric::L2, + DistanceMetric::Cosine, + DistanceMetric::InnerProduct, + ] { + let via_typed = distance_typed(metric, VectorStorageDtype::BF16, &a_bytes, &b_bytes, 4) + .expect("BF16 typed distance must not fail"); + let reference = f32_ref(metric); + assert!( + (via_typed - reference).abs() < EPS_BF16, + "BF16 typed distance for {metric:?}: got {via_typed}, ref {reference}, diff {}", + (via_typed - reference).abs() + ); + } + } + + // ── Dim mismatch is caught ──────────────────────────────────────────────── + + #[test] + fn dim_mismatch_returns_dtype_error() { + // a_bytes encodes 2 F32 dims (8 bytes); b_bytes encodes 4 F32 dims (16 bytes). + // validate_byte_len(b_bytes, F32, 2) should fire with BadByteLen. + let a_bytes = [0u8; 8]; + let b_bytes = [0u8; 16]; + let err = distance_typed( + DistanceMetric::L2, + VectorStorageDtype::F32, + &a_bytes, + &b_bytes, + 2, + ) + .expect_err("mismatched buffer must return an error"); + match err { + DistanceError::Dtype(DtypeError::BadByteLen { + dtype, + dim, + expected, + actual, + }) => { + assert_eq!(dtype, VectorStorageDtype::F32); + assert_eq!(dim, 2); + assert_eq!(expected, 8); + assert_eq!(actual, 16); + } + other => panic!("expected DistanceError::Dtype(BadByteLen), got {other:?}"), + } + } + + // ── All three metrics × all three dtypes: finite + non-NaN ─────────────── + + #[test] + fn all_metrics_all_dtypes_finite_non_nan() { + let metrics = [ + DistanceMetric::L2, + DistanceMetric::Cosine, + DistanceMetric::InnerProduct, + ]; + let dtypes = [ + VectorStorageDtype::F32, + VectorStorageDtype::F16, + VectorStorageDtype::BF16, + ]; + for &metric in &metrics { + for &dtype in &dtypes { + let a_bytes = cast_from_f32(&A, dtype); + let b_bytes = cast_from_f32(&B, dtype); + let result = + distance_typed(metric, dtype, &a_bytes, &b_bytes, 4).unwrap_or_else(|e| { + panic!("distance_typed({metric:?}, {dtype:?}) failed: {e}") + }); + assert!( + result.is_finite() && !result.is_nan(), + "distance_typed({metric:?}, {dtype:?}) returned non-finite/NaN: {result}" + ); + } + } + } + + // ── F32 path: result is finite ──────────────────────────────────────────── + + #[test] + fn f32_result_finite() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::F32); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F32); + let result = distance_typed( + DistanceMetric::L2, + VectorStorageDtype::F32, + &a_bytes, + &b_bytes, + 4, + ) + .expect("F32 distance must succeed"); + assert!( + result.is_finite(), + "F32 L2 result must be finite, got {result}" + ); + assert!((result - f32_ref(DistanceMetric::L2)).abs() < EPS_F32); + } +} diff --git a/nodedb-vector/src/distance/mod.rs b/nodedb-vector/src/distance/mod.rs index 770126ea0..446af02c3 100644 --- a/nodedb-vector/src/distance/mod.rs +++ b/nodedb-vector/src/distance/mod.rs @@ -2,8 +2,10 @@ //! Distance metrics for vector similarity search. +pub mod dispatch; pub mod scalar; pub mod simd; +pub(crate) mod typed_scalar; pub use scalar::*; diff --git a/nodedb-vector/src/distance/simd/avx2_typed.rs b/nodedb-vector/src/distance/simd/avx2_typed.rs new file mode 100644 index 000000000..c096607db --- /dev/null +++ b/nodedb-vector/src/distance/simd/avx2_typed.rs @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_arch = "x86_64")] + +//! AVX2 distance kernels for F16 (via F16C conversion) and BF16 (via +//! bit-shift widening). All math runs in F32 inside the kernel; only the +//! load + widen differs from the F32 AVX2 kernels. + +// ── F16 kernels (requires AVX2 + F16C + FMA) ───────────────────────────────── + +/// L2-squared distance between two F16-encoded byte slices (AVX2+F16C+FMA). +pub fn l2_squared_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx2 f16 l2: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx2 f16 l2: b byte len mismatch"); + // SAFETY: caller verified avx2+f16c+fma via is_x86_feature_detected. + unsafe { l2_squared_f16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx2,fma,f16c")] +unsafe fn l2_squared_f16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut sum = _mm256_setzero_ps(); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; // 8 elements × 2 bytes each + let a_packed = _mm_loadu_si128(a.as_ptr().add(off) as *const __m128i); + let b_packed = _mm_loadu_si128(b.as_ptr().add(off) as *const __m128i); + let a_f32 = _mm256_cvtph_ps(a_packed); + let b_f32 = _mm256_cvtph_ps(b_packed); + let diff = _mm256_sub_ps(a_f32, b_f32); + sum = _mm256_fmadd_ps(diff, diff, sum); + } + let mut result = hsum256(sum); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let d = av - bv; + result += d * d; + } + result + } +} + +/// Cosine distance between two F16-encoded byte slices (AVX2+F16C+FMA). +pub fn cosine_distance_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx2 f16 cosine: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx2 f16 cosine: b byte len mismatch"); + unsafe { cosine_f16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx2,fma,f16c")] +unsafe fn cosine_f16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm256_setzero_ps(); + let mut vna = _mm256_setzero_ps(); + let mut vnb = _mm256_setzero_ps(); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let a_packed = _mm_loadu_si128(a.as_ptr().add(off) as *const __m128i); + let b_packed = _mm_loadu_si128(b.as_ptr().add(off) as *const __m128i); + let va = _mm256_cvtph_ps(a_packed); + let vb = _mm256_cvtph_ps(b_packed); + vdot = _mm256_fmadd_ps(va, vb, vdot); + vna = _mm256_fmadd_ps(va, va, vna); + vnb = _mm256_fmadd_ps(vb, vb, vnb); + } + let mut dot = hsum256(vdot); + let mut na = hsum256(vna); + let mut nb = hsum256(vnb); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } + } +} + +/// Negative inner product between two F16-encoded byte slices (AVX2+F16C+FMA). +pub fn neg_inner_product_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx2 f16 ip: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx2 f16 ip: b byte len mismatch"); + unsafe { ip_f16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx2,fma,f16c")] +unsafe fn ip_f16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm256_setzero_ps(); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let a_packed = _mm_loadu_si128(a.as_ptr().add(off) as *const __m128i); + let b_packed = _mm_loadu_si128(b.as_ptr().add(off) as *const __m128i); + let va = _mm256_cvtph_ps(a_packed); + let vb = _mm256_cvtph_ps(b_packed); + vdot = _mm256_fmadd_ps(va, vb, vdot); + } + let mut dot = hsum256(vdot); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot + } +} + +// ── BF16 kernels (requires AVX2 + FMA) ──────────────────────────────────────── + +/// L2-squared distance between two BF16-encoded byte slices (AVX2+FMA). +pub fn l2_squared_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx2 bf16 l2: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx2 bf16 l2: b byte len mismatch"); + unsafe { l2_squared_bf16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx2,fma")] +unsafe fn l2_squared_bf16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut sum = _mm256_setzero_ps(); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let a_packed = _mm_loadu_si128(a.as_ptr().add(off) as *const __m128i); + let b_packed = _mm_loadu_si128(b.as_ptr().add(off) as *const __m128i); + let a_f32 = bf16_to_f32x8(a_packed); + let b_f32 = bf16_to_f32x8(b_packed); + let diff = _mm256_sub_ps(a_f32, b_f32); + sum = _mm256_fmadd_ps(diff, diff, sum); + } + let mut result = hsum256(sum); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let d = av - bv; + result += d * d; + } + result + } +} + +/// Cosine distance between two BF16-encoded byte slices (AVX2+FMA). +pub fn cosine_distance_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx2 bf16 cosine: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx2 bf16 cosine: b byte len mismatch"); + unsafe { cosine_bf16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx2,fma")] +unsafe fn cosine_bf16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm256_setzero_ps(); + let mut vna = _mm256_setzero_ps(); + let mut vnb = _mm256_setzero_ps(); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let a_packed = _mm_loadu_si128(a.as_ptr().add(off) as *const __m128i); + let b_packed = _mm_loadu_si128(b.as_ptr().add(off) as *const __m128i); + let va = bf16_to_f32x8(a_packed); + let vb = bf16_to_f32x8(b_packed); + vdot = _mm256_fmadd_ps(va, vb, vdot); + vna = _mm256_fmadd_ps(va, va, vna); + vnb = _mm256_fmadd_ps(vb, vb, vnb); + } + let mut dot = hsum256(vdot); + let mut na = hsum256(vna); + let mut nb = hsum256(vnb); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } + } +} + +/// Negative inner product between two BF16-encoded byte slices (AVX2+FMA). +pub fn neg_inner_product_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx2 bf16 ip: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx2 bf16 ip: b byte len mismatch"); + unsafe { ip_bf16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx2,fma")] +unsafe fn ip_bf16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm256_setzero_ps(); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let a_packed = _mm_loadu_si128(a.as_ptr().add(off) as *const __m128i); + let b_packed = _mm_loadu_si128(b.as_ptr().add(off) as *const __m128i); + let va = bf16_to_f32x8(a_packed); + let vb = bf16_to_f32x8(b_packed); + vdot = _mm256_fmadd_ps(va, vb, vdot); + } + let mut dot = hsum256(vdot); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot + } +} + +// ── Shared helpers ───────────────────────────────────────────────────────────── + +/// Widen 8 × BF16 (LE u16 in __m128i) to 8 × f32 (__m256) via left-shift. +/// +/// BF16 occupies the upper 16 bits of an f32. Zero-extend u16→u32, shift +/// left 16, reinterpret as f32. +#[target_feature(enable = "avx2")] +unsafe fn bf16_to_f32x8(v: std::arch::x86_64::__m128i) -> std::arch::x86_64::__m256 { + use std::arch::x86_64::*; + let u32s = _mm256_cvtepu16_epi32(v); + let shifted = _mm256_slli_epi32(u32s, 16); + _mm256_castsi256_ps(shifted) +} + +/// Horizontal sum of 8 × f32 in a __m256. +#[target_feature(enable = "avx2")] +unsafe fn hsum256(v: std::arch::x86_64::__m256) -> f32 { + use std::arch::x86_64::*; + let hi = _mm256_extractf128_ps(v, 1); + let lo = _mm256_castps256_ps128(v); + let sum128 = _mm_add_ps(lo, hi); + let shuf = _mm_movehdup_ps(sum128); + let sums = _mm_add_ps(sum128, shuf); + let shuf2 = _mm_movehl_ps(sums, sums); + let sums2 = _mm_add_ss(sums, shuf2); + _mm_cvtss_f32(sums2) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::distance::typed_scalar; + use crate::dtype::cast_from_f32; + use nodedb_types::vector_dtype::VectorStorageDtype; + + const A16: [f32; 16] = [ + 0.5, -1.0, 2.5, 0.1, 1.0, -0.5, 3.0, 0.2, -2.0, 1.5, 0.8, -0.3, 4.0, -1.2, 0.7, 0.9, + ]; + const B16: [f32; 16] = [ + 1.0, 0.5, -1.5, 2.0, -0.5, 1.0, -2.0, 0.3, 1.0, -1.0, 0.4, 0.6, -3.0, 0.8, -0.6, 1.1, + ]; + + const A13: [f32; 13] = [ + 0.5, -1.0, 2.5, 0.1, 1.0, -0.5, 3.0, 0.2, -2.0, 1.5, 0.8, -0.3, 4.0, + ]; + const B13: [f32; 13] = [ + 1.0, 0.5, -1.5, 2.0, -0.5, 1.0, -2.0, 0.3, 1.0, -1.0, 0.4, 0.6, -3.0, + ]; + + // Small ULP-safe margin: both paths produce identical f32 after widening; + // horizontal-sum reordering may introduce a single-ULP difference. + const EPS: f32 = 1e-5; + + #[test] + fn f16_l2_dim16() { + if !std::is_x86_feature_detected!("avx2") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A16, VectorStorageDtype::F16); + let b = cast_from_f32(&B16, VectorStorageDtype::F16); + let simd = l2_squared_f16(&a, &b, 16); + let scalar = typed_scalar::l2_squared_f16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "f16 l2 dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn f16_cosine_dim16() { + if !std::is_x86_feature_detected!("avx2") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A16, VectorStorageDtype::F16); + let b = cast_from_f32(&B16, VectorStorageDtype::F16); + let simd = cosine_distance_f16(&a, &b, 16); + let scalar = typed_scalar::cosine_f16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "f16 cosine dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn f16_neg_ip_dim16() { + if !std::is_x86_feature_detected!("avx2") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A16, VectorStorageDtype::F16); + let b = cast_from_f32(&B16, VectorStorageDtype::F16); + let simd = neg_inner_product_f16(&a, &b, 16); + let scalar = typed_scalar::neg_inner_product_f16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "f16 ip dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_l2_dim16() { + if !std::is_x86_feature_detected!("avx2") { + return; + } + let a = cast_from_f32(&A16, VectorStorageDtype::BF16); + let b = cast_from_f32(&B16, VectorStorageDtype::BF16); + let simd = l2_squared_bf16(&a, &b, 16); + let scalar = typed_scalar::l2_squared_bf16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "bf16 l2 dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_cosine_dim16() { + if !std::is_x86_feature_detected!("avx2") { + return; + } + let a = cast_from_f32(&A16, VectorStorageDtype::BF16); + let b = cast_from_f32(&B16, VectorStorageDtype::BF16); + let simd = cosine_distance_bf16(&a, &b, 16); + let scalar = typed_scalar::cosine_bf16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "bf16 cosine dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_neg_ip_dim16() { + if !std::is_x86_feature_detected!("avx2") { + return; + } + let a = cast_from_f32(&A16, VectorStorageDtype::BF16); + let b = cast_from_f32(&B16, VectorStorageDtype::BF16); + let simd = neg_inner_product_bf16(&a, &b, 16); + let scalar = typed_scalar::neg_inner_product_bf16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "bf16 ip dim16: simd={simd}, scalar={scalar}" + ); + } + + // ── Tail-loop correctness: dim=13 (not a multiple of 8) ────────────────── + + #[test] + fn f16_l2_dim13_tail() { + if !std::is_x86_feature_detected!("avx2") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A13, VectorStorageDtype::F16); + let b = cast_from_f32(&B13, VectorStorageDtype::F16); + let simd = l2_squared_f16(&a, &b, 13); + let scalar = typed_scalar::l2_squared_f16(&a, &b, 13); + assert!( + (simd - scalar).abs() < EPS, + "f16 l2 dim13: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_l2_dim13_tail() { + if !std::is_x86_feature_detected!("avx2") { + return; + } + let a = cast_from_f32(&A13, VectorStorageDtype::BF16); + let b = cast_from_f32(&B13, VectorStorageDtype::BF16); + let simd = l2_squared_bf16(&a, &b, 13); + let scalar = typed_scalar::l2_squared_bf16(&a, &b, 13); + assert!( + (simd - scalar).abs() < EPS, + "bf16 l2 dim13: simd={simd}, scalar={scalar}" + ); + } +} diff --git a/nodedb-vector/src/distance/simd/avx512_typed.rs b/nodedb-vector/src/distance/simd/avx512_typed.rs new file mode 100644 index 000000000..f5c02c402 --- /dev/null +++ b/nodedb-vector/src/distance/simd/avx512_typed.rs @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_arch = "x86_64")] + +//! AVX-512 distance kernels for F16 and BF16 byte buffers. +//! +//! - F16 path: uses `avx512f` + `f16c` widening (16 elements per chunk). +//! Native half-precision via `avx512fp16` (`__m512h` + `_mm512_fmadd_ph`) +//! requires the `stdarch_x86_avx512_f16` unstable feature and is therefore +//! not compiled on stable toolchains. The widen-to-F32 path is used instead. +//! - BF16 path: uses `avx512bf16` (`_mm512_dpbf16_ps` VDPBF16PS) when +//! available for InnerProduct and Cosine — it accumulates dot-products +//! directly in F32. L2 stays in the widen path even on `avx512bf16` because +//! VDPBF16PS computes dot-product, not element-wise diff-square. +//! Falls through to `avx512f` + bit-shift widening otherwise. +//! +//! Each kernel processes 16 elements per iteration (vs AVX2's 8). Tail loop +//! handles `dim % 16` remainder element-wise via scalar decode. + +// ── F16 kernels ─────────────────────────────────────────────────────────────── + +/// L2-squared distance between two F16-encoded byte slices (AVX-512+F16C+FMA). +pub fn l2_squared_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx512 f16 l2: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx512 f16 l2: b byte len mismatch"); + // SAFETY: caller verified avx512f+f16c+fma via is_x86_feature_detected. + unsafe { l2_f16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx512f,f16c,fma")] +unsafe fn l2_f16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut sum = _mm512_setzero_ps(); + let chunks = dim / 16; + for i in 0..chunks { + let off = i * 32; // 16 elements × 2 bytes each + let a_half = _mm256_loadu_si256(a.as_ptr().add(off) as *const __m256i); + let b_half = _mm256_loadu_si256(b.as_ptr().add(off) as *const __m256i); + let va = _mm512_cvtph_ps(a_half); + let vb = _mm512_cvtph_ps(b_half); + let diff = _mm512_sub_ps(va, vb); + sum = _mm512_fmadd_ps(diff, diff, sum); + } + let mut result = _mm512_reduce_add_ps(sum); + for i in (chunks * 16)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let d = av - bv; + result += d * d; + } + result + } +} + +/// Cosine distance between two F16-encoded byte slices (AVX-512+F16C+FMA). +pub fn cosine_distance_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx512 f16 cosine: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx512 f16 cosine: b byte len mismatch"); + unsafe { cosine_f16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx512f,f16c,fma")] +unsafe fn cosine_f16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm512_setzero_ps(); + let mut vna = _mm512_setzero_ps(); + let mut vnb = _mm512_setzero_ps(); + let chunks = dim / 16; + for i in 0..chunks { + let off = i * 32; + let va = _mm512_cvtph_ps(_mm256_loadu_si256(a.as_ptr().add(off) as *const __m256i)); + let vb = _mm512_cvtph_ps(_mm256_loadu_si256(b.as_ptr().add(off) as *const __m256i)); + vdot = _mm512_fmadd_ps(va, vb, vdot); + vna = _mm512_fmadd_ps(va, va, vna); + vnb = _mm512_fmadd_ps(vb, vb, vnb); + } + let mut dot = _mm512_reduce_add_ps(vdot); + let mut na = _mm512_reduce_add_ps(vna); + let mut nb = _mm512_reduce_add_ps(vnb); + for i in (chunks * 16)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } + } +} + +/// Negative inner product between two F16-encoded byte slices (AVX-512+F16C+FMA). +pub fn neg_inner_product_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx512 f16 ip: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx512 f16 ip: b byte len mismatch"); + unsafe { ip_f16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx512f,f16c,fma")] +unsafe fn ip_f16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm512_setzero_ps(); + let chunks = dim / 16; + for i in 0..chunks { + let off = i * 32; + let va = _mm512_cvtph_ps(_mm256_loadu_si256(a.as_ptr().add(off) as *const __m256i)); + let vb = _mm512_cvtph_ps(_mm256_loadu_si256(b.as_ptr().add(off) as *const __m256i)); + vdot = _mm512_fmadd_ps(va, vb, vdot); + } + let mut dot = _mm512_reduce_add_ps(vdot); + for i in (chunks * 16)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot + } +} + +// ── BF16 kernels ────────────────────────────────────────────────────────────── + +/// L2-squared distance between two BF16-encoded byte slices (AVX-512+FMA). +/// +/// Uses widen-to-F32 even when `avx512bf16` is available: `_mm512_dpbf16_ps` +/// computes dot-product, not element-wise diff-square, so it cannot be used +/// for L2 without a separate subtract step that eliminates its advantage. +pub fn l2_squared_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx512 bf16 l2: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx512 bf16 l2: b byte len mismatch"); + unsafe { l2_bf16_impl(a, b, dim) } +} + +#[target_feature(enable = "avx512f,fma")] +unsafe fn l2_bf16_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut sum = _mm512_setzero_ps(); + let chunks = dim / 16; + for i in 0..chunks { + let off = i * 32; + let va = bf16_to_f32x16(_mm256_loadu_si256(a.as_ptr().add(off) as *const __m256i)); + let vb = bf16_to_f32x16(_mm256_loadu_si256(b.as_ptr().add(off) as *const __m256i)); + let diff = _mm512_sub_ps(va, vb); + sum = _mm512_fmadd_ps(diff, diff, sum); + } + let mut result = _mm512_reduce_add_ps(sum); + for i in (chunks * 16)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let d = av - bv; + result += d * d; + } + result + } +} + +/// Cosine distance between two BF16-encoded byte slices. +/// +/// Uses `avx512bf16` (`_mm512_dpbf16_ps`) when available: dot, a-norm, and +/// b-norm are each accumulated in a single pass with three F32 accumulators. +/// Falls through to AVX-512F + bit-shift widening otherwise. +pub fn cosine_distance_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx512 bf16 cosine: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx512 bf16 cosine: b byte len mismatch"); + if std::is_x86_feature_detected!("avx512bf16") { + unsafe { cosine_bf16_dp_impl(a, b, dim) } + } else { + unsafe { cosine_bf16_widen_impl(a, b, dim) } + } +} + +/// Uses VDPBF16PS (avx512bf16): 32 BF16 values per iteration, three accumulators. +#[target_feature(enable = "avx512f,avx512bf16")] +unsafe fn cosine_bf16_dp_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm512_setzero_ps(); + let mut vna = _mm512_setzero_ps(); + let mut vnb = _mm512_setzero_ps(); + // dpbf16_ps processes 32 BF16 values per call (two packed __m512bh). + let chunks = dim / 32; + for i in 0..chunks { + let off = i * 64; // 32 elements × 2 bytes + let ra: __m512i = _mm512_loadu_si512(a.as_ptr().add(off) as *const _); + let rb: __m512i = _mm512_loadu_si512(b.as_ptr().add(off) as *const _); + let ba: __m512bh = std::mem::transmute(ra); + let bb: __m512bh = std::mem::transmute(rb); + vdot = _mm512_dpbf16_ps(vdot, ba, bb); + vna = _mm512_dpbf16_ps(vna, ba, ba); + vnb = _mm512_dpbf16_ps(vnb, bb, bb); + } + let mut dot = _mm512_reduce_add_ps(vdot); + let mut na = _mm512_reduce_add_ps(vna); + let mut nb = _mm512_reduce_add_ps(vnb); + // Scalar tail for remainder elements (dim % 32). + for i in (chunks * 32)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } + } +} + +#[target_feature(enable = "avx512f,fma")] +unsafe fn cosine_bf16_widen_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm512_setzero_ps(); + let mut vna = _mm512_setzero_ps(); + let mut vnb = _mm512_setzero_ps(); + let chunks = dim / 16; + for i in 0..chunks { + let off = i * 32; + let va = bf16_to_f32x16(_mm256_loadu_si256(a.as_ptr().add(off) as *const __m256i)); + let vb = bf16_to_f32x16(_mm256_loadu_si256(b.as_ptr().add(off) as *const __m256i)); + vdot = _mm512_fmadd_ps(va, vb, vdot); + vna = _mm512_fmadd_ps(va, va, vna); + vnb = _mm512_fmadd_ps(vb, vb, vnb); + } + let mut dot = _mm512_reduce_add_ps(vdot); + let mut na = _mm512_reduce_add_ps(vna); + let mut nb = _mm512_reduce_add_ps(vnb); + for i in (chunks * 16)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } + } +} + +/// Negative inner product between two BF16-encoded byte slices. +/// +/// Uses `avx512bf16` (`_mm512_dpbf16_ps`) when available for the ideal use +/// case: accumulate `dot += a · b` directly in F32, 32 elements per iteration. +/// Falls through to AVX-512F + bit-shift widening otherwise. +pub fn neg_inner_product_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "avx512 bf16 ip: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "avx512 bf16 ip: b byte len mismatch"); + if std::is_x86_feature_detected!("avx512bf16") { + unsafe { ip_bf16_dp_impl(a, b, dim) } + } else { + unsafe { ip_bf16_widen_impl(a, b, dim) } + } +} + +#[target_feature(enable = "avx512f,avx512bf16")] +unsafe fn ip_bf16_dp_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm512_setzero_ps(); + let chunks = dim / 32; + for i in 0..chunks { + let off = i * 64; + let ba: __m512bh = + std::mem::transmute(_mm512_loadu_si512(a.as_ptr().add(off) as *const _)); + let bb: __m512bh = + std::mem::transmute(_mm512_loadu_si512(b.as_ptr().add(off) as *const _)); + vdot = _mm512_dpbf16_ps(vdot, ba, bb); + } + let mut dot = _mm512_reduce_add_ps(vdot); + for i in (chunks * 32)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot + } +} + +#[target_feature(enable = "avx512f,fma")] +unsafe fn ip_bf16_widen_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + unsafe { + use std::arch::x86_64::*; + let mut vdot = _mm512_setzero_ps(); + let chunks = dim / 16; + for i in 0..chunks { + let off = i * 32; + let va = bf16_to_f32x16(_mm256_loadu_si256(a.as_ptr().add(off) as *const __m256i)); + let vb = bf16_to_f32x16(_mm256_loadu_si256(b.as_ptr().add(off) as *const __m256i)); + vdot = _mm512_fmadd_ps(va, vb, vdot); + } + let mut dot = _mm512_reduce_add_ps(vdot); + for i in (chunks * 16)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot + } +} + +// ── Shared helpers ───────────────────────────────────────────────────────────── + +/// Widen 16 × BF16 (LE u16 in __m256i) to 16 × f32 (__m512) via left-shift. +/// +/// BF16 occupies the upper 16 bits of an f32. Zero-extend u16→u32 via +/// `_mm512_cvtepu16_epi32`, shift left 16, reinterpret as f32. +#[target_feature(enable = "avx512f")] +#[inline] +unsafe fn bf16_to_f32x16(v: std::arch::x86_64::__m256i) -> std::arch::x86_64::__m512 { + use std::arch::x86_64::*; + let u32s = _mm512_cvtepu16_epi32(v); + let shifted = _mm512_slli_epi32(u32s, 16); + _mm512_castsi512_ps(shifted) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::distance::typed_scalar; + use crate::dtype::cast_from_f32; + use nodedb_types::vector_dtype::VectorStorageDtype; + + const A32: [f32; 32] = [ + 0.5, -1.0, 2.5, 0.1, 1.0, -0.5, 3.0, 0.2, -2.0, 1.5, 0.8, -0.3, 4.0, -1.2, 0.7, 0.9, 0.3, + -0.8, 1.2, -1.5, 2.0, 0.6, -3.0, 0.4, 1.1, -0.9, 0.2, 2.2, -1.8, 0.5, -0.4, 1.3, + ]; + const B32: [f32; 32] = [ + 1.0, 0.5, -1.5, 2.0, -0.5, 1.0, -2.0, 0.3, 1.0, -1.0, 0.4, 0.6, -3.0, 0.8, -0.6, 1.1, -0.2, + 1.4, -0.7, 0.9, -1.3, 0.2, 2.5, -0.5, 0.8, -1.1, 1.6, -0.3, 0.7, -2.0, 0.9, 0.4, + ]; + + // dim=23: exercises both the 16-element SIMD chunk and a 7-element scalar tail. + const A23: [f32; 23] = [ + 0.5, -1.0, 2.5, 0.1, 1.0, -0.5, 3.0, 0.2, -2.0, 1.5, 0.8, -0.3, 4.0, -1.2, 0.7, 0.9, 0.3, + -0.8, 1.2, -1.5, 2.0, 0.6, -3.0, + ]; + const B23: [f32; 23] = [ + 1.0, 0.5, -1.5, 2.0, -0.5, 1.0, -2.0, 0.3, 1.0, -1.0, 0.4, 0.6, -3.0, 0.8, -0.6, 1.1, -0.2, + 1.4, -0.7, 0.9, -1.3, 0.2, 2.5, + ]; + + // SIMD/scalar horizontal-sum reordering may introduce small floating-point differences. + const EPS: f32 = 2e-4; + + #[test] + fn f16_l2_dim32() { + if !std::is_x86_feature_detected!("avx512f") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A32, VectorStorageDtype::F16); + let b = cast_from_f32(&B32, VectorStorageDtype::F16); + let simd = l2_squared_f16(&a, &b, 32); + let scalar = typed_scalar::l2_squared_f16(&a, &b, 32); + assert!( + (simd - scalar).abs() < EPS, + "f16 l2 dim32: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn f16_cosine_dim32() { + if !std::is_x86_feature_detected!("avx512f") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A32, VectorStorageDtype::F16); + let b = cast_from_f32(&B32, VectorStorageDtype::F16); + let simd = cosine_distance_f16(&a, &b, 32); + let scalar = typed_scalar::cosine_f16(&a, &b, 32); + assert!( + (simd - scalar).abs() < EPS, + "f16 cosine dim32: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn f16_neg_ip_dim32() { + if !std::is_x86_feature_detected!("avx512f") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A32, VectorStorageDtype::F16); + let b = cast_from_f32(&B32, VectorStorageDtype::F16); + let simd = neg_inner_product_f16(&a, &b, 32); + let scalar = typed_scalar::neg_inner_product_f16(&a, &b, 32); + assert!( + (simd - scalar).abs() < EPS, + "f16 ip dim32: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_l2_dim32() { + if !std::is_x86_feature_detected!("avx512f") { + return; + } + let a = cast_from_f32(&A32, VectorStorageDtype::BF16); + let b = cast_from_f32(&B32, VectorStorageDtype::BF16); + let simd = l2_squared_bf16(&a, &b, 32); + let scalar = typed_scalar::l2_squared_bf16(&a, &b, 32); + assert!( + (simd - scalar).abs() < EPS, + "bf16 l2 dim32: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_cosine_dim32() { + if !std::is_x86_feature_detected!("avx512f") { + return; + } + let a = cast_from_f32(&A32, VectorStorageDtype::BF16); + let b = cast_from_f32(&B32, VectorStorageDtype::BF16); + let simd = cosine_distance_bf16(&a, &b, 32); + let scalar = typed_scalar::cosine_bf16(&a, &b, 32); + assert!( + (simd - scalar).abs() < EPS, + "bf16 cosine dim32: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_neg_ip_dim32() { + if !std::is_x86_feature_detected!("avx512f") { + return; + } + let a = cast_from_f32(&A32, VectorStorageDtype::BF16); + let b = cast_from_f32(&B32, VectorStorageDtype::BF16); + let simd = neg_inner_product_bf16(&a, &b, 32); + let scalar = typed_scalar::neg_inner_product_bf16(&a, &b, 32); + assert!( + (simd - scalar).abs() < EPS, + "bf16 ip dim32: simd={simd}, scalar={scalar}" + ); + } + + // ── Tail-loop correctness: dim=23 (16-chunk + 7-element scalar tail) ───── + + #[test] + fn f16_l2_dim23_tail() { + if !std::is_x86_feature_detected!("avx512f") || !std::is_x86_feature_detected!("f16c") { + return; + } + let a = cast_from_f32(&A23, VectorStorageDtype::F16); + let b = cast_from_f32(&B23, VectorStorageDtype::F16); + let simd = l2_squared_f16(&a, &b, 23); + let scalar = typed_scalar::l2_squared_f16(&a, &b, 23); + assert!( + (simd - scalar).abs() < EPS, + "f16 l2 dim23: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_l2_dim23_tail() { + if !std::is_x86_feature_detected!("avx512f") { + return; + } + let a = cast_from_f32(&A23, VectorStorageDtype::BF16); + let b = cast_from_f32(&B23, VectorStorageDtype::BF16); + let simd = l2_squared_bf16(&a, &b, 23); + let scalar = typed_scalar::l2_squared_bf16(&a, &b, 23); + assert!( + (simd - scalar).abs() < EPS, + "bf16 l2 dim23: simd={simd}, scalar={scalar}" + ); + } +} diff --git a/nodedb-vector/src/distance/simd/neon_typed.rs b/nodedb-vector/src/distance/simd/neon_typed.rs new file mode 100644 index 000000000..286b56060 --- /dev/null +++ b/nodedb-vector/src/distance/simd/neon_typed.rs @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_arch = "aarch64")] + +//! NEON distance kernels for F16 and BF16 byte buffers. +//! +//! **Stable-Rust intrinsic availability (Rust 1.95, as of this writing):** +//! +//! - BF16: `bfloat16x8_t`, `vld1q_bf16`, `vbfdotq_f32` — NOT available on stable +//! (no stable feature gate exists; nightly-only via the `bf16` target feature). +//! The BF16 widen path uses only base `neon`: `vmovl_u16` → `vshlq_n_u32` → +//! `vreinterpretq_f32_u32`. This works on every aarch64 chip. +//! +//! - F16 arithmetic (`vfmaq_f16`, `vsubq_f16`, `vcvt_f32_f16`, `vreinterpret_f16_u16`): +//! in `arm_shared` under `unstable(feature = "stdarch_arm_neon_intrinsics")` — NOT stable. +//! `vld1q_f16` is also unstable (`stdarch_neon_f16`, issue 136306). +//! `vcvt_high_f32_f16` is stable (since 1.94, `stdarch_neon_fp16`) but requires a +//! `float16x8_t` input, which cannot be loaded without unstable `vld1q_f16`. +//! Therefore the F16 NEON path falls back to a scalar widen-in-loop via +//! `half::f16::from_le_bytes`, accumulating into base-NEON `float32x4_t` accumulators +//! in chunks of 4. This is vectorized in the accumulation stage and correct on all cores. +//! +//! - Native FP16 arithmetic (keep math in F16 lanes): requires `vfmaq_f16` which is +//! unstable. Not used here. +//! +//! Both paths emit `#[target_feature(enable = "neon")]` so the compiler can schedule +//! the F32 accumulator work with NEON registers even on the scalar-decode F16 path. +//! +//! Lite's primary target is mobile ARM64; the BF16 widen path is the production-hot +//! path for BF16 embeddings on all ARM chips. + +// ── F16 public wrappers ──────────────────────────────────────────────────────── + +/// L2-squared distance between two F16-encoded byte slices (NEON, widen to F32). +pub fn l2_squared_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "neon_typed f16 l2: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "neon_typed f16 l2: b byte len mismatch"); + // SAFETY: all aarch64 targets have NEON. + unsafe { f16_l2_impl(a, b, dim) } +} + +/// Cosine distance between two F16-encoded byte slices (NEON, widen to F32). +pub fn cosine_distance_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!( + a.len(), + dim * 2, + "neon_typed f16 cosine: a byte len mismatch" + ); + assert_eq!( + b.len(), + dim * 2, + "neon_typed f16 cosine: b byte len mismatch" + ); + unsafe { f16_cosine_impl(a, b, dim) } +} + +/// Negative inner product between two F16-encoded byte slices (NEON, widen to F32). +pub fn neg_inner_product_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "neon_typed f16 ip: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "neon_typed f16 ip: b byte len mismatch"); + unsafe { f16_ip_impl(a, b, dim) } +} + +// ── BF16 public wrappers ─────────────────────────────────────────────────────── + +/// L2-squared distance between two BF16-encoded byte slices (NEON widen to F32). +pub fn l2_squared_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "neon_typed bf16 l2: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "neon_typed bf16 l2: b byte len mismatch"); + unsafe { bf16_l2_impl(a, b, dim) } +} + +/// Cosine distance between two BF16-encoded byte slices (NEON widen to F32). +pub fn cosine_distance_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!( + a.len(), + dim * 2, + "neon_typed bf16 cosine: a byte len mismatch" + ); + assert_eq!( + b.len(), + dim * 2, + "neon_typed bf16 cosine: b byte len mismatch" + ); + unsafe { bf16_cosine_impl(a, b, dim) } +} + +/// Negative inner product between two BF16-encoded byte slices (NEON widen to F32). +pub fn neg_inner_product_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + assert_eq!(a.len(), dim * 2, "neon_typed bf16 ip: a byte len mismatch"); + assert_eq!(b.len(), dim * 2, "neon_typed bf16 ip: b byte len mismatch"); + unsafe { bf16_ip_impl(a, b, dim) } +} + +// ── F16 impls — scalar decode, NEON accumulation ───────────────────────────── +// +// `vcvt_f32_f16` and `vreinterpret_f16_u16` are unstable (`stdarch_arm_neon_intrinsics`, +// issue 111800). `vld1q_f16` is unstable (`stdarch_neon_f16`, issue 136306). Therefore +// F16 elements are decoded via `half::f16::from_le_bytes` (scalar), then loaded into +// `float32x4_t` accumulators four at a time using `vld1q_f32` on a stack buffer. +// The accumulator FMA and horizontal sum are fully vectorized with NEON. + +#[target_feature(enable = "neon")] +unsafe fn f16_l2_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + use std::arch::aarch64::*; + let mut sum = vdupq_n_f32(0.0f32); + let chunks = dim / 4; + for i in 0..chunks { + let base = i * 4; + let af = decode_f16x4(a, base); + let bf = decode_f16x4(b, base); + let va = vld1q_f32(af.as_ptr()); + let vb = vld1q_f32(bf.as_ptr()); + let diff = vsubq_f32(va, vb); + sum = vfmaq_f32(sum, diff, diff); + } + let mut result = vaddvq_f32(sum); + for i in (chunks * 4)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let d = av - bv; + result += d * d; + } + result +} + +#[target_feature(enable = "neon")] +unsafe fn f16_cosine_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + use std::arch::aarch64::*; + let mut vdot = vdupq_n_f32(0.0f32); + let mut vna = vdupq_n_f32(0.0f32); + let mut vnb = vdupq_n_f32(0.0f32); + let chunks = dim / 4; + for i in 0..chunks { + let base = i * 4; + let af = decode_f16x4(a, base); + let bf = decode_f16x4(b, base); + let va = vld1q_f32(af.as_ptr()); + let vb = vld1q_f32(bf.as_ptr()); + vdot = vfmaq_f32(vdot, va, vb); + vna = vfmaq_f32(vna, va, va); + vnb = vfmaq_f32(vnb, vb, vb); + } + let mut dot = vaddvq_f32(vdot); + let mut na = vaddvq_f32(vna); + let mut nb = vaddvq_f32(vnb); + for i in (chunks * 4)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } +} + +#[target_feature(enable = "neon")] +unsafe fn f16_ip_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + use std::arch::aarch64::*; + let mut vdot = vdupq_n_f32(0.0f32); + let chunks = dim / 4; + for i in 0..chunks { + let base = i * 4; + let af = decode_f16x4(a, base); + let bf = decode_f16x4(b, base); + let va = vld1q_f32(af.as_ptr()); + let vb = vld1q_f32(bf.as_ptr()); + vdot = vfmaq_f32(vdot, va, vb); + } + let mut dot = vaddvq_f32(vdot); + for i in (chunks * 4)..dim { + let off = i * 2; + let av = half::f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot +} + +/// Decode 4 consecutive F16 elements (LE bytes) starting at `base` into [f32; 4]. +#[inline(always)] +fn decode_f16x4(buf: &[u8], base: usize) -> [f32; 4] { + let off = base * 2; + [ + half::f16::from_le_bytes([buf[off], buf[off + 1]]).to_f32(), + half::f16::from_le_bytes([buf[off + 2], buf[off + 3]]).to_f32(), + half::f16::from_le_bytes([buf[off + 4], buf[off + 5]]).to_f32(), + half::f16::from_le_bytes([buf[off + 6], buf[off + 7]]).to_f32(), + ] +} + +// ── BF16 impls — NEON widen to F32 (base neon, no sub-extension) ───────────── +// +// BF16 occupies the upper 16 bits of an f32 mantissa (1 sign + 8 exp + 7 mantissa). +// Strategy: zero-extend each u16 lane to u32, shift left 16, reinterpret as f32. +// Uses only `vmovl_u16` + `vshlq_n_u32` + `vreinterpretq_f32_u32` — all stable on +// every aarch64 chip since NEON baseline (Rust 1.59). +// Processes 8 BF16 lanes per iteration (16 bytes): two float32x4_t from one load. + +#[target_feature(enable = "neon")] +unsafe fn bf16_l2_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + use std::arch::aarch64::*; + let mut sum = vdupq_n_f32(0.0f32); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; // 8 elements × 2 bytes + let au8 = a.as_ptr().add(off) as *const u16; + let bu8 = b.as_ptr().add(off) as *const u16; + let au16: uint16x8_t = vld1q_u16(au8); + let bu16: uint16x8_t = vld1q_u16(bu8); + let (alo, ahi) = bf16_widen_pair(au16); + let (blo, bhi) = bf16_widen_pair(bu16); + let diffl = vsubq_f32(alo, blo); + let diffh = vsubq_f32(ahi, bhi); + sum = vfmaq_f32(sum, diffl, diffl); + sum = vfmaq_f32(sum, diffh, diffh); + } + let mut result = vaddvq_f32(sum); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let d = av - bv; + result += d * d; + } + result +} + +#[target_feature(enable = "neon")] +unsafe fn bf16_cosine_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + use std::arch::aarch64::*; + let mut vdot = vdupq_n_f32(0.0f32); + let mut vna = vdupq_n_f32(0.0f32); + let mut vnb = vdupq_n_f32(0.0f32); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let au16: uint16x8_t = vld1q_u16(a.as_ptr().add(off) as *const u16); + let bu16: uint16x8_t = vld1q_u16(b.as_ptr().add(off) as *const u16); + let (alo, ahi) = bf16_widen_pair(au16); + let (blo, bhi) = bf16_widen_pair(bu16); + vdot = vfmaq_f32(vdot, alo, blo); + vdot = vfmaq_f32(vdot, ahi, bhi); + vna = vfmaq_f32(vna, alo, alo); + vna = vfmaq_f32(vna, ahi, ahi); + vnb = vfmaq_f32(vnb, blo, blo); + vnb = vfmaq_f32(vnb, bhi, bhi); + } + let mut dot = vaddvq_f32(vdot); + let mut na = vaddvq_f32(vna); + let mut nb = vaddvq_f32(vnb); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + na += av * av; + nb += bv * bv; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } +} + +#[target_feature(enable = "neon")] +unsafe fn bf16_ip_impl(a: &[u8], b: &[u8], dim: usize) -> f32 { + use std::arch::aarch64::*; + let mut vdot = vdupq_n_f32(0.0f32); + let chunks = dim / 8; + for i in 0..chunks { + let off = i * 16; + let au16: uint16x8_t = vld1q_u16(a.as_ptr().add(off) as *const u16); + let bu16: uint16x8_t = vld1q_u16(b.as_ptr().add(off) as *const u16); + let (alo, ahi) = bf16_widen_pair(au16); + let (blo, bhi) = bf16_widen_pair(bu16); + vdot = vfmaq_f32(vdot, alo, blo); + vdot = vfmaq_f32(vdot, ahi, bhi); + } + let mut dot = vaddvq_f32(vdot); + for i in (chunks * 8)..dim { + let off = i * 2; + let av = half::bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = half::bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot +} + +/// Widen 8 × BF16 (as `uint16x8_t`) into two `float32x4_t` (lo + hi halves). +/// +/// BF16 = upper 16 bits of f32. Zero-extend u16 → u32, shift left 16, reinterpret. +/// Uses only base NEON — no sub-extension required. +#[inline(always)] +#[target_feature(enable = "neon")] +unsafe fn bf16_widen_pair( + v: std::arch::aarch64::uint16x8_t, +) -> ( + std::arch::aarch64::float32x4_t, + std::arch::aarch64::float32x4_t, +) { + use std::arch::aarch64::*; + let lo_u16: uint16x4_t = vget_low_u16(v); + let hi_u16: uint16x4_t = vget_high_u16(v); + let lo_u32: uint32x4_t = vmovl_u16(lo_u16); + let hi_u32: uint32x4_t = vmovl_u16(hi_u16); + let lo_shifted: uint32x4_t = vshlq_n_u32(lo_u32, 16); + let hi_shifted: uint32x4_t = vshlq_n_u32(hi_u32, 16); + ( + vreinterpretq_f32_u32(lo_shifted), + vreinterpretq_f32_u32(hi_shifted), + ) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── +// +// These tests run only on aarch64 hosts. The widen paths (both F16 and BF16) +// work on all aarch64 cores — no sub-extension detection needed. Tests compare +// NEON kernels against `typed_scalar` reference implementations within an +// absolute tolerance of 1e-4 (order-of-summation differences between NEON +// horizontal-sum and scalar accumulation may produce small floating-point deltas). + +#[cfg(test)] +mod tests { + use super::*; + use crate::distance::typed_scalar; + use crate::dtype::cast_from_f32; + use nodedb_types::vector_dtype::VectorStorageDtype; + + const A16: [f32; 16] = [ + 0.5, -1.0, 2.5, 0.1, 1.0, -0.5, 3.0, 0.2, -2.0, 1.5, 0.8, -0.3, 4.0, -1.2, 0.7, 0.9, + ]; + const B16: [f32; 16] = [ + 1.0, 0.5, -1.5, 2.0, -0.5, 1.0, -2.0, 0.3, 1.0, -1.0, 0.4, 0.6, -3.0, 0.8, -0.6, 1.1, + ]; + + const A13: [f32; 13] = [ + 0.5, -1.0, 2.5, 0.1, 1.0, -0.5, 3.0, 0.2, -2.0, 1.5, 0.8, -0.3, 4.0, + ]; + const B13: [f32; 13] = [ + 1.0, 0.5, -1.5, 2.0, -0.5, 1.0, -2.0, 0.3, 1.0, -1.0, 0.4, 0.6, -3.0, + ]; + + // F16 round-trip through `half` introduces ~3 decimal digits of precision; + // NEON horizontal-sum reordering adds at most one ULP on top. + const EPS: f32 = 1e-4; + + #[test] + fn f16_l2_dim16() { + let a = cast_from_f32(&A16, VectorStorageDtype::F16); + let b = cast_from_f32(&B16, VectorStorageDtype::F16); + let simd = l2_squared_f16(&a, &b, 16); + let scalar = typed_scalar::l2_squared_f16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "f16 l2 dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn f16_cosine_dim16() { + let a = cast_from_f32(&A16, VectorStorageDtype::F16); + let b = cast_from_f32(&B16, VectorStorageDtype::F16); + let simd = cosine_distance_f16(&a, &b, 16); + let scalar = typed_scalar::cosine_f16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "f16 cosine dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn f16_neg_ip_dim16() { + let a = cast_from_f32(&A16, VectorStorageDtype::F16); + let b = cast_from_f32(&B16, VectorStorageDtype::F16); + let simd = neg_inner_product_f16(&a, &b, 16); + let scalar = typed_scalar::neg_inner_product_f16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "f16 ip dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_l2_dim16() { + let a = cast_from_f32(&A16, VectorStorageDtype::BF16); + let b = cast_from_f32(&B16, VectorStorageDtype::BF16); + let simd = l2_squared_bf16(&a, &b, 16); + let scalar = typed_scalar::l2_squared_bf16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "bf16 l2 dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_cosine_dim16() { + let a = cast_from_f32(&A16, VectorStorageDtype::BF16); + let b = cast_from_f32(&B16, VectorStorageDtype::BF16); + let simd = cosine_distance_bf16(&a, &b, 16); + let scalar = typed_scalar::cosine_bf16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "bf16 cosine dim16: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_neg_ip_dim16() { + let a = cast_from_f32(&A16, VectorStorageDtype::BF16); + let b = cast_from_f32(&B16, VectorStorageDtype::BF16); + let simd = neg_inner_product_bf16(&a, &b, 16); + let scalar = typed_scalar::neg_inner_product_bf16(&a, &b, 16); + assert!( + (simd - scalar).abs() < EPS, + "bf16 ip dim16: simd={simd}, scalar={scalar}" + ); + } + + // ── Tail-loop correctness: dim=13 (not a multiple of 4 or 8) ───────────── + + #[test] + fn f16_l2_dim13_tail() { + let a = cast_from_f32(&A13, VectorStorageDtype::F16); + let b = cast_from_f32(&B13, VectorStorageDtype::F16); + let simd = l2_squared_f16(&a, &b, 13); + let scalar = typed_scalar::l2_squared_f16(&a, &b, 13); + assert!( + (simd - scalar).abs() < EPS, + "f16 l2 dim13: simd={simd}, scalar={scalar}" + ); + } + + #[test] + fn bf16_l2_dim13_tail() { + let a = cast_from_f32(&A13, VectorStorageDtype::BF16); + let b = cast_from_f32(&B13, VectorStorageDtype::BF16); + let simd = l2_squared_bf16(&a, &b, 13); + let scalar = typed_scalar::l2_squared_bf16(&a, &b, 13); + assert!( + (simd - scalar).abs() < EPS, + "bf16 l2 dim13: simd={simd}, scalar={scalar}" + ); + } +} diff --git a/nodedb-vector/src/distance/simd/runtime.rs b/nodedb-vector/src/distance/simd/runtime.rs index f8daadf89..a148f817a 100644 --- a/nodedb-vector/src/distance/simd/runtime.rs +++ b/nodedb-vector/src/distance/simd/runtime.rs @@ -4,6 +4,7 @@ use super::hamming::fast_hamming; use super::scalar::{scalar_cosine, scalar_ip, scalar_l2}; +use crate::distance::typed_scalar; #[cfg(target_arch = "x86_64")] use super::{avx2, avx512}; @@ -11,6 +12,9 @@ use super::{avx2, avx512}; #[cfg(target_arch = "aarch64")] use super::neon; +/// Function pointer type for half-precision byte-level distance kernels. +type HalfFn = fn(&[u8], &[u8], usize) -> f32; + /// Selected SIMD runtime — function pointers to the best available kernels. pub struct SimdRuntime { pub l2_squared: fn(&[f32], &[f32]) -> f32, @@ -18,6 +22,14 @@ pub struct SimdRuntime { pub neg_inner_product: fn(&[f32], &[f32]) -> f32, pub hamming: fn(&[u8], &[u8]) -> u32, pub name: &'static str, + /// F16 fused decode-and-compute kernels (no intermediate Vec). + pub l2_squared_f16: HalfFn, + pub cosine_distance_f16: HalfFn, + pub neg_inner_product_f16: HalfFn, + /// BF16 fused decode-and-compute kernels. + pub l2_squared_bf16: HalfFn, + pub cosine_distance_bf16: HalfFn, + pub neg_inner_product_bf16: HalfFn, } impl SimdRuntime { @@ -39,6 +51,12 @@ impl SimdRuntime { neg_inner_product: avx512::neg_inner_product, hamming: fast_hamming, name, + l2_squared_f16: typed_scalar::l2_squared_f16, + cosine_distance_f16: typed_scalar::cosine_f16, + neg_inner_product_f16: typed_scalar::neg_inner_product_f16, + l2_squared_bf16: typed_scalar::l2_squared_bf16, + cosine_distance_bf16: typed_scalar::cosine_bf16, + neg_inner_product_bf16: typed_scalar::neg_inner_product_bf16, }; tracing::info!(kernel = rt.name, "vector SIMD kernel selected"); debug_assert!( @@ -54,6 +72,12 @@ impl SimdRuntime { neg_inner_product: avx2::neg_inner_product, hamming: fast_hamming, name: "avx2+fma", + l2_squared_f16: typed_scalar::l2_squared_f16, + cosine_distance_f16: typed_scalar::cosine_f16, + neg_inner_product_f16: typed_scalar::neg_inner_product_f16, + l2_squared_bf16: typed_scalar::l2_squared_bf16, + cosine_distance_bf16: typed_scalar::cosine_bf16, + neg_inner_product_bf16: typed_scalar::neg_inner_product_bf16, }; tracing::info!(kernel = rt.name, "vector SIMD kernel selected"); return rt; @@ -67,6 +91,12 @@ impl SimdRuntime { neg_inner_product: neon::neg_inner_product, hamming: fast_hamming, name: "neon", + l2_squared_f16: typed_scalar::l2_squared_f16, + cosine_distance_f16: typed_scalar::cosine_f16, + neg_inner_product_f16: typed_scalar::neg_inner_product_f16, + l2_squared_bf16: typed_scalar::l2_squared_bf16, + cosine_distance_bf16: typed_scalar::cosine_bf16, + neg_inner_product_bf16: typed_scalar::neg_inner_product_bf16, }; tracing::info!(kernel = rt.name, "vector SIMD kernel selected"); return rt; @@ -79,6 +109,12 @@ impl SimdRuntime { neg_inner_product: scalar_ip, hamming: fast_hamming, name: "scalar", + l2_squared_f16: typed_scalar::l2_squared_f16, + cosine_distance_f16: typed_scalar::cosine_f16, + neg_inner_product_f16: typed_scalar::neg_inner_product_f16, + l2_squared_bf16: typed_scalar::l2_squared_bf16, + cosine_distance_bf16: typed_scalar::cosine_bf16, + neg_inner_product_bf16: typed_scalar::neg_inner_product_bf16, }; tracing::info!(kernel = rt.name, "vector SIMD kernel selected"); rt diff --git a/nodedb-vector/src/distance/simd/wasm_simd128.rs b/nodedb-vector/src/distance/simd/wasm_simd128.rs new file mode 100644 index 000000000..0efdac416 --- /dev/null +++ b/nodedb-vector/src/distance/simd/wasm_simd128.rs @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(target_arch = "wasm32", target_feature = "simd128"))] + +//! WASM SIMD128 distance kernels for F32. +//! +//! WASM SIMD128 has 128-bit vectors with F32/I32/I16/I8/I64 lanes — no native +//! F16 or BF16. F32 kernels run 4 lanes per chunk via `f32x4_*` ops. F16/BF16 +//! distance on wasm32 stays scalar via `typed_scalar` (see runtime.rs). +//! +//! Compile-time gate: only built when targeting wasm32 with the simd128 +//! feature enabled (set via `RUSTFLAGS="-C target-feature=+simd128"` or +//! `[target.wasm32-unknown-unknown.rustflags]` in `.cargo/config.toml`). +//! +//! Rust 2024 note: `v128_load` is an `unsafe` fn (raw pointer dereference); +//! the arithmetic ops (`f32x4_*`) are safe. All `v128_load` calls sit inside +//! explicit `unsafe {}` blocks even within `unsafe fn` bodies, as required by +//! the `unsafe_op_in_unsafe_fn` lint default in edition 2024. + +use std::arch::wasm32::*; + +/// L2-squared distance between two F32 slices using WASM SIMD128. +pub fn l2_squared(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len(), "wasm_simd128 l2: length mismatch"); + // SAFETY: wasm32 + simd128 is a compile-time gate; if this module is + // compiled, the target is known to have SIMD128 support. The v128_load + // calls read 16 contiguous bytes within bounds (chunk loop guarantees it). + unsafe { l2_impl(a, b) } +} + +unsafe fn l2_impl(a: &[f32], b: &[f32]) -> f32 { + let n = a.len(); + let chunks = n / 4; + let mut acc = f32x4_splat(0.0); + for i in 0..chunks { + let off = i * 4; + // SAFETY: off..off+4 is within a (chunks = n/4 guarantees off+4 <= n). + let va = unsafe { v128_load(a.as_ptr().add(off) as *const v128) }; + let vb = unsafe { v128_load(b.as_ptr().add(off) as *const v128) }; + let diff = f32x4_sub(va, vb); + acc = f32x4_add(acc, f32x4_mul(diff, diff)); + } + let mut result = f32x4_extract_lane::<0>(acc) + + f32x4_extract_lane::<1>(acc) + + f32x4_extract_lane::<2>(acc) + + f32x4_extract_lane::<3>(acc); + for i in (chunks * 4)..n { + let d = a[i] - b[i]; + result += d * d; + } + result +} + +/// Cosine distance between two F32 slices using WASM SIMD128. +/// +/// Single-pass three-accumulator (dot, a_norm_sq, b_norm_sq). Returns `1.0` +/// if either vector is zero-norm (matches `scalar_cosine` convention). +pub fn cosine_distance(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len(), "wasm_simd128 cosine: length mismatch"); + // SAFETY: compile-time arch gate guarantees SIMD128 availability. + unsafe { cosine_impl(a, b) } +} + +unsafe fn cosine_impl(a: &[f32], b: &[f32]) -> f32 { + let n = a.len(); + let chunks = n / 4; + let mut vdot = f32x4_splat(0.0); + let mut vna = f32x4_splat(0.0); + let mut vnb = f32x4_splat(0.0); + for i in 0..chunks { + let off = i * 4; + // SAFETY: off..off+4 within bounds by chunk loop invariant. + let va = unsafe { v128_load(a.as_ptr().add(off) as *const v128) }; + let vb = unsafe { v128_load(b.as_ptr().add(off) as *const v128) }; + vdot = f32x4_add(vdot, f32x4_mul(va, vb)); + vna = f32x4_add(vna, f32x4_mul(va, va)); + vnb = f32x4_add(vnb, f32x4_mul(vb, vb)); + } + let mut dot = f32x4_extract_lane::<0>(vdot) + + f32x4_extract_lane::<1>(vdot) + + f32x4_extract_lane::<2>(vdot) + + f32x4_extract_lane::<3>(vdot); + let mut na = f32x4_extract_lane::<0>(vna) + + f32x4_extract_lane::<1>(vna) + + f32x4_extract_lane::<2>(vna) + + f32x4_extract_lane::<3>(vna); + let mut nb = f32x4_extract_lane::<0>(vnb) + + f32x4_extract_lane::<1>(vnb) + + f32x4_extract_lane::<2>(vnb) + + f32x4_extract_lane::<3>(vnb); + for i in (chunks * 4)..n { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } +} + +/// Negative inner product between two F32 slices using WASM SIMD128. +pub fn neg_inner_product(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len(), "wasm_simd128 ip: length mismatch"); + // SAFETY: compile-time arch gate guarantees SIMD128 availability. + unsafe { ip_impl(a, b) } +} + +unsafe fn ip_impl(a: &[f32], b: &[f32]) -> f32 { + let n = a.len(); + let chunks = n / 4; + let mut vdot = f32x4_splat(0.0); + for i in 0..chunks { + let off = i * 4; + // SAFETY: off..off+4 within bounds by chunk loop invariant. + let va = unsafe { v128_load(a.as_ptr().add(off) as *const v128) }; + let vb = unsafe { v128_load(b.as_ptr().add(off) as *const v128) }; + vdot = f32x4_add(vdot, f32x4_mul(va, vb)); + } + let mut dot = f32x4_extract_lane::<0>(vdot) + + f32x4_extract_lane::<1>(vdot) + + f32x4_extract_lane::<2>(vdot) + + f32x4_extract_lane::<3>(vdot); + for i in (chunks * 4)..n { + dot += a[i] * b[i]; + } + -dot +} + +#[cfg(target_arch = "wasm32")] +#[cfg(test)] +mod tests { + use super::*; + + // Reference scalar implementations mirrored inline — no cross-module dep + // that could complicate wasm32 cross-compile. + + fn ref_l2(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum() + } + + fn ref_cosine(a: &[f32], b: &[f32]) -> f32 { + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f32 = a.iter().map(|x| x * x).sum(); + let nb: f32 = b.iter().map(|x| x * x).sum(); + let denom = (na * nb).sqrt(); + if denom < f32::EPSILON { + 1.0 + } else { + (1.0 - dot / denom).max(0.0) + } + } + + fn ref_nip(a: &[f32], b: &[f32]) -> f32 { + -(a.iter().zip(b).map(|(x, y)| x * y).sum::()) + } + + // 4 full SIMD chunks (dim = 16). + const A16: [f32; 16] = [ + 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, + ]; + const B16: [f32; 16] = [ + 1.6, 1.5, 1.4, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, + ]; + + #[test] + fn l2_full_chunks() { + let got = l2_squared(&A16, &B16); + let want = ref_l2(&A16, &B16); + assert!((got - want).abs() < 1e-4, "l2 full: got={got}, want={want}"); + } + + #[test] + fn cosine_full_chunks() { + let got = cosine_distance(&A16, &B16); + let want = ref_cosine(&A16, &B16); + assert!( + (got - want).abs() < 1e-5, + "cosine full: got={got}, want={want}" + ); + } + + #[test] + fn nip_full_chunks() { + let got = neg_inner_product(&A16, &B16); + let want = ref_nip(&A16, &B16); + assert!( + (got - want).abs() < 1e-4, + "nip full: got={got}, want={want}" + ); + } + + // Tail loop exercise: dim = 7 (1 full chunk of 4, tail of 3). + const A7: [f32; 7] = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]; + const B7: [f32; 7] = [3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5]; + + #[test] + fn l2_tail() { + let got = l2_squared(&A7, &B7); + let want = ref_l2(&A7, &B7); + assert!((got - want).abs() < 1e-4, "l2 tail: got={got}, want={want}"); + } + + #[test] + fn cosine_tail() { + let got = cosine_distance(&A7, &B7); + let want = ref_cosine(&A7, &B7); + assert!( + (got - want).abs() < 1e-5, + "cosine tail: got={got}, want={want}" + ); + } + + #[test] + fn nip_tail() { + let got = neg_inner_product(&A7, &B7); + let want = ref_nip(&A7, &B7); + assert!( + (got - want).abs() < 1e-4, + "nip tail: got={got}, want={want}" + ); + } + + #[test] + fn cosine_zero_norm_returns_one() { + let z = [0.0f32; 8]; + let a = [1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + assert_eq!(cosine_distance(&z, &a), 1.0); + assert_eq!(cosine_distance(&a, &z), 1.0); + } +} diff --git a/nodedb-vector/src/distance/typed_scalar.rs b/nodedb-vector/src/distance/typed_scalar.rs new file mode 100644 index 000000000..683a67466 --- /dev/null +++ b/nodedb-vector/src/distance/typed_scalar.rs @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Fused dtype-decode + distance kernels for F16 and BF16 byte buffers. +//! +//! Each kernel iterates element-by-element, decoding two bytes per element +//! to f32 inline, avoiding the intermediate `Vec` allocation that +//! `cast_to_f32` would require. Callers must run `validate_byte_len` before +//! invoking any function here; the `debug_assert_eq!` guards are safety nets, +//! not primary validation. + +use half::{bf16, f16}; + +// ── F16 kernels ────────────────────────────────────────────────────────────── + +/// L2-squared distance between two F16-encoded byte slices. +/// +/// Returns `Σ (aᵢ - bᵢ)²` computed in f32 after decoding each element. +pub(crate) fn l2_squared_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + debug_assert_eq!(a.len(), dim * 2); + debug_assert_eq!(b.len(), dim * 2); + let mut acc = 0.0_f32; + for i in 0..dim { + let off = i * 2; + let av = f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let diff = av - bv; + acc += diff * diff; + } + acc +} + +/// Cosine distance between two F16-encoded byte slices. +/// +/// Computes `1 - dot(a,b) / (‖a‖ · ‖b‖)` in a single pass. If either +/// vector has zero magnitude, returns `1.0` (maximum distance), matching +/// the convention in `nodedb_types::vector_distance::cosine_distance`. +pub(crate) fn cosine_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + debug_assert_eq!(a.len(), dim * 2); + debug_assert_eq!(b.len(), dim * 2); + let mut dot = 0.0_f32; + let mut norm_a = 0.0_f32; + let mut norm_b = 0.0_f32; + for i in 0..dim { + let off = i * 2; + let av = f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + norm_a += av * av; + norm_b += bv * bv; + } + let denom = (norm_a * norm_b).sqrt(); + if denom < f32::EPSILON { + return 1.0; + } + (1.0 - (dot / denom)).max(0.0) +} + +/// Negative inner product between two F16-encoded byte slices. +/// +/// Returns `-dot(a, b)`. Negated so that "lower is closer" ordering is +/// consistent with all other distance metrics. +pub(crate) fn neg_inner_product_f16(a: &[u8], b: &[u8], dim: usize) -> f32 { + debug_assert_eq!(a.len(), dim * 2); + debug_assert_eq!(b.len(), dim * 2); + let mut dot = 0.0_f32; + for i in 0..dim { + let off = i * 2; + let av = f16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = f16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot +} + +// ── BF16 kernels ───────────────────────────────────────────────────────────── + +/// L2-squared distance between two BF16-encoded byte slices. +/// +/// Byte-for-byte identical to `l2_squared_f16` except uses +/// `bf16::from_le_bytes` for element decoding. +pub(crate) fn l2_squared_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + debug_assert_eq!(a.len(), dim * 2); + debug_assert_eq!(b.len(), dim * 2); + let mut acc = 0.0_f32; + for i in 0..dim { + let off = i * 2; + let av = bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + let diff = av - bv; + acc += diff * diff; + } + acc +} + +/// Cosine distance between two BF16-encoded byte slices. +/// +/// Same single-pass formula as `cosine_f16`; zero-magnitude returns `1.0`. +pub(crate) fn cosine_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + debug_assert_eq!(a.len(), dim * 2); + debug_assert_eq!(b.len(), dim * 2); + let mut dot = 0.0_f32; + let mut norm_a = 0.0_f32; + let mut norm_b = 0.0_f32; + for i in 0..dim { + let off = i * 2; + let av = bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + norm_a += av * av; + norm_b += bv * bv; + } + let denom = (norm_a * norm_b).sqrt(); + if denom < f32::EPSILON { + return 1.0; + } + (1.0 - (dot / denom)).max(0.0) +} + +/// Negative inner product between two BF16-encoded byte slices. +/// +/// Returns `-dot(a, b)` with BF16 element decoding. +pub(crate) fn neg_inner_product_bf16(a: &[u8], b: &[u8], dim: usize) -> f32 { + debug_assert_eq!(a.len(), dim * 2); + debug_assert_eq!(b.len(), dim * 2); + let mut dot = 0.0_f32; + for i in 0..dim { + let off = i * 2; + let av = bf16::from_le_bytes([a[off], a[off + 1]]).to_f32(); + let bv = bf16::from_le_bytes([b[off], b[off + 1]]).to_f32(); + dot += av * bv; + } + -dot +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::distance::DistanceMetric; + use crate::dtype::cast_from_f32; + use nodedb_types::vector_dtype::VectorStorageDtype; + + const EPS_F16: f32 = 1e-2; + const EPS_BF16: f32 = 1e-1; + + // Fixed vector pair shared across all tests. + const A: [f32; 5] = [0.5, -1.0, 2.5, 0.1, 100.0]; + const B: [f32; 5] = [1.0, 0.5, -1.5, 2.0, 50.0]; + + // Reference: cast F32 → dtype → F32, then run the F32 distance kernel. + fn f32_reference_via_roundtrip(metric: DistanceMetric, dtype: VectorStorageDtype) -> f32 { + use crate::dtype::cast_to_f32; + let a_bytes = cast_from_f32(&A, dtype); + let b_bytes = cast_from_f32(&B, dtype); + let a_f32 = cast_to_f32(&a_bytes, dtype, 5).expect("round-trip cast must succeed"); + let b_f32 = cast_to_f32(&b_bytes, dtype, 5).expect("round-trip cast must succeed"); + crate::distance::distance(&a_f32, &b_f32, metric) + } + + // ── F16 × L2 ────────────────────────────────────────────────────────────── + + #[test] + fn f16_l2_matches_reference() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::F16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F16); + let fused = l2_squared_f16(&a_bytes, &b_bytes, 5); + let reference = f32_reference_via_roundtrip(DistanceMetric::L2, VectorStorageDtype::F16); + assert!( + (fused - reference).abs() < EPS_F16, + "f16 L2: fused={fused}, reference={reference}, diff={}", + (fused - reference).abs() + ); + } + + // ── F16 × Cosine ────────────────────────────────────────────────────────── + + #[test] + fn f16_cosine_matches_reference() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::F16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F16); + let fused = cosine_f16(&a_bytes, &b_bytes, 5); + let reference = + f32_reference_via_roundtrip(DistanceMetric::Cosine, VectorStorageDtype::F16); + assert!( + (fused - reference).abs() < EPS_F16, + "f16 Cosine: fused={fused}, reference={reference}, diff={}", + (fused - reference).abs() + ); + } + + // ── F16 × InnerProduct ──────────────────────────────────────────────────── + + #[test] + fn f16_neg_inner_product_matches_reference() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::F16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F16); + let fused = neg_inner_product_f16(&a_bytes, &b_bytes, 5); + let reference = + f32_reference_via_roundtrip(DistanceMetric::InnerProduct, VectorStorageDtype::F16); + assert!( + (fused - reference).abs() < EPS_F16, + "f16 NegIP: fused={fused}, reference={reference}, diff={}", + (fused - reference).abs() + ); + } + + // ── BF16 × L2 ───────────────────────────────────────────────────────────── + + #[test] + fn bf16_l2_matches_reference() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::BF16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::BF16); + let fused = l2_squared_bf16(&a_bytes, &b_bytes, 5); + let reference = f32_reference_via_roundtrip(DistanceMetric::L2, VectorStorageDtype::BF16); + assert!( + (fused - reference).abs() < EPS_BF16, + "bf16 L2: fused={fused}, reference={reference}, diff={}", + (fused - reference).abs() + ); + } + + // ── BF16 × Cosine ───────────────────────────────────────────────────────── + + #[test] + fn bf16_cosine_matches_reference() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::BF16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::BF16); + let fused = cosine_bf16(&a_bytes, &b_bytes, 5); + let reference = + f32_reference_via_roundtrip(DistanceMetric::Cosine, VectorStorageDtype::BF16); + assert!( + (fused - reference).abs() < EPS_BF16, + "bf16 Cosine: fused={fused}, reference={reference}, diff={}", + (fused - reference).abs() + ); + } + + // ── BF16 × InnerProduct ─────────────────────────────────────────────────── + + #[test] + fn bf16_neg_inner_product_matches_reference() { + let a_bytes = cast_from_f32(&A, VectorStorageDtype::BF16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::BF16); + let fused = neg_inner_product_bf16(&a_bytes, &b_bytes, 5); + let reference = + f32_reference_via_roundtrip(DistanceMetric::InnerProduct, VectorStorageDtype::BF16); + assert!( + (fused - reference).abs() < EPS_BF16, + "bf16 NegIP: fused={fused}, reference={reference}, diff={}", + (fused - reference).abs() + ); + } + + // ── Zero-norm cosine returns 1.0 ───────────────────────────────────────── + + #[test] + fn f16_zero_norm_cosine_returns_one() { + let zero = [0.0_f32; 5]; + let zero_bytes = cast_from_f32(&zero, VectorStorageDtype::F16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::F16); + assert_eq!( + cosine_f16(&zero_bytes, &b_bytes, 5), + 1.0, + "f16 cosine of zero vector must be 1.0" + ); + assert_eq!( + cosine_f16(&b_bytes, &zero_bytes, 5), + 1.0, + "f16 cosine against zero vector must be 1.0" + ); + } + + #[test] + fn bf16_zero_norm_cosine_returns_one() { + let zero = [0.0_f32; 5]; + let zero_bytes = cast_from_f32(&zero, VectorStorageDtype::BF16); + let b_bytes = cast_from_f32(&B, VectorStorageDtype::BF16); + assert_eq!( + cosine_bf16(&zero_bytes, &b_bytes, 5), + 1.0, + "bf16 cosine of zero vector must be 1.0" + ); + assert_eq!( + cosine_bf16(&b_bytes, &zero_bytes, 5), + 1.0, + "bf16 cosine against zero vector must be 1.0" + ); + } +} diff --git a/nodedb-vector/src/dtype/cast.rs b/nodedb-vector/src/dtype/cast.rs new file mode 100644 index 000000000..dcb3fefef --- /dev/null +++ b/nodedb-vector/src/dtype/cast.rs @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Byte-level dtype conversion helpers for vector buffers. +//! +//! These scalar primitives cast raw byte slices between F32, F16, and BF16 +//! representations. SIMD acceleration (D5-D8) will dispatch through these +//! validated paths; this layer handles correctness only. +//! +//! All multi-byte values are little-endian, matching NodeDB's on-wire and WAL +//! conventions. + +use half::{bf16, f16}; +use nodedb_types::vector_dtype::VectorStorageDtype; + +/// Error returned when byte buffer dimensions do not match the expected dtype +/// layout, or when input is otherwise malformed for the requested dtype cast. +#[derive(thiserror::Error, Debug)] +pub enum DtypeError { + /// Byte buffer length does not match `dtype.bytes_for_dim(dim)`. + #[error( + "dtype byte-length mismatch for {dtype}: expected {expected} bytes for dim {dim}, got {actual}" + )] + BadByteLen { + dtype: VectorStorageDtype, + dim: usize, + expected: usize, + actual: usize, + }, +} + +/// Verify that `bytes.len() == dtype.bytes_for_dim(dim)`. +/// +/// Returns `Err(DtypeError::BadByteLen)` on mismatch. Public so distance and +/// index code can validate inputs before delegating to the cast functions. +pub fn validate_byte_len( + bytes: &[u8], + dtype: VectorStorageDtype, + dim: usize, +) -> Result<(), DtypeError> { + let expected = dtype.bytes_for_dim(dim); + if bytes.len() != expected { + return Err(DtypeError::BadByteLen { + dtype, + dim, + expected, + actual: bytes.len(), + }); + } + Ok(()) +} + +/// Cast a typed byte buffer to a freshly-allocated `Vec` of length `dim`. +/// +/// Up-converts F16 / BF16 to F32 element-wise. F32 input is a memcopy (still +/// allocates a new `Vec` to keep the return type uniform; zero-copy paths live +/// in the distance kernels themselves, not here). +/// +/// All multi-byte reads are little-endian. +/// +/// # Errors +/// +/// Returns `DtypeError::BadByteLen` if `src.len() != dtype.bytes_for_dim(dim)`. +pub fn cast_to_f32( + src: &[u8], + dtype: VectorStorageDtype, + dim: usize, +) -> Result, DtypeError> { + validate_byte_len(src, dtype, dim)?; + + match dtype { + VectorStorageDtype::F32 => { + // bytemuck::cast_slice requires the source to be aligned to f32's + // alignment. A raw &[u8] slice has alignment 1, which satisfies + // bytemuck's contract only when the destination type has alignment 1 + // too. Use explicit chunked reads instead to avoid alignment issues + // on arbitrary byte slices passed in from WAL / mmap regions. + let out = src + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + Ok(out) + } + VectorStorageDtype::F16 => { + let out = src + .chunks_exact(2) + .map(|c| f16::from_le_bytes([c[0], c[1]]).to_f32()) + .collect(); + Ok(out) + } + VectorStorageDtype::BF16 => { + let out = src + .chunks_exact(2) + .map(|c| bf16::from_le_bytes([c[0], c[1]]).to_f32()) + .collect(); + Ok(out) + } + // `VectorStorageDtype` is #[non_exhaustive]; this arm is required by + // the compiler but unreachable with any currently-defined variant. + _ => unreachable!("unrecognised VectorStorageDtype variant in cast_to_f32"), + } +} + +/// Cast a `&[f32]` slice into a freshly-allocated byte buffer in the target +/// dtype, suitable for storage. +/// +/// - `F32` → 4 bytes per element (little-endian IEEE 754 single). +/// - `F16` / `BF16` → 2 bytes per element. Rounding follows +/// `half::f16::from_f32` / `half::bf16::from_f32` (round-to-nearest-even, +/// per IEEE 754 / Brain Float spec). +/// +/// Returns an empty `Vec` for an empty `src` slice. +pub fn cast_from_f32(src: &[f32], dtype: VectorStorageDtype) -> Vec { + match dtype { + VectorStorageDtype::F32 => { + let mut out = Vec::with_capacity(src.len() * 4); + for &x in src { + out.extend_from_slice(&x.to_le_bytes()); + } + out + } + VectorStorageDtype::F16 => { + let mut out = Vec::with_capacity(src.len() * 2); + for &x in src { + out.extend_from_slice(&f16::from_f32(x).to_le_bytes()); + } + out + } + VectorStorageDtype::BF16 => { + let mut out = Vec::with_capacity(src.len() * 2); + for &x in src { + out.extend_from_slice(&bf16::from_f32(x).to_le_bytes()); + } + out + } + // `VectorStorageDtype` is #[non_exhaustive]; this arm is required by + // the compiler but unreachable with any currently-defined variant. + _ => unreachable!("unrecognised VectorStorageDtype variant in cast_from_f32"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── F32 ────────────────────────────────────────────────────────────────── + + #[test] + fn f32_round_trip_identity() { + let src = [1.0_f32, 2.0, 3.0]; + let bytes = cast_from_f32(&src, VectorStorageDtype::F32); + let got = cast_to_f32(&bytes, VectorStorageDtype::F32, 3).unwrap(); + assert_eq!(got, vec![1.0_f32, 2.0, 3.0]); + } + + #[test] + fn f32_empty_round_trip() { + let bytes = cast_from_f32(&[], VectorStorageDtype::F32); + assert!(bytes.is_empty()); + let got = cast_to_f32(&[], VectorStorageDtype::F32, 0).unwrap(); + assert!(got.is_empty()); + } + + // ── F16 ────────────────────────────────────────────────────────────────── + + #[test] + fn f16_round_trip_within_tolerance() { + // Values chosen to round cleanly in F16 (exact or near-exact repr). + let src = [0.5_f32, 1.0, 2.5, 100.0]; + let bytes = cast_from_f32(&src, VectorStorageDtype::F16); + let got = cast_to_f32(&bytes, VectorStorageDtype::F16, 4).unwrap(); + for (orig, recovered) in src.iter().zip(got.iter()) { + assert!( + (orig - recovered).abs() < 1e-3, + "F16 round-trip: {orig} → {recovered}, diff too large" + ); + } + } + + #[test] + fn f16_empty_round_trip() { + let bytes = cast_from_f32(&[], VectorStorageDtype::F16); + assert!(bytes.is_empty()); + let got = cast_to_f32(&[], VectorStorageDtype::F16, 0).unwrap(); + assert!(got.is_empty()); + } + + // ── BF16 ───────────────────────────────────────────────────────────────── + + #[test] + fn bf16_round_trip_within_tolerance() { + // BF16 has ~7-bit mantissa; pick floats representable within 1% error. + let src = [0.5_f32, 1.0, 2.5, 100.0]; + let bytes = cast_from_f32(&src, VectorStorageDtype::BF16); + let got = cast_to_f32(&bytes, VectorStorageDtype::BF16, 4).unwrap(); + for (orig, recovered) in src.iter().zip(got.iter()) { + assert!( + (orig - recovered).abs() < 1e-2, + "BF16 round-trip: {orig} → {recovered}, diff too large" + ); + } + } + + #[test] + fn bf16_empty_round_trip() { + let bytes = cast_from_f32(&[], VectorStorageDtype::BF16); + assert!(bytes.is_empty()); + let got = cast_to_f32(&[], VectorStorageDtype::BF16, 0).unwrap(); + assert!(got.is_empty()); + } + + // ── Range semantics (BF16 wide range vs F16 overflow) ──────────────────── + + #[test] + fn bf16_can_represent_large_values_f16_cannot() { + // 1e30 is within BF16's exponent range (matches F32 range) but overflows F16. + let large = [1.0e30_f32]; + + let bf16_bytes = cast_from_f32(&large, VectorStorageDtype::BF16); + let bf16_back = cast_to_f32(&bf16_bytes, VectorStorageDtype::BF16, 1).unwrap(); + assert!( + bf16_back[0].is_finite(), + "BF16 should represent 1e30 as finite" + ); + + let f16_bytes = cast_from_f32(&large, VectorStorageDtype::F16); + let f16_back = cast_to_f32(&f16_bytes, VectorStorageDtype::F16, 1).unwrap(); + assert!( + f16_back[0].is_infinite(), + "F16 should overflow 1e30 to infinity" + ); + } + + // ── Byte-length validation ──────────────────────────────────────────────── + + #[test] + fn bad_byte_len_f32_mismatch() { + let err = cast_to_f32(&[0u8; 7], VectorStorageDtype::F32, 2).unwrap_err(); + match err { + DtypeError::BadByteLen { + dtype, + dim, + expected, + actual, + } => { + assert_eq!(dtype, VectorStorageDtype::F32); + assert_eq!(dim, 2); + assert_eq!(expected, 8); + assert_eq!(actual, 7); + } + } + } + + #[test] + fn bad_byte_len_f16_odd_byte_count() { + let err = cast_to_f32(&[0u8; 3], VectorStorageDtype::F16, 2).unwrap_err(); + match err { + DtypeError::BadByteLen { + dtype, + dim, + expected, + actual, + } => { + assert_eq!(dtype, VectorStorageDtype::F16); + assert_eq!(dim, 2); + assert_eq!(expected, 4); + assert_eq!(actual, 3); + } + } + } + + #[test] + fn bad_byte_len_bf16_mismatch() { + let err = cast_to_f32(&[0u8; 5], VectorStorageDtype::BF16, 3).unwrap_err(); + match err { + DtypeError::BadByteLen { + dtype, + dim, + expected, + actual, + } => { + assert_eq!(dtype, VectorStorageDtype::BF16); + assert_eq!(dim, 3); + assert_eq!(expected, 6); + assert_eq!(actual, 5); + } + } + } + + // ── validate_byte_len independently ────────────────────────────────────── + + #[test] + fn validate_byte_len_correct_passes() { + let bytes = [0u8; 12]; // 3 × F32 + assert!(validate_byte_len(&bytes, VectorStorageDtype::F32, 3).is_ok()); + } + + #[test] + fn validate_byte_len_off_by_one_fails() { + let bytes = [0u8; 11]; // should be 12 + let err = validate_byte_len(&bytes, VectorStorageDtype::F32, 3).unwrap_err(); + match err { + DtypeError::BadByteLen { + expected, actual, .. + } => { + assert_eq!(expected, 12); + assert_eq!(actual, 11); + } + } + } +} diff --git a/nodedb-vector/src/dtype/mod.rs b/nodedb-vector/src/dtype/mod.rs new file mode 100644 index 000000000..55600fb29 --- /dev/null +++ b/nodedb-vector/src/dtype/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub mod cast; + +pub use cast::{DtypeError, cast_from_f32, cast_to_f32, validate_byte_len}; diff --git a/nodedb-vector/src/hnsw/build.rs b/nodedb-vector/src/hnsw/build.rs index 7b7b1ed7e..f43a85a50 100644 --- a/nodedb-vector/src/hnsw/build.rs +++ b/nodedb-vector/src/hnsw/build.rs @@ -2,9 +2,11 @@ //! HNSW insert algorithm (Malkov & Yashunin, Algorithm 1). +use crate::dtype::cast_from_f32; use crate::error::VectorError; -use crate::hnsw::graph::{Candidate, HnswIndex, Node}; +use crate::hnsw::graph::{Candidate, HnswIndex, Node, NodeStorage}; use crate::hnsw::search::search_layer; +use nodedb_types::vector_dtype::VectorStorageDtype; impl HnswIndex { /// Insert a vector into the index. @@ -28,8 +30,15 @@ impl HnswIndex { let new_id = self.nodes.len() as u32; let new_layer = self.random_layer(); + let storage = match self.params.dtype { + VectorStorageDtype::F32 => NodeStorage::F32(vector.clone()), + dtype => NodeStorage::Bytes { + dtype, + bytes: cast_from_f32(&vector, dtype), + }, + }; let node = Node { - vector, + storage, neighbors: (0..=new_layer).map(|_| Vec::new()).collect(), deleted: false, }; @@ -41,15 +50,16 @@ impl HnswIndex { return Ok(()); }; - // Clone the query vector to avoid aliasing self.nodes during mutation. - let query = self.nodes[new_id as usize].vector.clone(); + // Encode query once to the index dtype for all dist_to_node calls below. + let query = vector; + let query_bytes = cast_from_f32(&query, self.params.dtype); let mut current_ep = ep; // Phase 1: Greedy descent from top layer to new_layer + 1. if self.max_layer > new_layer { for layer in (new_layer + 1..=self.max_layer).rev() { - let results = search_layer(self, &query, current_ep, 1, layer, None, 0); + let results = search_layer(self, &query_bytes, current_ep, 1, layer, None, 0); if let Some(nearest) = results.first() { current_ep = nearest.id; } @@ -60,7 +70,7 @@ impl HnswIndex { let insert_top = new_layer.min(self.max_layer); for layer in (0..=insert_top).rev() { let ef = self.params.ef_construction; - let candidates = search_layer(self, &query, current_ep, ef, layer, None, 0); + let candidates = search_layer(self, &query_bytes, current_ep, ef, layer, None, 0); let m = self.max_neighbors(layer); let selected = select_neighbors_heuristic(self, &candidates, m); @@ -72,8 +82,8 @@ impl HnswIndex { self.nodes[nid].neighbors[layer].push(new_id); if self.nodes[nid].neighbors[layer].len() > m { - let node_vec = self.nodes[nid].vector.clone(); - self.prune_neighbors(nid, layer, &node_vec, m); + let node_bytes = self.nodes[nid].storage.as_bytes().to_vec(); + self.prune_neighbors(nid, layer, &node_bytes, m); } } @@ -91,14 +101,14 @@ impl HnswIndex { } /// Prune a node's neighbor list using the diversity heuristic. - fn prune_neighbors(&mut self, node_idx: usize, layer: usize, node_vec: &[f32], m: usize) { + fn prune_neighbors(&mut self, node_idx: usize, layer: usize, node_bytes: &[u8], m: usize) { let neighbor_ids: Vec = self.nodes[node_idx].neighbors[layer].clone(); let mut candidates: Vec = neighbor_ids .iter() .map(|&nid| Candidate { id: nid, - dist: self.dist_to_node(node_vec, nid), + dist: self.dist_to_node(node_bytes, nid), }) .collect(); candidates.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); @@ -122,18 +132,19 @@ fn select_neighbors_heuristic( break; } - let candidate_vec = &index.nodes[candidate.id as usize].vector; - let selected_vecs: Vec<&[f32]> = selected - .iter() - .map(|s| index.nodes[s.id as usize].vector.as_slice()) - .collect(); - - let is_diverse = crate::batch_distance::is_diverse_batched( - candidate_vec, - candidate.dist, - &selected_vecs, - index.params.metric, - ); + let candidate_bytes = index.nodes[candidate.id as usize].storage.as_bytes(); + let is_diverse = selected.iter().all(|s| { + let selected_bytes = index.nodes[s.id as usize].storage.as_bytes(); + let dist_to_selected = crate::distance::dispatch::distance_typed( + index.params.metric, + index.params.dtype, + candidate_bytes, + selected_bytes, + index.dim, + ) + .unwrap_or(f32::MAX); + candidate.dist <= dist_to_selected + }); if is_diverse { selected.push(*candidate); @@ -169,6 +180,7 @@ mod tests { m0: 8, ef_construction: 32, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 12345, ) diff --git a/nodedb-vector/src/hnsw/checkpoint.rs b/nodedb-vector/src/hnsw/checkpoint.rs index 88816fbd0..8827c7bc8 100644 --- a/nodedb-vector/src/hnsw/checkpoint.rs +++ b/nodedb-vector/src/hnsw/checkpoint.rs @@ -9,7 +9,7 @@ use std::cell::RefCell; use crate::distance::DistanceMetric; use crate::hnsw::arena::BeamSearchArena; use crate::hnsw::flat_neighbors::FlatNeighborStore; -use crate::hnsw::graph::{ARENA_INITIAL_CAPACITY, HnswIndex, Node, Xorshift64}; +use crate::hnsw::graph::{ARENA_INITIAL_CAPACITY, HnswIndex, Node, NodeStorage, Xorshift64}; /// Magic header for rkyv-serialized HNSW snapshots (6 bytes). const HNSW_RKYV_MAGIC: &[u8; 6] = b"RKHNS\0"; @@ -50,7 +50,7 @@ impl HnswIndex { entry_point: self.entry_point, max_layer: self.max_layer, rng_state: self.rng.0, - node_vectors: self.nodes.iter().map(|n| n.vector.clone()).collect(), + node_vectors: self.export_vectors(), node_neighbors: if let Some(ref flat) = self.flat_neighbors { flat.to_nested(self.nodes.len()) } else { @@ -119,7 +119,7 @@ impl HnswIndex { .into_iter() .zip(snap.node_deleted) .map(|(vector, deleted)| Node { - vector, + storage: NodeStorage::F32(vector), neighbors: Vec::new(), deleted, }) @@ -133,6 +133,7 @@ impl HnswIndex { m0: snap.m0, ef_construction: snap.ef_construction, metric, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, nodes, entry_point: snap.entry_point, @@ -157,6 +158,7 @@ mod tests { m0: 8, ef_construction: 32, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 12345, ) diff --git a/nodedb-vector/src/hnsw/graph.rs b/nodedb-vector/src/hnsw/graph/index.rs similarity index 59% rename from nodedb-vector/src/hnsw/graph.rs rename to nodedb-vector/src/hnsw/graph/index.rs index 55c789a94..e10e04a57 100644 --- a/nodedb-vector/src/hnsw/graph.rs +++ b/nodedb-vector/src/hnsw/graph/index.rs @@ -1,48 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -//! HNSW graph structure — nodes, parameters, core index operations. -//! -//! Production implementation per Malkov & Yashunin (2018). -//! FP32 construction for structural integrity; heuristic neighbor selection. - use std::cell::RefCell; +use crate::distance::dispatch::distance_typed; use crate::distance::distance; +use crate::dtype::cast_from_f32; use crate::hnsw::arena::BeamSearchArena; +use nodedb_types::vector_dtype::VectorStorageDtype; -// Re-export shared params from nodedb-types. +use super::types::{Node, NodeStorage, Xorshift64}; +use super::{ARENA_INITIAL_CAPACITY, MAX_LAYER_CAP}; pub use nodedb_types::hnsw::HnswParams; -/// Initial arena capacity used when constructing a new [`HnswIndex`]. -/// -/// Sized to cover `ef_construction = 200` (the default) without needing a -/// reallocation on the first insert or search. -pub(crate) const ARENA_INITIAL_CAPACITY: usize = 256; - -/// Hard cap on the layer assigned to any node during insertion. -/// Standard HNSW practice — prevents pathological RNG draws from inflating -/// `max_layer` and slowing every subsequent search. -pub const MAX_LAYER_CAP: usize = 16; - -/// Result of a k-NN search. -#[derive(Debug, Clone)] -pub struct SearchResult { - /// Internal node identifier (insertion order). - pub id: u32, - /// Distance from the query vector. - pub distance: f32, -} - -/// A node in the HNSW graph. -pub struct Node { - /// Full-precision vector data. - pub vector: Vec, - /// Neighbors at each layer this node participates in. - pub neighbors: Vec>, - /// Tombstone flag for soft-deletion. - pub deleted: bool, -} - /// Hierarchical Navigable Small World graph index. /// /// - FP32 construction for structural integrity @@ -106,46 +75,6 @@ impl HnswIndex { } } -/// Lightweight xorshift64 PRNG for layer assignment. -pub struct Xorshift64(pub u64); - -impl Xorshift64 { - pub fn new(seed: u64) -> Self { - Self(seed.max(1)) - } - - pub fn next_f64(&mut self) -> f64 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 7; - self.0 ^= self.0 << 17; - (self.0 as f64) / (u64::MAX as f64) - } -} - -/// Ordered candidate for priority queues during search and construction. -#[derive(Clone, Copy, PartialEq)] -pub struct Candidate { - pub dist: f32, - pub id: u32, -} - -impl Eq for Candidate {} - -impl PartialOrd for Candidate { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for Candidate { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.dist - .partial_cmp(&other.dist) - .unwrap_or(std::cmp::Ordering::Equal) - .then(self.id.cmp(&other.id)) - } -} - impl HnswIndex { /// The distance metric this index was built with. Search-time metric /// overrides must match this; differing metrics require either rebuilding @@ -240,8 +169,38 @@ impl HnswIndex { self.dim } + /// Storage dtype this index was constructed with. + pub fn dtype(&self) -> VectorStorageDtype { + self.params.dtype + } + + /// Returns a `&[f32]` view of the stored vector for node `id`. + /// + /// Returns `Some` only when the index dtype is `F32`. For `F16` or `BF16` + /// indexes this method returns `None` — use [`Self::get_vector_bytes`] + /// instead and decode via [`crate::dtype::cast_to_f32`] if an f32 view is + /// needed. + /// + /// In debug builds, calling this on a non-F32 index triggers a + /// `debug_assert!` to flag misuse early. In release builds the + /// `debug_assert!` is a no-op and `None` is returned silently. pub fn get_vector(&self, id: u32) -> Option<&[f32]> { - self.nodes.get(id as usize).map(|n| n.vector.as_slice()) + debug_assert!( + self.params.dtype == VectorStorageDtype::F32, + "get_vector: called on non-F32 index (dtype={}); use get_vector_bytes instead", + self.params.dtype, + ); + self.nodes + .get(id as usize) + .and_then(|n| n.storage.as_f32_slice()) + } + + /// Dtype-agnostic byte view of the stored vector for node `id`. + /// + /// Returns `None` if `id` is out of range. Pair the returned slice with + /// [`Self::dtype`] to interpret the encoding. + pub fn get_vector_bytes(&self, id: u32) -> Option<&[u8]> { + self.nodes.get(id as usize).map(|n| n.storage.as_bytes()) } pub fn params(&self) -> &HnswParams { @@ -263,7 +222,7 @@ impl HnswIndex { /// Approximate memory usage in bytes (vector data + neighbor lists). pub fn memory_usage_bytes(&self) -> usize { - let vector_bytes = self.nodes.len() * self.dim * std::mem::size_of::(); + let vector_bytes = self.nodes.len() * self.params.dtype.bytes_for_dim(self.dim); let neighbor_bytes: usize = self .nodes .iter() @@ -278,9 +237,21 @@ impl HnswIndex { vector_bytes + neighbor_bytes + node_overhead } - /// Export all vectors for snapshot transfer. + /// Export all vectors as F32 for snapshot transfer. + /// + /// For F32 indexes this is a clone. For F16/BF16 indexes each vector is + /// decoded to F32 on the fly. pub fn export_vectors(&self) -> Vec> { - self.nodes.iter().map(|n| n.vector.clone()).collect() + self.nodes + .iter() + .map(|n| match &n.storage { + NodeStorage::F32(v) => v.clone(), + NodeStorage::Bytes { dtype, bytes } => { + crate::dtype::cast_to_f32(bytes, *dtype, self.dim) + .expect("export_vectors: byte-length invariant violated") + } + }) + .collect() } /// Export all neighbor lists for snapshot transfer. @@ -300,13 +271,46 @@ impl HnswIndex { layer.min(MAX_LAYER_CAP) } - /// Compute distance between a query vector and a stored node. - pub(crate) fn dist_to_node(&self, query: &[f32], node_id: u32) -> f32 { - distance( - query, - &self.nodes[node_id as usize].vector, + /// Compute distance between a pre-encoded query and a stored node. + /// + /// `query_bytes` must already be encoded in `self.params.dtype`; callers + /// encode once at the top of search/insert and pass the same buffer to + /// every `dist_to_node` call within that operation. + pub(crate) fn dist_to_node(&self, query_bytes: &[u8], node_id: u32) -> f32 { + let node_bytes = self.nodes[node_id as usize].storage.as_bytes(); + distance_typed( self.params.metric, + self.params.dtype, + query_bytes, + node_bytes, + self.dim, ) + .expect("dist_to_node: byte-length mismatch; byte lengths are validated at insert") + } + + /// Compute distance between a query given as `&[f32]` and a stored node. + /// + /// For F32 indexes this is a direct call to `distance`. For F16/BF16 + /// indexes the query is encoded to the storage dtype on each call, which + /// is an allocation. Prefer pre-encoding the query once via + /// [`crate::dtype::cast_from_f32`] and calling [`Self::dist_to_node`] + /// for hot-path code such as search. + #[allow(dead_code)] + pub(crate) fn dist_to_node_f32(&self, query: &[f32], node_id: u32) -> f32 { + match self.params.dtype { + VectorStorageDtype::F32 => distance( + query, + self.nodes[node_id as usize] + .storage + .as_f32_slice() + .expect("F32 dtype must have F32 storage"), + self.params.metric, + ), + _ => { + let query_bytes = cast_from_f32(query, self.params.dtype); + self.dist_to_node(&query_bytes, node_id) + } + } } /// Max neighbors allowed at a given layer. @@ -374,7 +378,7 @@ impl HnswIndex { }) .collect(); new_nodes.push(Node { - vector: node.vector, + storage: node.storage, neighbors: remapped_neighbors, deleted: false, }); @@ -410,6 +414,17 @@ impl HnswIndex { mod tests { use super::*; use crate::distance::DistanceMetric; + use nodedb_types::vector_dtype::VectorStorageDtype; + + fn make_params(dtype: VectorStorageDtype) -> HnswParams { + HnswParams { + m: 4, + m0: 8, + ef_construction: 32, + metric: DistanceMetric::L2, + dtype, + } + } #[test] fn create_empty_index() { @@ -426,12 +441,93 @@ mod tests { assert_eq!(p.m0, 32); assert_eq!(p.ef_construction, 200); assert_eq!(p.metric, DistanceMetric::Cosine); + assert_eq!(p.dtype, VectorStorageDtype::F32); } #[test] fn candidate_ordering() { - let a = Candidate { dist: 0.1, id: 1 }; - let b = Candidate { dist: 0.5, id: 2 }; + let a = super::super::types::Candidate { dist: 0.1, id: 1 }; + let b = super::super::types::Candidate { dist: 0.5, id: 2 }; assert!(a < b); } + + #[test] + fn f32_default_unchanged() { + let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1); + assert_eq!(idx.dtype(), VectorStorageDtype::F32); + for i in 0..10u32 { + idx.insert(vec![i as f32, 0.0, 0.0]).unwrap(); + } + // get_vector works on F32 indexes. + let v = idx.get_vector(3).unwrap(); + assert_eq!(v[0], 3.0_f32); + // get_vector_bytes also works. + assert_eq!(idx.get_vector_bytes(3).unwrap().len(), 12); // 3 dims * 4 bytes + } + + #[test] + fn f16_insert_search_smoke() { + let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F16), 42); + assert_eq!(idx.dtype(), VectorStorageDtype::F16); + for i in 0..10u32 { + idx.insert(vec![i as f32, 0.0, 0.0]).unwrap(); + } + let results = idx.search(&[5.0, 0.0, 0.0], 3, 32); + assert_eq!(results.len(), 3); + // Results must be in monotonically non-decreasing distance order. + for w in results.windows(2) { + assert!( + w[0].distance <= w[1].distance, + "results not sorted: {:?}", + results + ); + } + } + + #[test] + fn bf16_insert_search_smoke() { + let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::BF16), 42); + assert_eq!(idx.dtype(), VectorStorageDtype::BF16); + for i in 0..10u32 { + idx.insert(vec![i as f32, 0.0, 0.0]).unwrap(); + } + let results = idx.search(&[5.0, 0.0, 0.0], 3, 32); + assert_eq!(results.len(), 3); + for w in results.windows(2) { + assert!( + w[0].distance <= w[1].distance, + "results not sorted: {:?}", + results + ); + } + } + + #[test] + fn get_vector_returns_none_on_non_f32_dtype() { + let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F16), 1); + idx.insert(vec![1.0, 2.0, 3.0]).unwrap(); + // get_vector_bytes works for F16; get_vector does not (returns None in + // release, fires debug_assert in dev — so we only assert None in release). + assert!(idx.get_vector_bytes(0).is_some()); + #[cfg(not(debug_assertions))] + assert!(idx.get_vector(0).is_none()); + } + + #[test] + fn get_vector_bytes_works_for_all_dtypes() { + for (dtype, expected_byte_len) in [ + (VectorStorageDtype::F32, 12usize), // 3 dims * 4 bytes + (VectorStorageDtype::F16, 6usize), // 3 dims * 2 bytes + (VectorStorageDtype::BF16, 6usize), // 3 dims * 2 bytes + ] { + let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1); + idx.insert(vec![1.0, 2.0, 3.0]).unwrap(); + let bytes = idx.get_vector_bytes(0).expect("must be Some for valid id"); + assert_eq!( + bytes.len(), + expected_byte_len, + "wrong byte len for dtype={dtype:?}" + ); + } + } } diff --git a/nodedb-vector/src/hnsw/graph/mod.rs b/nodedb-vector/src/hnsw/graph/mod.rs new file mode 100644 index 000000000..c9b9f2699 --- /dev/null +++ b/nodedb-vector/src/hnsw/graph/mod.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! HNSW graph structure — nodes, parameters, core index operations. +//! +//! Production implementation per Malkov & Yashunin (2018). +//! FP32 construction for structural integrity; heuristic neighbor selection. + +pub mod index; +pub mod types; + +/// Initial arena capacity used when constructing a new [`index::HnswIndex`]. +/// +/// Sized to cover `ef_construction = 200` (the default) without needing a +/// reallocation on the first insert or search. +pub(crate) const ARENA_INITIAL_CAPACITY: usize = 256; + +/// Hard cap on the layer assigned to any node during insertion. +/// Standard HNSW practice — prevents pathological RNG draws from inflating +/// `max_layer` and slowing every subsequent search. +pub const MAX_LAYER_CAP: usize = 16; + +pub use index::HnswIndex; +pub use nodedb_types::hnsw::HnswParams; +pub use types::{Candidate, Node, NodeStorage, SearchResult, Xorshift64}; diff --git a/nodedb-vector/src/hnsw/graph/types.rs b/nodedb-vector/src/hnsw/graph/types.rs new file mode 100644 index 000000000..955875a59 --- /dev/null +++ b/nodedb-vector/src/hnsw/graph/types.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +use nodedb_types::vector_dtype::VectorStorageDtype; + +/// Result of a k-NN search. +#[derive(Debug, Clone)] +pub struct SearchResult { + /// Internal node identifier (insertion order). + pub id: u32, + /// Distance from the query vector. + pub distance: f32, +} + +/// Per-node vector storage, discriminated by dtype. +/// +/// `F32` keeps `Vec` directly for zero-copy `&[f32]` access via +/// `get_vector`. `Bytes` stores a typed byte buffer for F16/BF16 — the +/// dtype tag mirrors `HnswParams::dtype` and is stored here for convenience +/// so callers that only have a `Node` reference do not need to thread the +/// index params. +pub enum NodeStorage { + /// F32 storage — 4 bytes per dim; direct `&[f32]` access, no conversion. + F32(Vec), + /// Reduced-precision storage — 2 bytes per dim; dtype identifies encoding. + Bytes { + dtype: VectorStorageDtype, + bytes: Vec, + }, +} + +impl NodeStorage { + /// Returns a byte-level view of the stored vector regardless of dtype. + /// + /// For `F32`, the cast is alignment-safe in the `f32 → u8` direction + /// (any-alignment requirement for `u8` is satisfied). For `Bytes`, the + /// slice is returned directly. + #[inline] + pub fn as_bytes(&self) -> &[u8] { + match self { + NodeStorage::F32(v) => bytemuck::cast_slice::(v.as_slice()), + NodeStorage::Bytes { bytes, .. } => bytes.as_slice(), + } + } + + /// Returns `Some(&[f32])` only for `F32` storage. + /// + /// For non-F32 storage this returns `None`. Callers that require an f32 + /// view of a reduced-precision node must decode via + /// [`crate::dtype::cast_to_f32`] or use [`Self::as_bytes`] and + /// `distance_typed`. + #[inline] + pub fn as_f32_slice(&self) -> Option<&[f32]> { + match self { + NodeStorage::F32(v) => Some(v.as_slice()), + NodeStorage::Bytes { .. } => None, + } + } +} + +/// A node in the HNSW graph. +pub struct Node { + /// Vector data in the index's configured storage dtype. + pub storage: NodeStorage, + /// Neighbors at each layer this node participates in. + pub neighbors: Vec>, + /// Tombstone flag for soft-deletion. + pub deleted: bool, +} + +/// Lightweight xorshift64 PRNG for layer assignment. +pub struct Xorshift64(pub u64); + +impl Xorshift64 { + pub fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + pub fn next_f64(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 as f64) / (u64::MAX as f64) + } +} + +/// Ordered candidate for priority queues during search and construction. +#[derive(Clone, Copy, PartialEq)] +pub struct Candidate { + pub dist: f32, + pub id: u32, +} + +impl Eq for Candidate {} + +impl PartialOrd for Candidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Candidate { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.dist + .partial_cmp(&other.dist) + .unwrap_or(std::cmp::Ordering::Equal) + .then(self.id.cmp(&other.id)) + } +} diff --git a/nodedb-vector/src/hnsw/search.rs b/nodedb-vector/src/hnsw/search.rs index 01b417c9d..4cd4bfa40 100644 --- a/nodedb-vector/src/hnsw/search.rs +++ b/nodedb-vector/src/hnsw/search.rs @@ -31,6 +31,7 @@ fn prefetch_t0(ptr: *const u8) { use roaring::RoaringBitmap; +use crate::dtype::cast_from_f32; use crate::hnsw::graph::{Candidate, HnswIndex, SearchResult}; impl HnswIndex { @@ -51,17 +52,19 @@ impl HnswIndex { return Vec::new(); }; + let query_bytes = cast_from_f32(query, self.params.dtype); + // Phase 1: Greedy descent from top layer to layer 1. let mut current_ep = ep; for layer in (1..=self.max_layer).rev() { - let results = search_layer(self, query, current_ep, 1, layer, None, 0); + let results = search_layer(self, &query_bytes, current_ep, 1, layer, None, 0); if let Some(nearest) = results.first() { current_ep = nearest.id; } } // Phase 2: Beam search at layer 0. - let results = search_layer(self, query, current_ep, ef, 0, None, 0); + let results = search_layer(self, &query_bytes, current_ep, ef, 0, None, 0); results .into_iter() @@ -107,15 +110,25 @@ impl HnswIndex { return Vec::new(); }; + let query_bytes = cast_from_f32(query, self.params.dtype); + let mut current_ep = ep; for layer in (1..=self.max_layer).rev() { - let results = search_layer(self, query, current_ep, 1, layer, None, 0); + let results = search_layer(self, &query_bytes, current_ep, 1, layer, None, 0); if let Some(nearest) = results.first() { current_ep = nearest.id; } } - let results = search_layer(self, query, current_ep, ef, 0, Some(filter), id_offset); + let results = search_layer( + self, + &query_bytes, + current_ep, + ef, + 0, + Some(filter), + id_offset, + ); results .into_iter() @@ -167,7 +180,7 @@ impl HnswIndex { /// amortised zero-allocation steady state. pub(crate) fn search_layer( index: &HnswIndex, - query: &[f32], + query_bytes: &[u8], entry_point: u32, ef: usize, layer: usize, @@ -181,7 +194,7 @@ pub(crate) fn search_layer( arena.visited.insert(entry_point); - let ep_dist = index.dist_to_node(query, entry_point); + let ep_dist = index.dist_to_node(query_bytes, entry_point); let ep_candidate = Candidate { dist: ep_dist, id: entry_point, @@ -220,14 +233,13 @@ pub(crate) fn search_layer( break; } - // Prefetch the vector of the next candidate before touching this + // Prefetch the vector bytes of the next candidate before touching this // iteration's neighbor list, so it lands in cache by the time the // inner loop calls dist_to_node on it. if let Some(Reverse(next)) = candidates.peek() && let Some(node) = index.nodes.get(next.id as usize) - && let Some(v) = node.vector.first() { - prefetch_t0(v as *const f32 as *const u8); + prefetch_t0(node.storage.as_bytes().as_ptr()); } let neighbors = index.neighbors_at(current.id, layer); @@ -240,7 +252,7 @@ pub(crate) fn search_layer( continue; } - let dist = index.dist_to_node(query, neighbor_id); + let dist = index.dist_to_node(query_bytes, neighbor_id); let neighbor = Candidate { dist, id: neighbor_id, @@ -287,6 +299,7 @@ mod tests { m0: 32, ef_construction: 100, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 42, ); @@ -313,6 +326,7 @@ mod tests { m0: 8, ef_construction: 16, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 1, ); diff --git a/nodedb-vector/src/lib.rs b/nodedb-vector/src/lib.rs index 59311db2f..8f9dbb395 100644 --- a/nodedb-vector/src/lib.rs +++ b/nodedb-vector/src/lib.rs @@ -17,6 +17,7 @@ pub mod batch_distance; pub mod codec_index; pub mod delta; pub mod distance; +pub mod dtype; pub mod error; pub mod hnsw; pub mod hybrid; diff --git a/nodedb-vector/src/navix/acorn.rs b/nodedb-vector/src/navix/acorn.rs index c93ec9ce8..6ec7f8444 100644 --- a/nodedb-vector/src/navix/acorn.rs +++ b/nodedb-vector/src/navix/acorn.rs @@ -270,6 +270,7 @@ mod inner { m0: 16, ef_construction: 50, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 42, ); diff --git a/nodedb-vector/src/navix/traversal.rs b/nodedb-vector/src/navix/traversal.rs index fa2c52bcb..677e6f494 100644 --- a/nodedb-vector/src/navix/traversal.rs +++ b/nodedb-vector/src/navix/traversal.rs @@ -468,6 +468,7 @@ mod tests { m0: 16, ef_construction: 50, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 42, ); @@ -608,6 +609,7 @@ mod tests { m0: 16, ef_construction: 50, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, ); let mut allowed = RoaringBitmap::new(); diff --git a/nodedb-vector/src/rerank/codec.rs b/nodedb-vector/src/rerank/codec.rs new file mode 100644 index 000000000..1338f2d63 --- /dev/null +++ b/nodedb-vector/src/rerank/codec.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use super::types::RerankError; + +/// Identity tag for a rerank codec — used to detect mismatch when a search +/// requests a different codec than the sidecar was built with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodecName { + Sq8, + Pq, + Binary, + RaBitQ, + Bbq, +} + +impl CodecName { + pub fn as_str(self) -> &'static str { + match self { + CodecName::Sq8 => "sq8", + CodecName::Pq => "pq", + CodecName::Binary => "binary", + CodecName::RaBitQ => "rabitq", + CodecName::Bbq => "bbq", + } + } +} + +/// Prepared query form — opaque payload held by the caller between +/// `prepare_query` and `distance_prepared` calls. New variants will be added +/// as specific codec impls land in later sub-tasks. +pub enum PreparedQuery { + /// Raw full-precision query, used by codecs whose prepared form is just + /// the input vector (e.g. RaBitQ rotation applied later, Binary). + Raw(Vec), + /// Per-subspace lookup table, used by ADC-style codecs (PQ, OPQ). + Lut(Vec>), + /// Codec-specific opaque bytes — for codecs that don't fit the above two + /// shapes (e.g. BBQ carries a centroid + alpha). + Bytes(Vec), +} + +/// Object-safe trait for asymmetric rerank codecs. Each impl wraps an existing +/// `nodedb-codec::VectorCodec` and exposes a uniform shape so the sidecar can +/// hold `Arc` regardless of the underlying associated-type +/// machinery. +pub trait RerankCodec: Send + Sync { + /// Encode a full-precision vector. Returns fixed-width bytes for this codec. + fn encode(&self, v: &[f32]) -> Result, RerankError>; + + /// Prepare a query once before repeated distance calls. + fn prepare_query(&self, q: &[f32]) -> Result; + + /// Compute asymmetric distance from a prepared query to an encoded vector. + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result; + + /// Identity tag for mismatch detection. + fn name(&self) -> CodecName; + + /// Train from a sample of vectors. Default no-op for codecs that don't need + /// training (e.g. Binary). Specific codec impls override this when needed. + fn train(&mut self, _samples: &[&[f32]]) -> Result<(), RerankError> { + Ok(()) + } + + /// Serialize trained state to bytes. Each codec uses its own magic header + /// (NDSQ / NDBIN / NDPQ / NDRBQ / NDBBQ). The bytes are codec-specific; + /// `rerank_codec_from_bytes` is used for restore, paired with `name()`. + fn to_bytes(&self) -> Result, RerankError>; +} + +/// Reconstruct a `RerankCodec` from its byte form. The `name` tag tells us +/// which wrapper to dispatch into; the bytes are the codec's own format. +pub fn rerank_codec_from_bytes( + name: CodecName, + bytes: &[u8], +) -> Result, RerankError> { + use crate::quantize::pq::PqCodec; + use crate::quantize::sq8::Sq8Codec; + use crate::rerank::codecs::{BbqRerank, BinaryRerank, PqRerank, RaBitQRerank, Sq8Rerank}; + use nodedb_codec::vector_quant::bbq::BbqCodec; + use nodedb_codec::vector_quant::rabitq::RaBitQCodec; + + match name { + CodecName::Sq8 => { + let inner = Sq8Codec::from_bytes(bytes) + .map_err(|e| RerankError::BadInput(format!("sq8 from_bytes: {e}")))?; + Ok(Arc::new(Sq8Rerank::from_codec(inner))) + } + CodecName::Binary => { + // Format: [NDBIN\0 (6 bytes)][version u8 = 1][dim u32 LE (4 bytes)] — 11 bytes total. + if bytes.len() < 11 { + return Err(RerankError::BadInput("binary from_bytes: too short".into())); + } + if &bytes[..6] != b"NDBIN\0" { + return Err(RerankError::BadInput("binary from_bytes: bad magic".into())); + } + // bytes[6] is version, bytes[7..11] is dim as u32 LE. + let dim = u32::from_le_bytes([bytes[7], bytes[8], bytes[9], bytes[10]]) as usize; + Ok(Arc::new(BinaryRerank::new(dim))) + } + CodecName::Pq => { + let inner = PqCodec::from_bytes(bytes) + .map_err(|e| RerankError::BadInput(format!("pq from_bytes: {e}")))?; + Ok(Arc::new(PqRerank::from_codec(inner))) + } + CodecName::RaBitQ => { + let inner = RaBitQCodec::from_bytes(bytes) + .map_err(|e| RerankError::BadInput(format!("rabitq from_bytes: {e}")))?; + Ok(Arc::new(RaBitQRerank::from_codec(inner))) + } + CodecName::Bbq => { + let inner = BbqCodec::from_bytes(bytes) + .map_err(|e| RerankError::BadInput(format!("bbq from_bytes: {e}")))?; + Ok(Arc::new(BbqRerank::from_codec(inner))) + } + } +} diff --git a/nodedb-vector/src/rerank/codecs/bbq.rs b/nodedb-vector/src/rerank/codecs/bbq.rs new file mode 100644 index 000000000..663fbdc05 --- /dev/null +++ b/nodedb-vector/src/rerank/codecs/bbq.rs @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `RerankCodec` wrapper for BBQ (Better Binary Quantization). +//! +//! BBQ is a training-based codec: `train()` calibrates a centroid from a +//! sample of vectors. Until training is complete, `encode` and `prepare_query` +//! return `RerankError::NotTrained`. +//! +//! Distance uses the asymmetric path from the BBQ paper: the query is kept in +//! centered FP32; the stored vector is reconstructed from its 1-bit sign pack +//! and `residual_norm` (≈ ±norm/√dim per dimension). The L2 distance between +//! the exact centered query and the reconstructed candidate is returned. +//! +//! The prepared form is `PreparedQuery::Bytes` with the layout: +//! [0..4] alpha (query_norm) as f32 little-endian +//! [4..4+dim*4] centered f32 values, each as f32 little-endian + +use nodedb_codec::vector_quant::bbq::BbqCodec; +use nodedb_codec::vector_quant::codec::VectorCodec as _; +use nodedb_codec::vector_quant::layout::UnifiedQuantizedVectorRef; + +use crate::{ + rerank::codec::{CodecName, PreparedQuery, RerankCodec}, + rerank::types::RerankError, +}; + +// ── Payload helpers ─────────────────────────────────────────────────────────── + +fn encode_payload(query_norm: f32, centered: &[f32]) -> Vec { + // Layout: 4 bytes alpha (query_norm f32 LE) || dim * 4 bytes centered f32 LE + let mut buf = Vec::with_capacity(4 + centered.len() * 4); + buf.extend_from_slice(&query_norm.to_le_bytes()); + for &x in centered { + buf.extend_from_slice(&x.to_le_bytes()); + } + buf +} + +fn decode_payload(payload: &[u8], dim: usize) -> Result<(f32, Vec), RerankError> { + let expected = 4 + dim * 4; + if payload.len() != expected { + return Err(RerankError::BadInput(format!( + "bbq distance: payload len {} != expected {} for dim {}", + payload.len(), + expected, + dim + ))); + } + let query_norm = f32::from_le_bytes( + payload[..4] + .try_into() + .expect("slice of 4 bytes always converts to [u8;4]"), + ); + let centered: Vec = payload[4..] + .chunks_exact(4) + .map(|b| f32::from_le_bytes(b.try_into().expect("chunks_exact(4) always 4 bytes"))) + .collect(); + Ok((query_norm, centered)) +} + +// ── Inline dequantize (mirrors BbqCodec::dequantize, which is private) ──────── + +/// Reconstruct an approximate FP32 vector from BBQ sign bits and residual norm. +/// +/// Each dimension is approximated as ±residual_norm / √dim, with the sign +/// taken from the packed bit (MSB-first within each byte, same as BBQ's +/// `pack_signs`). +#[inline] +fn bbq_dequantize(packed: &[u8], residual_norm: f32, dim: usize) -> Vec { + let scale = if dim > 0 { + residual_norm / (dim as f32).sqrt() + } else { + 0.0 + }; + (0..dim) + .map(|i| { + let bit = (packed[i / 8] >> (7 - (i % 8))) & 1; + if bit != 0 { scale } else { -scale } + }) + .collect() +} + +// ── BbqRerank ───────────────────────────────────────────────────────────────── + +/// Default oversample multiplier used when the caller does not specify one. +pub const DEFAULT_OVERSAMPLE: u8 = 4; + +/// Object-safe `RerankCodec` wrapper around `BbqCodec`. +/// +/// The codec starts untrained. `encode` and `prepare_query` return +/// `RerankError::NotTrained` until `train()` has been called with a +/// representative sample of vectors. +/// +/// `from_codec` accepts a pre-calibrated `BbqCodec` (used when restoring +/// from a snapshot). +pub struct BbqRerank { + codec: Option, + dim: usize, + oversample: u8, +} + +impl BbqRerank { + /// Construct an untrained wrapper. + /// + /// `encode` / `distance_prepared` return `RerankError::NotTrained` until + /// `train()` is called. + pub fn new(dim: usize, oversample: u8) -> Self { + Self { + codec: None, + dim, + oversample, + } + } + + /// Construct from a pre-calibrated codec (used when restoring from snapshot). + pub fn from_codec(codec: BbqCodec) -> Self { + let dim = codec.dim; + Self { + codec: Some(codec), + dim, + oversample: DEFAULT_OVERSAMPLE, + } + } +} + +impl RerankCodec for BbqRerank { + /// Encode a full-precision vector to BBQ 1-bit bytes. + /// + /// The serialised form is the raw `UnifiedQuantizedVector` buffer + /// (`as_bytes()`): 32-byte `QuantHeader` followed by `dim.div_ceil(8)` + /// sign-packed bits plus 14 bytes of corrective factors in the header. + fn encode(&self, v: &[f32]) -> Result, RerankError> { + if v.len() != self.dim { + return Err(RerankError::BadInput(format!( + "bbq encode: vector len {} != codec dim {}", + v.len(), + self.dim + ))); + } + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained( + "bbq: codec must be trained before encoding (call train() with a sample of vectors)" + .to_string(), + ) + })?; + let quantized = codec.encode(v); + Ok(quantized.as_ref().as_bytes().to_vec()) + } + + /// Prepare the query by centering it and serialising the exact FP32 centered + /// vector alongside the query norm. + /// + /// The prepared form is `PreparedQuery::Bytes` with the layout: + /// 4 bytes query_norm (f32 LE) || dim × 4 bytes centered f32 LE. + fn prepare_query(&self, q: &[f32]) -> Result { + if q.len() != self.dim { + return Err(RerankError::BadInput(format!( + "bbq prepare_query: query len {} != codec dim {}", + q.len(), + self.dim + ))); + } + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained( + "bbq: codec must be trained before prepare_query (call train() with a sample of vectors)" + .to_string(), + ) + })?; + let query = codec.prepare_query(q); + Ok(PreparedQuery::Bytes(encode_payload( + query.query_norm, + &query.centered, + ))) + } + + /// Compute asymmetric L2 distance from a prepared query to a BBQ-encoded + /// candidate. + /// + /// The query is the exact centered FP32 vector. The stored candidate is + /// reconstructed from its sign bits and `residual_norm` (each dim ≈ + /// ±norm/√dim). Returns L2 distance between them. + /// + /// Expects `PreparedQuery::Bytes` produced by `prepare_query`. + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let payload = match prepared { + PreparedQuery::Bytes(b) => b.as_slice(), + _ => { + return Err(RerankError::BadInput( + "bbq distance: prepared query is not Bytes".to_string(), + )); + } + }; + + let (_query_norm, centered) = decode_payload(payload, self.dim)?; + + let packed_len = self.dim.div_ceil(8); + let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| { + RerankError::BadInput(format!("bbq distance: failed to parse encoded bytes: {e}")) + })?; + + let header = uqv_ref.header(); + let recon = bbq_dequantize(uqv_ref.packed_bits(), header.residual_norm, self.dim); + let dist = centered + .iter() + .zip(recon.iter()) + .map(|(&a, &b)| (a - b) * (a - b)) + .sum::() + .sqrt(); + Ok(dist) + } + + fn name(&self) -> CodecName { + CodecName::Bbq + } + + fn to_bytes(&self) -> Result, RerankError> { + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained("bbq sidecar serialize: codec not trained".to_string()) + })?; + codec + .to_bytes() + .map_err(|e| RerankError::BadInput(format!("bbq to_bytes: {e}"))) + } + + /// Calibrate from a sample of vectors. + /// + /// Validates that: + /// - `samples` is non-empty. + /// - Every sample has length `self.dim`. + /// + /// On success, stores the calibrated codec; subsequent `encode` / + /// `distance_prepared` calls will succeed. + fn train(&mut self, samples: &[&[f32]]) -> Result<(), RerankError> { + if samples.is_empty() { + return Err(RerankError::BadInput( + "bbq train: empty sample set".to_string(), + )); + } + for s in samples { + if s.len() != self.dim { + return Err(RerankError::BadInput(format!( + "bbq train: sample has len {} but codec dim is {}", + s.len(), + self.dim + ))); + } + } + let codec = BbqCodec::calibrate(samples, self.dim, self.oversample); + self.codec = Some(codec); + Ok(()) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const DIM: usize = 16; + const N: usize = 64; + + fn det_vec(i: usize, dim: usize) -> Vec { + (0..dim) + .map(|j| ((i * 31 + j) % 100) as f32 / 100.0) + .collect() + } + + fn trained() -> BbqRerank { + let vecs: Vec> = (0..N).map(|i| det_vec(i, DIM)).collect(); + let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let mut codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE); + codec.train(&refs).expect("train must succeed"); + codec + } + + #[test] + fn train_then_encode_roundtrip() { + let codec = trained(); + let v = det_vec(0, DIM); + let enc = codec.encode(&v).expect("encode"); + let prep = codec.prepare_query(&v).expect("prepare_query"); + let dist = codec.distance_prepared(&prep, &enc).expect("distance"); + assert!(dist.is_finite(), "distance must be finite, got {dist}"); + assert!(dist >= 0.0, "distance must be non-negative, got {dist}"); + } + + #[test] + fn encode_before_train_returns_not_trained() { + let codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE); + let v = det_vec(0, DIM); + let err = codec.encode(&v).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("not trained") || msg.contains("trained"), + "expected 'trained' in error, got: {msg}" + ); + } + + #[test] + fn train_with_empty_samples_fails() { + let mut codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE); + let err = codec.train(&[]).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("bad input") || msg.contains("empty"), + "expected bad input error, got: {msg}" + ); + } + + #[test] + fn train_with_dim_mismatch_fails() { + let vecs: Vec> = (0..N).map(|i| det_vec(i, DIM)).collect(); + let mut refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let bad = det_vec(0, DIM + 4); + refs.push(bad.as_slice()); + let mut codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE); + let err = codec.train(&refs).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("bad input") || msg.contains("dim"), + "expected bad input error, got: {msg}" + ); + } + + #[test] + fn prepare_query_wrong_dim_fails() { + let codec = trained(); + let bad = det_vec(0, DIM + 2); + match codec.prepare_query(&bad) { + Err(e) => { + let msg = format!("{e}"); + assert!( + msg.contains("bad input") || msg.contains("dim"), + "expected bad input error, got: {msg}" + ); + } + Ok(_) => panic!("expected an error for wrong dim"), + } + } + + #[test] + fn distance_prepared_wrong_variant_fails() { + let codec = trained(); + let v = det_vec(0, DIM); + let enc = codec.encode(&v).expect("encode"); + let bad_prepared = PreparedQuery::Raw(vec![0.0f32; DIM]); + let err = codec.distance_prepared(&bad_prepared, &enc).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("Bytes") || msg.contains("not Bytes"), + "error message should mention Bytes variant, got: {msg}" + ); + } + + #[test] + fn name_is_expected() { + let codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE); + assert_eq!(codec.name(), CodecName::Bbq); + } +} diff --git a/nodedb-vector/src/rerank/codecs/binary.rs b/nodedb-vector/src/rerank/codecs/binary.rs new file mode 100644 index 000000000..24ad9dbab --- /dev/null +++ b/nodedb-vector/src/rerank/codecs/binary.rs @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `RerankCodec` wrapper for binary (sign-bit) quantization. +//! +//! Bridges `BinaryCodec` (which implements `VectorCodec` with associated types) +//! into the object-safe `RerankCodec` trait used by the rerank sidecar. +//! +//! Binary has no learned state: `train()` is satisfied by the default no-op. + +use nodedb_codec::vector_quant::layout::UnifiedQuantizedVectorRef; + +use crate::{ + quantize::binary_codec::BinaryCodec, + rerank::codec::{CodecName, PreparedQuery, RerankCodec}, + rerank::types::RerankError, +}; + +// ── packed_bits_len helper ──────────────────────────────────────────────────── + +/// Binary is 1 bpw: `ceil(dim / 8)` bytes. +#[inline] +fn binary_packed_bits_len(dim: usize) -> usize { + dim.div_ceil(8) +} + +// ── BinaryRerank ────────────────────────────────────────────────────────────── + +/// Object-safe `RerankCodec` wrapper around `BinaryCodec`. +/// +/// Binary has no learned parameters. All instances with the same `dim` are +/// equivalent. `train()` is the default no-op. +pub struct BinaryRerank { + codec: BinaryCodec, + dim: usize, +} + +impl BinaryRerank { + /// Create a binary rerank codec for vectors of length `dim`. + pub fn new(dim: usize) -> Self { + Self { + codec: BinaryCodec { dim }, + dim, + } + } +} + +impl RerankCodec for BinaryRerank { + /// Encode a full-precision vector to binary sign bits. + /// + /// The serialized form is the raw `UnifiedQuantizedVector` buffer + /// (`as_bytes()`): 32-byte `QuantHeader` followed by `ceil(dim/8)` bytes + /// of packed sign bits. + fn encode(&self, v: &[f32]) -> Result, RerankError> { + if v.len() != self.dim { + return Err(RerankError::BadInput(format!( + "binary encode: vector len {} != codec dim {}", + v.len(), + self.dim + ))); + } + use nodedb_codec::vector_quant::codec::VectorCodec as _; + let quantized = self.codec.encode(v); + Ok(quantized.as_ref().as_bytes().to_vec()) + } + + /// Prepare the query for repeated distance calls. + /// + /// Binary encodes both the query and candidates to sign bits and computes + /// Hamming distance. The prepared form is `PreparedQuery::Bytes` holding + /// the packed query bits. + fn prepare_query(&self, q: &[f32]) -> Result { + if q.len() != self.dim { + return Err(RerankError::BadInput(format!( + "binary prepare_query: query len {} != codec dim {}", + q.len(), + self.dim + ))); + } + use nodedb_codec::vector_quant::codec::VectorCodec as _; + let query_bits = self.codec.prepare_query(q); + Ok(PreparedQuery::Bytes(query_bits)) + } + + /// Compute Hamming distance from a prepared query to a binary-encoded + /// candidate. + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let q_bits = match prepared { + PreparedQuery::Bytes(b) => b, + _ => { + return Err(RerankError::BadInput( + "binary distance: expected PreparedQuery::Bytes".to_string(), + )); + } + }; + + let packed_len = binary_packed_bits_len(self.dim); + let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| { + RerankError::BadInput(format!( + "binary distance: failed to parse encoded bytes: {e}" + )) + })?; + + let packed = uqv_ref.packed_bits(); + // Compute Hamming distance directly via the public helper. + let dist = crate::quantize::binary::hamming_distance(q_bits, packed) as f32; + Ok(dist) + } + + fn name(&self) -> CodecName { + CodecName::Binary + } + + /// Serialize binary codec state. + /// + /// Format: `[NDBIN\0 (6 bytes)][version: u8 = 1][dim: u32 LE (4 bytes)]` — 11 bytes total. + /// `dim` is stored so `rerank_codec_from_bytes` can reconstruct a stateless `BinaryRerank`. + fn to_bytes(&self) -> Result, RerankError> { + let mut buf = Vec::with_capacity(11); + buf.extend_from_slice(b"NDBIN\0"); + buf.push(1u8); // version + buf.extend_from_slice(&(self.dim as u32).to_le_bytes()); + Ok(buf) + } + + // train() is the default no-op — Binary has no learned state. +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const DIM: usize = 16; + + fn all_pos() -> Vec { + vec![1.0f32; DIM] + } + + fn all_neg() -> Vec { + vec![-1.0f32; DIM] + } + + #[test] + fn round_trip_returns_finite_distance() { + let codec = BinaryRerank::new(DIM); + let v1 = all_pos(); + let v2 = all_neg(); + + let enc = codec.encode(&v1).expect("encode v1"); + let prepared = codec.prepare_query(&v2).expect("prepare_query v2"); + let dist = codec + .distance_prepared(&prepared, &enc) + .expect("distance_prepared"); + assert!(dist.is_finite(), "expected finite distance, got {dist}"); + assert!(dist >= 0.0, "expected non-negative distance, got {dist}"); + } + + #[test] + fn opposite_vectors_have_max_distance() { + let codec = BinaryRerank::new(DIM); + let pos = all_pos(); + let neg = all_neg(); + + let enc = codec.encode(&pos).expect("encode pos"); + let prepared = codec.prepare_query(&neg).expect("prepare_query neg"); + let dist = codec + .distance_prepared(&prepared, &enc) + .expect("distance_prepared"); + assert!( + (dist - DIM as f32).abs() < f32::EPSILON, + "opposite vectors should have Hamming distance == dim ({DIM}), got {dist}" + ); + } + + #[test] + fn identical_vectors_zero_distance() { + let codec = BinaryRerank::new(DIM); + let v = all_pos(); + + let enc = codec.encode(&v).expect("encode"); + let prepared = codec.prepare_query(&v).expect("prepare_query"); + let dist = codec + .distance_prepared(&prepared, &enc) + .expect("distance_prepared"); + assert!( + dist < f32::EPSILON, + "identical vectors must have zero Hamming distance, got {dist}" + ); + } + + #[test] + fn wrong_prepared_query_variant_returns_bad_input() { + let codec = BinaryRerank::new(DIM); + let v = all_pos(); + let enc = codec.encode(&v).expect("encode"); + let bad_prepared = PreparedQuery::Raw(vec![0.0f32; DIM]); + + let result = codec.distance_prepared(&bad_prepared, &enc); + assert!(result.is_err(), "expected BadInput error"); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("Bytes"), + "error message should mention Bytes, got: {msg}" + ); + } + + #[test] + fn name_returns_binary() { + let codec = BinaryRerank::new(DIM); + assert_eq!(codec.name(), CodecName::Binary); + } + + #[test] + fn wrong_dim_encode_returns_error() { + let codec = BinaryRerank::new(DIM); + let bad = vec![0.0f32; DIM + 1]; + assert!(codec.encode(&bad).is_err()); + } +} diff --git a/nodedb-vector/src/rerank/codecs/mod.rs b/nodedb-vector/src/rerank/codecs/mod.rs new file mode 100644 index 000000000..8116ce679 --- /dev/null +++ b/nodedb-vector/src/rerank/codecs/mod.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub mod bbq; +pub mod binary; +pub mod pq; +pub mod rabitq; +pub mod sq8; + +pub use bbq::BbqRerank; +pub use binary::BinaryRerank; +pub use pq::PqRerank; +pub use rabitq::RaBitQRerank; +pub use sq8::Sq8Rerank; diff --git a/nodedb-vector/src/rerank/codecs/pq.rs b/nodedb-vector/src/rerank/codecs/pq.rs new file mode 100644 index 000000000..076383427 --- /dev/null +++ b/nodedb-vector/src/rerank/codecs/pq.rs @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `RerankCodec` wrapper for Product Quantization (PQ). +//! +//! PQ is a training-based codec: `train()` runs k-means over a sample of +//! vectors to learn per-subspace codebooks. Until training is complete, +//! `encode` and `prepare_query` return `RerankError::NotTrained`. +//! +//! Distance uses the ADC (Asymmetric Distance Computation) model: the query +//! is kept in FP32 and a per-subspace lookup table is precomputed once via +//! `prepare_query`; each candidate lookup is then O(M) table additions. +//! The prepared form maps directly to `PreparedQuery::Lut` — the existing +//! variant already holds `Vec>` which is exactly the PQ distance +//! table (`lut[sub][centroid]`). + +use nodedb_codec::vector_quant::layout::UnifiedQuantizedVectorRef; + +use crate::{ + quantize::pq::PqCodec, + rerank::codec::{CodecName, PreparedQuery, RerankCodec}, + rerank::types::RerankError, +}; + +// ── packed_bits_len helper ──────────────────────────────────────────────────── + +/// PQ stores one centroid-index byte per subspace: packed_bits_len == m. +#[inline] +fn pq_packed_bits_len(m: usize) -> usize { + m +} + +// ── PqRerank ────────────────────────────────────────────────────────────────── + +/// Object-safe `RerankCodec` wrapper around `PqCodec`. +/// +/// The codec starts untrained. `encode` and `prepare_query` return +/// `RerankError::NotTrained` until `train()` has been called with a +/// representative sample of vectors. +/// +/// `from_codec` accepts a pre-trained `PqCodec` (used when restoring from +/// a snapshot). +pub struct PqRerank { + codec: Option, + dim: usize, + m: usize, + k: usize, + max_iter: usize, +} + +impl PqRerank { + /// Construct an untrained PQ codec configuration. + /// + /// `m` is the number of subspaces; `k` is centroids per subspace. + /// Defaults used by higher-level callers: `m = 8`, `k = 256`. + /// `encode` / `distance_prepared` return `RerankError::NotTrained` until + /// `train()` has been called. + pub fn new(dim: usize, m: usize, k: usize) -> Self { + Self { + codec: None, + dim, + m, + k, + max_iter: 25, + } + } + + /// Construct from a pre-trained codec (used when restoring from snapshot). + pub fn from_codec(codec: PqCodec) -> Self { + let dim = codec.dim; + let m = codec.m; + let k = codec.k; + Self { + codec: Some(codec), + dim, + m, + k, + max_iter: 25, + } + } +} + +impl RerankCodec for PqRerank { + /// Encode a full-precision vector to PQ bytes (one centroid index per subspace). + /// + /// The serialized form is the raw `UnifiedQuantizedVector` buffer + /// (`as_bytes()`): 32-byte `QuantHeader` followed by `m` code bytes. + fn encode(&self, v: &[f32]) -> Result, RerankError> { + if v.len() != self.dim { + return Err(RerankError::BadInput(format!( + "pq encode: vector len {} != codec dim {}", + v.len(), + self.dim + ))); + } + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained( + "pq: codec must be trained before encoding (call train() with a sample of vectors)" + .to_string(), + ) + })?; + use nodedb_codec::vector_quant::codec::VectorCodec; + let quantized = ::encode(codec, v); + Ok(quantized.as_ref().as_bytes().to_vec()) + } + + /// Prepare the query by precomputing the M×K asymmetric distance table. + /// + /// The prepared form is `PreparedQuery::Lut` where `lut[sub][centroid]` + /// holds the squared L2 distance from the query's sub-vector to each + /// centroid of subspace `sub`. This is the standard ADC lookup table. + fn prepare_query(&self, q: &[f32]) -> Result { + if q.len() != self.dim { + return Err(RerankError::BadInput(format!( + "pq prepare_query: query len {} != codec dim {}", + q.len(), + self.dim + ))); + } + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained( + "pq: codec must be trained before prepare_query (call train() with a sample of vectors)" + .to_string(), + ) + })?; + use nodedb_codec::vector_quant::codec::VectorCodec; + let pq_query = ::prepare_query(codec, q); + Ok(PreparedQuery::Lut(pq_query.distance_table)) + } + + /// Compute asymmetric ADC distance from a prepared query to a PQ-encoded + /// candidate. + /// + /// Expects `PreparedQuery::Lut` produced by `prepare_query`. + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let lut = match prepared { + PreparedQuery::Lut(t) => t, + _ => { + return Err(RerankError::BadInput( + "pq distance: expected PreparedQuery::Lut".to_string(), + )); + } + }; + + let packed_len = pq_packed_bits_len(self.m); + let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| { + RerankError::BadInput(format!("pq distance: failed to parse encoded bytes: {e}")) + })?; + + let packed = uqv_ref.packed_bits(); + // ADC: sum lut[sub][code[sub]] for each subspace. + let dist = packed + .iter() + .enumerate() + .map(|(sub, &code)| { + lut.get(sub) + .and_then(|row| row.get(code as usize).copied()) + .unwrap_or(0.0) + }) + .sum(); + Ok(dist) + } + + fn name(&self) -> CodecName { + CodecName::Pq + } + + fn to_bytes(&self) -> Result, RerankError> { + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained("pq sidecar serialize: codec not trained".to_string()) + })?; + codec + .to_bytes() + .map_err(|e| RerankError::BadInput(format!("pq to_bytes: {e}"))) + } + + /// Train PQ codebooks via k-means on a sample of vectors. + /// + /// Validates that: + /// - `samples` is non-empty. + /// - Every sample has length `self.dim`. + /// - `self.dim % self.m == 0` (PQ requires divisible dimensionality). + /// - At least `self.k` samples are provided (k-means needs ≥ k points). + /// + /// On success, stores the trained codec and subsequent `encode` / + /// `distance_prepared` calls will succeed. + fn train(&mut self, samples: &[&[f32]]) -> Result<(), RerankError> { + if samples.is_empty() { + return Err(RerankError::BadInput( + "pq train: empty sample set".to_string(), + )); + } + for s in samples { + if s.len() != self.dim { + return Err(RerankError::BadInput(format!( + "pq train: sample has len {} but codec dim is {}", + s.len(), + self.dim + ))); + } + } + if !self.dim.is_multiple_of(self.m) { + return Err(RerankError::BadInput(format!( + "pq train: dim ({}) must be divisible by m ({})", + self.dim, self.m + ))); + } + if samples.len() < self.k { + return Err(RerankError::BadInput(format!( + "pq train: need >= k samples for k-means, got {}", + samples.len() + ))); + } + let codec = PqCodec::train(samples, self.dim, self.m, self.k, self.max_iter); + self.codec = Some(codec); + Ok(()) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const DIM: usize = 32; + const M: usize = 4; + const K: usize = 8; + const N: usize = 64; + + fn det_vec(i: usize, dim: usize) -> Vec { + (0..dim) + .map(|j| ((i * 31 + j) % 100) as f32 / 100.0) + .collect() + } + + fn trained() -> PqRerank { + let vecs: Vec> = (0..N).map(|i| det_vec(i, DIM)).collect(); + let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let mut codec = PqRerank::new(DIM, M, K); + codec.train(&refs).expect("train must succeed"); + codec + } + + #[test] + fn train_then_encode_roundtrip() { + let codec = trained(); + let v = det_vec(0, DIM); + let enc = codec.encode(&v).expect("encode"); + let prep = codec.prepare_query(&v).expect("prepare_query"); + let dist = codec.distance_prepared(&prep, &enc).expect("distance"); + assert!(dist.is_finite(), "distance must be finite, got {dist}"); + assert!(dist >= 0.0, "distance must be non-negative, got {dist}"); + // Self-distance should be small for ADC on identical vector. + assert!(dist < 1.0, "self-distance too large: {dist}"); + } + + #[test] + fn encode_before_train_returns_not_trained() { + let codec = PqRerank::new(DIM, M, K); + let v = det_vec(0, DIM); + let err = codec.encode(&v).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("not trained") || msg.contains("trained"), + "expected 'trained' in error, got: {msg}" + ); + } + + #[test] + fn train_with_wrong_dim_sample_fails() { + let vecs: Vec> = (0..N).map(|i| det_vec(i, DIM)).collect(); + let mut refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let bad = det_vec(0, DIM + 4); + refs.push(bad.as_slice()); + let mut codec = PqRerank::new(DIM, M, K); + let err = codec.train(&refs).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("bad input"), + "expected bad input error, got: {msg}" + ); + } + + #[test] + fn train_with_indivisible_dim_fails() { + // dim=33, m=4: 33 % 4 != 0 + let vecs: Vec> = (0..16).map(|i| det_vec(i, 33)).collect(); + let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let mut codec = PqRerank::new(33, 4, 8); + let err = codec.train(&refs).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("divisible"), + "expected divisibility error, got: {msg}" + ); + } + + #[test] + fn train_with_too_few_samples_fails() { + // k=8 but only 4 samples + let vecs: Vec> = (0..4).map(|i| det_vec(i, DIM)).collect(); + let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let mut codec = PqRerank::new(DIM, M, 8); + let err = codec.train(&refs).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("k samples") || msg.contains("bad input"), + "expected sample count error, got: {msg}" + ); + } + + #[test] + fn name_is_pq() { + let codec = PqRerank::new(DIM, M, K); + assert_eq!(codec.name(), CodecName::Pq); + } +} diff --git a/nodedb-vector/src/rerank/codecs/rabitq.rs b/nodedb-vector/src/rerank/codecs/rabitq.rs new file mode 100644 index 000000000..55227c2eb --- /dev/null +++ b/nodedb-vector/src/rerank/codecs/rabitq.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `RerankCodec` wrapper for RaBitQ 1-bit quantization. +//! +//! RaBitQ is a training-based codec: `train()` runs centroid calibration and +//! stores a randomised WHT rotation. Until training is complete, `encode` and +//! `prepare_query` return `RerankError::NotTrained`. +//! +//! Distance is computed by inlining the asymmetric Hamming-based formula from +//! `RaBitQCodec::exact_asymmetric_distance`: +//! +//! approx_l2 = q_norm² + v_norm² − 2 · q_norm · v_norm · (1 − 2·hamming/dim) +//! +//! The prepared form is `PreparedQuery::Bytes` with the layout: +//! [0..4] query_norm as f32 little-endian +//! [4..] rotated_signs bytes (length = dim.div_ceil(8)) + +use nodedb_codec::vector_quant::codec::VectorCodec as _; +use nodedb_codec::vector_quant::hamming::hamming_distance; +use nodedb_codec::vector_quant::layout::UnifiedQuantizedVectorRef; +use nodedb_codec::vector_quant::rabitq::{RaBitQCodec, RaBitQQuery}; + +use crate::{ + rerank::codec::{CodecName, PreparedQuery, RerankCodec}, + rerank::types::RerankError, +}; + +// ── Payload helpers ─────────────────────────────────────────────────────────── + +fn encode_payload(query: &RaBitQQuery) -> Vec { + // Layout: 4 bytes query_norm (f32 LE) || rotated_signs bytes + let mut buf = Vec::with_capacity(4 + query.rotated_signs.len()); + buf.extend_from_slice(&query.query_norm.to_le_bytes()); + buf.extend_from_slice(&query.rotated_signs); + buf +} + +fn decode_payload(payload: &[u8], dim: usize) -> Result<(f32, Vec), RerankError> { + let sign_len = dim.div_ceil(8); + let expected = 4 + sign_len; + if payload.len() != expected { + return Err(RerankError::BadInput(format!( + "rabitq distance: payload len {} != expected {} for dim {}", + payload.len(), + expected, + dim + ))); + } + let query_norm = f32::from_le_bytes( + payload[..4] + .try_into() + .expect("slice of 4 bytes always converts to [u8;4]"), + ); + Ok((query_norm, payload[4..].to_vec())) +} + +// ── RaBitQRerank ────────────────────────────────────────────────────────────── + +/// Default rotation seed used when the caller does not specify one. +pub const DEFAULT_ROTATION_SEED: u64 = 0x00C0_FFEE_00C0_FFEE; + +/// Object-safe `RerankCodec` wrapper around `RaBitQCodec`. +/// +/// The codec starts untrained. `encode` and `prepare_query` return +/// `RerankError::NotTrained` until `train()` has been called with a +/// representative sample of vectors. +/// +/// `from_codec` accepts a pre-calibrated `RaBitQCodec` (used when restoring +/// from a snapshot). +pub struct RaBitQRerank { + codec: Option, + dim: usize, + rotation_seed: u64, +} + +impl RaBitQRerank { + /// Construct an untrained wrapper. + /// + /// `encode` / `distance_prepared` return `RerankError::NotTrained` until + /// `train()` is called. + pub fn new(dim: usize, rotation_seed: u64) -> Self { + Self { + codec: None, + dim, + rotation_seed, + } + } + + /// Construct from a pre-calibrated codec (used when restoring from snapshot). + pub fn from_codec(codec: RaBitQCodec) -> Self { + let dim = codec.dim; + Self { + codec: Some(codec), + dim, + rotation_seed: DEFAULT_ROTATION_SEED, + } + } +} + +impl RerankCodec for RaBitQRerank { + /// Encode a full-precision vector to RaBitQ 1-bit bytes. + /// + /// The serialised form is the raw `UnifiedQuantizedVector` buffer + /// (`as_bytes()`): 32-byte `QuantHeader` followed by `dim.div_ceil(8)` + /// sign-packed bits. + fn encode(&self, v: &[f32]) -> Result, RerankError> { + if v.len() != self.dim { + return Err(RerankError::BadInput(format!( + "rabitq encode: vector len {} != codec dim {}", + v.len(), + self.dim + ))); + } + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained( + "rabitq: codec must be trained before encoding (call train() with a sample of vectors)" + .to_string(), + ) + })?; + let quantized = codec.encode(v); + Ok(quantized.as_ref().as_bytes().to_vec()) + } + + /// Prepare the query by computing its centroid-subtracted, rotated sign pack + /// and the exact query norm. + /// + /// The prepared form is `PreparedQuery::Bytes` with the layout: + /// 4 bytes query_norm (f32 LE) || sign bytes (dim.div_ceil(8)). + fn prepare_query(&self, q: &[f32]) -> Result { + if q.len() != self.dim { + return Err(RerankError::BadInput(format!( + "rabitq prepare_query: query len {} != codec dim {}", + q.len(), + self.dim + ))); + } + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained( + "rabitq: codec must be trained before prepare_query (call train() with a sample of vectors)" + .to_string(), + ) + })?; + let query = codec.prepare_query(q); + Ok(PreparedQuery::Bytes(encode_payload(&query))) + } + + /// Compute asymmetric Hamming-based L2 distance from a prepared query to a + /// RaBitQ-encoded candidate. + /// + /// Inlines `RaBitQCodec::exact_asymmetric_distance` (without bias_correct) + /// using `UnifiedQuantizedVectorRef` to avoid a redundant allocation: + /// + /// approx = q_norm² + v_norm² − 2·q_norm·v_norm·(1 − 2·hamming/dim) + /// + /// Expects `PreparedQuery::Bytes` produced by `prepare_query`. + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let payload = match prepared { + PreparedQuery::Bytes(b) => b.as_slice(), + _ => { + return Err(RerankError::BadInput( + "rabitq distance: prepared query is not Bytes".to_string(), + )); + } + }; + + let (query_norm, rotated_signs) = decode_payload(payload, self.dim)?; + + let packed_len = self.dim.div_ceil(8); + let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| { + RerankError::BadInput(format!( + "rabitq distance: failed to parse encoded bytes: {e}" + )) + })?; + + let vh = uqv_ref.header(); + let vb = uqv_ref.packed_bits(); + let h = hamming_distance(&rotated_signs, vb); + let dim = self.dim as f32; + let dot_estimate = 1.0 - 2.0 * h as f32 / dim; + let approx = query_norm * query_norm + vh.residual_norm * vh.residual_norm + - 2.0 * query_norm * vh.residual_norm * dot_estimate; + Ok(approx.max(0.0)) + } + + fn name(&self) -> CodecName { + CodecName::RaBitQ + } + + fn to_bytes(&self) -> Result, RerankError> { + let codec = self.codec.as_ref().ok_or_else(|| { + RerankError::NotTrained("rabitq sidecar serialize: codec not trained".to_string()) + })?; + codec + .to_bytes() + .map_err(|e| RerankError::BadInput(format!("rabitq to_bytes: {e}"))) + } + + /// Calibrate from a sample of vectors. + /// + /// Validates that: + /// - `samples` is non-empty. + /// - Every sample has length `self.dim`. + /// + /// On success, stores the calibrated codec; subsequent `encode` / + /// `distance_prepared` calls will succeed. + fn train(&mut self, samples: &[&[f32]]) -> Result<(), RerankError> { + if samples.is_empty() { + return Err(RerankError::BadInput( + "rabitq train: empty sample set".to_string(), + )); + } + for s in samples { + if s.len() != self.dim { + return Err(RerankError::BadInput(format!( + "rabitq train: sample has len {} but codec dim is {}", + s.len(), + self.dim + ))); + } + } + let codec = RaBitQCodec::calibrate(samples, self.dim, self.rotation_seed); + self.codec = Some(codec); + Ok(()) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const DIM: usize = 16; + const N: usize = 64; + + fn det_vec(i: usize, dim: usize) -> Vec { + (0..dim) + .map(|j| ((i * 31 + j) % 100) as f32 / 100.0) + .collect() + } + + fn trained() -> RaBitQRerank { + let vecs: Vec> = (0..N).map(|i| det_vec(i, DIM)).collect(); + let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let mut codec = RaBitQRerank::new(DIM, DEFAULT_ROTATION_SEED); + codec.train(&refs).expect("train must succeed"); + codec + } + + #[test] + fn train_then_encode_roundtrip() { + let codec = trained(); + let v = det_vec(0, DIM); + let enc = codec.encode(&v).expect("encode"); + let prep = codec.prepare_query(&v).expect("prepare_query"); + let dist = codec.distance_prepared(&prep, &enc).expect("distance"); + assert!(dist.is_finite(), "distance must be finite, got {dist}"); + assert!(dist >= 0.0, "distance must be non-negative, got {dist}"); + } + + #[test] + fn encode_before_train_returns_not_trained() { + let codec = RaBitQRerank::new(DIM, DEFAULT_ROTATION_SEED); + let v = det_vec(0, DIM); + let err = codec.encode(&v).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("not trained") || msg.contains("trained"), + "expected 'trained' in error, got: {msg}" + ); + } + + #[test] + fn train_with_empty_samples_fails() { + let mut codec = RaBitQRerank::new(DIM, DEFAULT_ROTATION_SEED); + let err = codec.train(&[]).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("bad input") || msg.contains("empty"), + "expected bad input error, got: {msg}" + ); + } + + #[test] + fn train_with_dim_mismatch_fails() { + let vecs: Vec> = (0..N).map(|i| det_vec(i, DIM)).collect(); + let mut refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect(); + let bad = det_vec(0, DIM + 4); + refs.push(bad.as_slice()); + let mut codec = RaBitQRerank::new(DIM, DEFAULT_ROTATION_SEED); + let err = codec.train(&refs).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("bad input") || msg.contains("dim"), + "expected bad input error, got: {msg}" + ); + } + + #[test] + fn prepare_query_wrong_dim_fails() { + let codec = trained(); + let bad = det_vec(0, DIM + 2); + match codec.prepare_query(&bad) { + Err(e) => { + let msg = format!("{e}"); + assert!( + msg.contains("bad input") || msg.contains("dim"), + "expected bad input error, got: {msg}" + ); + } + Ok(_) => panic!("expected an error for wrong dim"), + } + } + + #[test] + fn distance_prepared_wrong_variant_fails() { + let codec = trained(); + let v = det_vec(0, DIM); + let enc = codec.encode(&v).expect("encode"); + let bad_prepared = PreparedQuery::Raw(vec![0.0f32; DIM]); + let err = codec.distance_prepared(&bad_prepared, &enc).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("Bytes") || msg.contains("not Bytes"), + "error message should mention Bytes variant, got: {msg}" + ); + } + + #[test] + fn name_is_expected() { + let codec = RaBitQRerank::new(DIM, DEFAULT_ROTATION_SEED); + assert_eq!(codec.name(), CodecName::RaBitQ); + } +} diff --git a/nodedb-vector/src/rerank/codecs/sq8.rs b/nodedb-vector/src/rerank/codecs/sq8.rs new file mode 100644 index 000000000..5b615607d --- /dev/null +++ b/nodedb-vector/src/rerank/codecs/sq8.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `RerankCodec` wrapper for SQ8 scalar quantization. +//! +//! Bridges `Sq8Codec` (which implements `VectorCodec` with associated types) +//! into the object-safe `RerankCodec` trait used by the rerank sidecar. + +use nodedb_codec::vector_quant::layout::UnifiedQuantizedVectorRef; + +use crate::{ + quantize::sq8::Sq8Codec, + rerank::codec::{CodecName, PreparedQuery, RerankCodec}, + rerank::types::RerankError, +}; + +// ── packed_bits_len helper ──────────────────────────────────────────────────── + +/// SQ8 is 8 bpw, so packed_bits_len == dim bytes. +#[inline] +fn sq8_packed_bits_len(dim: usize) -> usize { + dim +} + +// ── Sq8Rerank ───────────────────────────────────────────────────────────────── + +/// Object-safe `RerankCodec` wrapper around `Sq8Codec`. +/// +/// `train()` calls `Sq8Codec::calibrate` to fit per-dimension min/max from a +/// sample of vectors. Subsequent `encode` / `distance_prepared` calls use the +/// calibrated codec. +pub struct Sq8Rerank { + codec: Sq8Codec, + dim: usize, +} + +impl Sq8Rerank { + /// Create an untrained wrapper with a default-calibrated codec. + /// + /// The default codec treats every dimension's min as 0.0 and max as 1.0, + /// which is suitable for normalized embeddings. For best accuracy call + /// `train()` with representative samples before encoding. + pub fn new(dim: usize) -> Self { + // Build a minimal calibration over the unit range so encoding is + // functional before train() is called. + let lo = vec![0.0f32; dim]; + let hi = vec![1.0f32; dim]; + let samples: Vec<&[f32]> = vec![lo.as_slice(), hi.as_slice()]; + let codec = Sq8Codec::calibrate(&samples, dim); + Self { codec, dim } + } + + /// Wrap an already-trained `Sq8Codec`. + pub fn from_codec(codec: Sq8Codec) -> Self { + let dim = codec.dim; + Self { codec, dim } + } +} + +impl RerankCodec for Sq8Rerank { + /// Encode a full-precision vector to SQ8 bytes. + /// + /// The serialized form is the raw `UnifiedQuantizedVector` buffer + /// (`as_bytes()`), which embeds a 32-byte `QuantHeader` followed by + /// `dim` packed INT8 codes. + fn encode(&self, v: &[f32]) -> Result, RerankError> { + if v.len() != self.dim { + return Err(RerankError::BadInput(format!( + "sq8 encode: vector len {} != codec dim {}", + v.len(), + self.dim + ))); + } + use nodedb_codec::vector_quant::codec::VectorCodec as _; + let quantized = self.codec.encode(v); + Ok(quantized.as_ref().as_bytes().to_vec()) + } + + /// Prepare the query for repeated distance calls. + /// + /// SQ8 is asymmetric: the query is kept in full FP32 precision while + /// candidates are INT8. The prepared form is therefore `PreparedQuery::Raw`. + fn prepare_query(&self, q: &[f32]) -> Result { + if q.len() != self.dim { + return Err(RerankError::BadInput(format!( + "sq8 prepare_query: query len {} != codec dim {}", + q.len(), + self.dim + ))); + } + Ok(PreparedQuery::Raw(q.to_vec())) + } + + /// Compute asymmetric L2 distance from a prepared FP32 query to an + /// SQ8-encoded candidate. + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let q = match prepared { + PreparedQuery::Raw(q) => q, + _ => { + return Err(RerankError::BadInput( + "sq8 distance: expected PreparedQuery::Raw".to_string(), + )); + } + }; + + let packed_len = sq8_packed_bits_len(self.dim); + let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| { + RerankError::BadInput(format!("sq8 distance: failed to parse encoded bytes: {e}")) + })?; + + let packed = uqv_ref.packed_bits(); + let dist = self.codec.asymmetric_l2(q, packed); + Ok(dist) + } + + fn name(&self) -> CodecName { + CodecName::Sq8 + } + + fn to_bytes(&self) -> Result, RerankError> { + Ok(self.codec.to_bytes()) + } + + /// Calibrate from a sample of vectors. + /// + /// Replaces the current codec state. Requires at least one sample. + fn train(&mut self, samples: &[&[f32]]) -> Result<(), RerankError> { + if samples.is_empty() { + return Err(RerankError::BadInput( + "sq8 train: empty sample set".to_string(), + )); + } + self.codec = Sq8Codec::calibrate(samples, self.dim); + Ok(()) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const DIM: usize = 16; + const EPS: f32 = 1e-2; + + fn make_vec(base: f32) -> Vec { + (0..DIM).map(|i| base + i as f32 * 0.01).collect() + } + + fn trained_codec() -> Sq8Rerank { + let samples: Vec> = (0..50).map(|i| make_vec(i as f32 * 0.1)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + let mut codec = Sq8Rerank::new(DIM); + codec.train(&refs).expect("train must succeed"); + codec + } + + #[test] + fn round_trip_returns_finite_distance() { + let codec = trained_codec(); + let v1 = make_vec(0.5); + let v2 = make_vec(1.0); + + let enc = codec.encode(&v1).expect("encode v1"); + let prepared = codec.prepare_query(&v2).expect("prepare_query v2"); + let dist = codec + .distance_prepared(&prepared, &enc) + .expect("distance_prepared"); + assert!(dist.is_finite(), "expected finite distance, got {dist}"); + assert!(dist >= 0.0, "expected non-negative distance, got {dist}"); + } + + #[test] + fn identical_vectors_small_distance() { + let codec = trained_codec(); + let v = make_vec(0.5); + + let enc = codec.encode(&v).expect("encode"); + let prepared = codec.prepare_query(&v).expect("prepare_query"); + let dist = codec + .distance_prepared(&prepared, &enc) + .expect("distance_prepared"); + assert!(dist.is_finite()); + assert!( + dist < EPS, + "identical vectors should have near-zero distance, got {dist}" + ); + } + + #[test] + fn wrong_prepared_query_variant_returns_bad_input() { + let codec = trained_codec(); + let v = make_vec(0.5); + let enc = codec.encode(&v).expect("encode"); + let bad_prepared = PreparedQuery::Bytes(vec![0u8; 8]); + + let result = codec.distance_prepared(&bad_prepared, &enc); + assert!(result.is_err(), "expected BadInput error"); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("Raw"), + "error message should mention Raw, got: {msg}" + ); + } + + #[test] + fn name_returns_sq8() { + let codec = Sq8Rerank::new(DIM); + assert_eq!(codec.name(), CodecName::Sq8); + } + + #[test] + fn train_calibrates_without_error() { + let mut codec = Sq8Rerank::new(DIM); + let samples: Vec> = (0..20).map(|i| make_vec(i as f32 * 0.05)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + codec.train(&refs).expect("train must succeed"); + + // After training, encode + distance must still work. + let v = make_vec(0.5); + let enc = codec.encode(&v).expect("encode after train"); + let prep = codec.prepare_query(&v).expect("prepare after train"); + let dist = codec + .distance_prepared(&prep, &enc) + .expect("distance after train"); + assert!(dist.is_finite()); + } + + #[test] + fn wrong_dim_encode_returns_error() { + let codec = Sq8Rerank::new(DIM); + let bad = vec![0.0f32; DIM + 1]; + assert!(codec.encode(&bad).is_err()); + } +} diff --git a/nodedb-vector/src/rerank/gating.rs b/nodedb-vector/src/rerank/gating.rs new file mode 100644 index 000000000..6525463ae --- /dev/null +++ b/nodedb-vector/src/rerank/gating.rs @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Option-combination gating for `rerank`. +//! +//! Validates a [`VectorAnnOptions`] request against the index shape and returns +//! the [`CodecName`] the search should use (or `None` for FP32-only), surfacing +//! unsupported combinations as [`RerankError::BadInput`] with precise messages. + +use nodedb_types::vector_ann::{VectorAnnOptions, VectorQuantization}; + +use super::codec::CodecName; +use super::types::RerankError; + +/// Shape of the underlying vector index, used by [`validate_options`] to decide +/// which options are coherent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexShape { + SingleVector, + MultiVector, +} + +/// Validate the option combo against the index shape and the collection's +/// configured quantization. Returns the [`CodecName`] the search should use +/// for rerank (`None` when the request is FP32-only), or +/// [`RerankError::BadInput`] when the combination is invalid. +/// +/// # Quantization contract +/// +/// The codec is fixed at collection-creation time. `collection_quant` is the +/// quantization that was declared via DDL; `opts.quantization` is the optional +/// search-time override. +/// +/// - If `opts.quantization` is `None`: honor `collection_quant` — map it to a +/// `CodecName` via `codec_name_for_quant`. Ternary / OPQ collection configs +/// still surface as `BadInput` because they have no HNSW-integration path. +/// - If `opts.quantization` is `Some(q)` and `q == collection_quant`: proceed +/// (same as old behavior — the caller is being explicit about what the index +/// already uses). +/// - If `opts.quantization` is `Some(q)` and `q != collection_quant`: return +/// `RerankError::BadInput` naming both the requested codec and the collection's +/// configured codec. Silent fallback is never allowed. +/// - `Some(VectorQuantization::None)` against any non-`None` `collection_quant` +/// is also a contradiction and returns `BadInput`. +pub fn validate_options( + opts: &VectorAnnOptions, + index_shape: IndexShape, + collection_quant: VectorQuantization, +) -> Result, RerankError> { + validate_meta_token_budget(opts, index_shape)?; + validate_quantization_with_collection(opts, collection_quant) +} + +fn validate_meta_token_budget( + opts: &VectorAnnOptions, + index_shape: IndexShape, +) -> Result<(), RerankError> { + if opts.meta_token_budget.is_none() { + return Ok(()); + } + match index_shape { + IndexShape::SingleVector => Err(RerankError::BadInput( + "meta_token_budget requires a multi-vector (MetaEmbed) index; \ + the target collection is single-vector. \ + Multi-vector indexes are not yet available in this deployment." + .to_owned(), + )), + IndexShape::MultiVector => Err(RerankError::BadInput( + "meta_token_budget routing not yet implemented; \ + multi-vector indexes exist but PLAID/MaxSim dispatch is not wired." + .to_owned(), + )), + } +} + +/// Map a `VectorQuantization` variant to its `CodecName`, returning `None` +/// for variants that have no codec path (i.e. `None` / `VectorQuantization::None`). +/// Variants that are not yet routable (Ternary, Opq, unknown) return `None` +/// because their error is surfaced by `validate_quantization` — callers that +/// need error surfacing should use `validate_options` instead. +pub(crate) fn codec_name_for_quant(q: VectorQuantization) -> Option { + match q { + VectorQuantization::None => None, + VectorQuantization::Sq8 => Some(CodecName::Sq8), + VectorQuantization::Pq => Some(CodecName::Pq), + VectorQuantization::Binary => Some(CodecName::Binary), + VectorQuantization::RaBitQ => Some(CodecName::RaBitQ), + VectorQuantization::Bbq => Some(CodecName::Bbq), + // Not yet routable — validate_options surfaces a precise error. + _ => None, + } +} + +/// Validate the search-time quantization against the collection's configured +/// codec, and surface a precise `BadInput` on any mismatch. No silent fallback. +fn validate_quantization_with_collection( + opts: &VectorAnnOptions, + collection_quant: VectorQuantization, +) -> Result, RerankError> { + match opts.quantization { + // Caller did not specify a codec: honor whatever the collection was + // built with. Ternary / OPQ are still unroutable even at this level. + None => map_collection_quant(collection_quant), + + // Caller explicitly requested "no quantization" (FP32 path). + Some(VectorQuantization::None) => { + if collection_quant != VectorQuantization::None { + return Err(RerankError::BadInput(format!( + "search-time quantization 'None' does not match collection's configured \ + quantization '{collection_quant:?}'; the codec is fixed at \ + collection-creation time" + ))); + } + Ok(None) + } + + // Caller specified a concrete codec. + Some(requested) => { + if requested != collection_quant { + return Err(RerankError::BadInput(format!( + "search-time quantization '{requested:?}' does not match collection's \ + configured quantization '{collection_quant:?}'; the codec is fixed at \ + collection-creation time" + ))); + } + // The requested codec matches the collection config — validate it is routable. + map_collection_quant(collection_quant) + } + } +} + +/// Map a `VectorQuantization` that matches (or defaults from) the collection's +/// config to a `CodecName`. Returns `BadInput` for unroutable variants. +fn map_collection_quant(q: VectorQuantization) -> Result, RerankError> { + match q { + VectorQuantization::None => Ok(None), + VectorQuantization::Sq8 => Ok(Some(CodecName::Sq8)), + VectorQuantization::Pq => Ok(Some(CodecName::Pq)), + VectorQuantization::Binary => Ok(Some(CodecName::Binary)), + VectorQuantization::RaBitQ => Ok(Some(CodecName::RaBitQ)), + VectorQuantization::Bbq => Ok(Some(CodecName::Bbq)), + VectorQuantization::Ternary => Err(RerankError::BadInput( + "quantization=ternary: codec exists in nodedb-codec but has no HNSW-integration \ + path in nodedb-vector; cannot serve a search request with ternary quantization \ + until the index-side wiring lands." + .to_owned(), + )), + VectorQuantization::Opq => Err(RerankError::BadInput( + "quantization=opq: codec exists in nodedb-codec but has no HNSW-integration \ + path in nodedb-vector; cannot serve a search request with opq quantization \ + until the index-side wiring lands." + .to_owned(), + )), + // Safety net for future non_exhaustive variants added to VectorQuantization + // before nodedb-vector is updated. Treat as unroutable until wired. + _ => Err(RerankError::BadInput( + "quantization variant is not yet routable in nodedb-vector; \ + update gating.rs when the HNSW-integration path lands." + .to_owned(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts_with_quant(q: Option) -> VectorAnnOptions { + VectorAnnOptions { + quantization: q, + ..Default::default() + } + } + + fn opts_with_budget(budget: u8, q: Option) -> VectorAnnOptions { + VectorAnnOptions { + meta_token_budget: Some(budget), + quantization: q, + ..Default::default() + } + } + + // ── Existing tests updated to pass collection_quant ─────────────────────── + + #[test] + fn none_quantization_returns_none() { + let result = validate_options( + &opts_with_quant(None), + IndexShape::SingleVector, + VectorQuantization::None, + ); + assert_eq!(result.unwrap(), None); + } + + #[test] + fn explicit_none_quantization_returns_none() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::None)), + IndexShape::SingleVector, + VectorQuantization::None, + ); + assert_eq!(result.unwrap(), None); + } + + #[test] + fn sq8_returns_codec() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::Sq8)), + IndexShape::SingleVector, + VectorQuantization::Sq8, + ); + assert_eq!(result.unwrap(), Some(CodecName::Sq8)); + } + + #[test] + fn pq_returns_codec() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::Pq)), + IndexShape::SingleVector, + VectorQuantization::Pq, + ); + assert_eq!(result.unwrap(), Some(CodecName::Pq)); + } + + #[test] + fn binary_returns_codec() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::Binary)), + IndexShape::SingleVector, + VectorQuantization::Binary, + ); + assert_eq!(result.unwrap(), Some(CodecName::Binary)); + } + + #[test] + fn rabitq_returns_codec() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::RaBitQ)), + IndexShape::SingleVector, + VectorQuantization::RaBitQ, + ); + assert_eq!(result.unwrap(), Some(CodecName::RaBitQ)); + } + + #[test] + fn bbq_returns_codec() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::Bbq)), + IndexShape::SingleVector, + VectorQuantization::Bbq, + ); + assert_eq!(result.unwrap(), Some(CodecName::Bbq)); + } + + #[test] + fn ternary_returns_bad_input() { + let err = validate_options( + &opts_with_quant(Some(VectorQuantization::Ternary)), + IndexShape::SingleVector, + VectorQuantization::Ternary, + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("ternary"), "expected 'ternary' in: {msg}"); + assert!(matches!(err, RerankError::BadInput(_))); + } + + #[test] + fn opq_returns_bad_input() { + let err = validate_options( + &opts_with_quant(Some(VectorQuantization::Opq)), + IndexShape::SingleVector, + VectorQuantization::Opq, + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("opq"), "expected 'opq' in: {msg}"); + assert!(matches!(err, RerankError::BadInput(_))); + } + + #[test] + fn meta_token_budget_single_vec_returns_bad_input() { + let err = validate_options( + &opts_with_budget(8, None), + IndexShape::SingleVector, + VectorQuantization::None, + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("single-vector"), + "expected 'single-vector' in: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } + + #[test] + fn meta_token_budget_multi_vec_returns_bad_input() { + let err = validate_options( + &opts_with_budget(8, None), + IndexShape::MultiVector, + VectorQuantization::None, + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("PLAID") || msg.contains("MaxSim"), + "expected 'PLAID' or 'MaxSim' in: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } + + #[test] + fn meta_token_budget_none_passes_with_sq8() { + let result = validate_options( + &opts_with_quant(Some(VectorQuantization::Sq8)), + IndexShape::SingleVector, + VectorQuantization::Sq8, + ); + assert_eq!(result.unwrap(), Some(CodecName::Sq8)); + } + + // ── New mismatch / collection-default tests ─────────────────────────────── + + #[test] + fn quantization_mismatch_returns_bad_input() { + let opts = opts_with_quant(Some(VectorQuantization::Sq8)); + let err = + validate_options(&opts, IndexShape::SingleVector, VectorQuantization::Pq).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Sq8") && msg.contains("Pq"), + "message must name both requested and configured codec: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } + + #[test] + fn quantization_matches_collection_passes() { + let opts = opts_with_quant(Some(VectorQuantization::RaBitQ)); + let result = validate_options(&opts, IndexShape::SingleVector, VectorQuantization::RaBitQ); + assert_eq!(result.unwrap(), Some(CodecName::RaBitQ)); + } + + #[test] + fn quantization_none_with_collection_codec_uses_collection_codec() { + // Caller didn't specify; collection was built with Sq8 → return Sq8. + let opts = opts_with_quant(None); + let result = validate_options(&opts, IndexShape::SingleVector, VectorQuantization::Sq8); + assert_eq!(result.unwrap(), Some(CodecName::Sq8)); + } + + #[test] + fn quantization_none_with_collection_none_returns_none() { + // Both unset → FP32-only path. + let opts = opts_with_quant(None); + let result = validate_options(&opts, IndexShape::SingleVector, VectorQuantization::None); + assert_eq!(result.unwrap(), None); + } + + #[test] + fn explicit_none_against_sq8_collection_returns_bad_input() { + // Requesting "no codec" against a collection configured with Sq8 is contradictory. + let opts = opts_with_quant(Some(VectorQuantization::None)); + let err = + validate_options(&opts, IndexShape::SingleVector, VectorQuantization::Sq8).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("None") && msg.contains("Sq8"), + "message must name both requested 'None' and collection's 'Sq8': {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } +} diff --git a/nodedb-vector/src/rerank/mod.rs b/nodedb-vector/src/rerank/mod.rs new file mode 100644 index 000000000..3fdeb48cf --- /dev/null +++ b/nodedb-vector/src/rerank/mod.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub mod codec; +pub mod codecs; +pub mod gating; +pub mod pipeline; +pub mod recall; +pub mod sidecar; +pub mod types; + +pub use codec::{CodecName, PreparedQuery, RerankCodec}; +pub use codecs::{BinaryRerank, Sq8Rerank}; +pub use gating::{IndexShape, validate_options}; +pub use pipeline::rerank; +pub use recall::recall_scale; +pub use sidecar::CodecSidecar; +pub use types::{Candidate, Ranked, RerankError}; diff --git a/nodedb-vector/src/rerank/pipeline.rs b/nodedb-vector/src/rerank/pipeline.rs new file mode 100644 index 000000000..59f7bfc9f --- /dev/null +++ b/nodedb-vector/src/rerank/pipeline.rs @@ -0,0 +1,679 @@ +// SPDX-License-Identifier: Apache-2.0 + +use nodedb_types::vector_ann::VectorAnnOptions; +use nodedb_types::vector_distance::DistanceMetric; + +use super::gating::codec_name_for_quant; +use super::sidecar::CodecSidecar; +use super::types::{Candidate, Ranked, RerankError}; + +/// Shared rerank pipeline. Both Origin and Lite call this after their index-level coarse search. +/// +/// Callers use `opts.oversample` to compute `fetch_k` before pre-fetching from HNSW; +/// this function receives whatever candidates were fetched and reranks by exact distance. +/// +/// When `opts.quantization` is `None` (or `VectorQuantization::None`), the FP32 path is used: +/// `fetch_vector` is called once per candidate and must return the stored full-precision vector. +/// Returning `None` for any id is a hard inconsistency error. +/// +/// When `opts.quantization` is `Some(_)`, a `CodecSidecar` must be provided. The sidecar +/// encodes query and stored vectors; `fetch_vector` is not called in this path. +/// +/// When `opts.query_dim = Some(d)`, the FP32 path applies Matryoshka truncated-distance +/// reranking using only the first `d` components. `d` must satisfy `0 < d <= query.len()`. +/// `query_dim` combined with `quantization` is not supported — return `BadInput` if both set. +/// +/// `target_recall`, `oversample`, and `meta_token_budget` are accepted via `opts` but not +/// honored here — callers handle those before calling this function. +pub fn rerank<'v, F>( + candidates: Vec, + query: &[f32], + metric: DistanceMetric, + k: usize, + opts: &VectorAnnOptions, + sidecar: Option<&CodecSidecar>, + mut fetch_vector: F, +) -> Result, RerankError> +where + F: FnMut(u32) -> Option<&'v [f32]>, +{ + if k == 0 { + return Err(RerankError::BadInput("k must be > 0".into())); + } + if query.is_empty() { + return Err(RerankError::BadInput("query is empty".into())); + } + + // Determine requested codec (if any) from opts. + let requested_codec = opts.quantization.and_then(codec_name_for_quant); + + // Part C: query_dim + quantization combination is not supported. + if opts.query_dim.is_some() && requested_codec.is_some() { + return Err(RerankError::BadInput( + "rerank: query_dim (Matryoshka truncation) is not yet supported in combination \ + with quantization codecs — use one or the other" + .into(), + )); + } + + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Codec path. + if let Some(requested) = requested_codec { + let sc = sidecar.ok_or_else(|| { + RerankError::BadInput( + "rerank: opts.quantization requested but no codec sidecar provided".into(), + ) + })?; + + let actual = sc.codec_name(); + if actual != requested { + return Err(RerankError::BadInput(format!( + "rerank: requested codec {requested:?} does not match sidecar codec {actual:?}" + ))); + } + + let prepared = sc.prepare_query(query)?; + + let mut scored: Vec = Vec::with_capacity(candidates.len()); + for c in candidates { + match sc.distance_prepared(&prepared, c.id)? { + None => { + return Err(RerankError::BadInput(format!( + "rerank: candidate id {} not present in sidecar (index/sidecar drift)", + c.id + ))); + } + Some(d) => { + scored.push(Ranked { + id: c.id, + distance: d, + }); + } + } + } + + scored.sort_unstable_by(|a, b| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scored.truncate(k); + + // Suppress unused-closure warning — fetch_vector is not used in codec path. + let _ = &mut fetch_vector; + return Ok(scored); + } + + // FP32 path: validate query_dim before touching candidates. + let effective_dim: usize = match opts.query_dim { + Some(d) => { + let d = d as usize; + if d == 0 || d > query.len() { + return Err(RerankError::BadInput(format!( + "query_dim={d} is out of range; query has {} dimensions \ + (must be 0 < query_dim <= query.len())", + query.len(), + ))); + } + d + } + None => query.len(), + }; + + // Truncate query once; candidates are sliced inline using the same length. + let query_slice = crate::matryoshka::truncate(query, effective_dim); + + let mut scored: Vec = Vec::with_capacity(candidates.len()); + let query_dim = query.len(); + + for c in candidates { + let vec = fetch_vector(c.id).ok_or_else(|| { + RerankError::BadInput(format!( + "rerank: fetch_vector returned None for id {}", + c.id + )) + })?; + if vec.len() != query_dim { + return Err(RerankError::BadInput(format!( + "candidate id={} has dim {} but query has dim {}", + c.id, + vec.len(), + query_dim, + ))); + } + let vec_slice = crate::matryoshka::truncate(vec, effective_dim); + let d = crate::distance::distance(query_slice, vec_slice, metric); + scored.push(Ranked { + id: c.id, + distance: d, + }); + } + + scored.sort_unstable_by(|a, b| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scored.truncate(k); + + Ok(scored) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Arc; + + use nodedb_types::vector_ann::{VectorAnnOptions, VectorQuantization}; + + use crate::rerank::codec::{CodecName, PreparedQuery, RerankCodec}; + use crate::rerank::sidecar::CodecSidecar; + use crate::rerank::types::RerankError; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn opts() -> VectorAnnOptions { + VectorAnnOptions::default() + } + + fn opts_with_dim(d: u32) -> VectorAnnOptions { + VectorAnnOptions { + query_dim: Some(d), + ..Default::default() + } + } + + fn opts_with_quant(q: VectorQuantization) -> VectorAnnOptions { + VectorAnnOptions { + quantization: Some(q), + ..Default::default() + } + } + + fn make(id: u32) -> Candidate { + Candidate { + id, + index_distance: 0.0, + } + } + + fn store(pairs: &[(u32, Vec)]) -> HashMap> { + pairs.iter().cloned().collect() + } + + fn fetch<'a>(store: &'a HashMap>) -> impl FnMut(u32) -> Option<&'a [f32]> { + move |id| store.get(&id).map(|v| v.as_slice()) + } + + /// Stub codec that encodes as raw LE f32 bytes and computes L2 distance. + /// Reports `CodecName::Binary` so tests can request it via `VectorQuantization::Binary`. + struct StubCodec { + name: CodecName, + } + + impl RerankCodec for StubCodec { + fn encode(&self, v: &[f32]) -> Result, RerankError> { + Ok(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + fn prepare_query(&self, q: &[f32]) -> Result { + Ok(PreparedQuery::Raw(q.to_vec())) + } + + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let query = match prepared { + PreparedQuery::Raw(v) => v, + _ => { + return Err(RerankError::BadInput( + "StubCodec expects Raw prepared query".into(), + )); + } + }; + let floats: Vec = encoded + .chunks_exact(4) + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .collect(); + if query.len() != floats.len() { + return Err(RerankError::BadInput("dimension mismatch".into())); + } + let d = query + .iter() + .zip(floats.iter()) + .map(|(a, b)| (a - b) * (a - b)) + .sum::() + .sqrt(); + Ok(d) + } + + fn name(&self) -> CodecName { + self.name + } + + fn to_bytes(&self) -> Result, RerankError> { + Err(RerankError::BadInput( + "StubCodec does not support serialization".into(), + )) + } + } + + fn make_sidecar(name: CodecName) -> CodecSidecar { + CodecSidecar::new(Arc::new(StubCodec { name })) + } + + // ── existing FP32 tests (updated to pass None sidecar) ─────────────────── + + #[test] + fn happy_path_top2() { + let s = store(&[ + (1, vec![1.0, 0.0]), + (2, vec![0.1, 0.0]), + (3, vec![0.5, 0.0]), + (4, vec![2.0, 0.0]), + (5, vec![0.3, 0.0]), + ]); + let candidates = vec![make(1), make(2), make(3), make(4), make(5)]; + let query = [0.0, 0.0]; + let result = rerank( + candidates, + &query, + DistanceMetric::L2, + 2, + &opts(), + None, + fetch(&s), + ) + .unwrap(); + assert_eq!(result.len(), 2); + // closest: id=2 (0.01), then id=5 (0.09) + assert_eq!(result[0].id, 2); + assert_eq!(result[1].id, 5); + } + + #[test] + fn empty_candidates_returns_empty() { + let s: HashMap> = HashMap::new(); + let result = rerank( + vec![], + &[1.0, 2.0], + DistanceMetric::L2, + 3, + &opts(), + None, + fetch(&s), + ) + .unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn dim_mismatch_returns_bad_input() { + let s = store(&[(7, vec![1.0, 2.0, 3.0])]); + let err = rerank( + vec![make(7)], + &[1.0, 2.0], + DistanceMetric::L2, + 1, + &opts(), + None, + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("7"), "expected id in message: {msg}"); + assert!( + msg.contains("3"), + "expected candidate dim in message: {msg}" + ); + assert!(msg.contains("2"), "expected query dim in message: {msg}"); + } + + #[test] + fn k_zero_returns_bad_input() { + let s = store(&[(1, vec![1.0])]); + let err = rerank( + vec![make(1)], + &[0.0], + DistanceMetric::L2, + 0, + &opts(), + None, + fetch(&s), + ) + .unwrap_err(); + assert!(err.to_string().contains("k must be > 0")); + } + + #[test] + fn k_exceeds_candidates_returns_all() { + let s = store(&[ + (1, vec![1.0, 0.0]), + (2, vec![2.0, 0.0]), + (3, vec![3.0, 0.0]), + ]); + let candidates = vec![make(1), make(2), make(3)]; + let result = rerank( + candidates, + &[0.0, 0.0], + DistanceMetric::L2, + 10, + &opts(), + None, + fetch(&s), + ) + .unwrap(); + assert_eq!(result.len(), 3); + } + + // ── query_dim (Matryoshka truncated-distance reranking) ─────────────────── + + #[test] + fn query_dim_truncated_ranking_differs_from_full() { + let s = store(&[(1, vec![0.1, 0.1]), (2, vec![0.0, 9.0])]); + let query = [0.0_f32, 1.0]; + + let full = rerank( + vec![make(1), make(2)], + &query, + DistanceMetric::L2, + 1, + &opts(), + None, + fetch(&s), + ) + .unwrap(); + assert_eq!(full[0].id, 1, "full-dim should rank id=1 first"); + + let trunc = rerank( + vec![make(1), make(2)], + &query, + DistanceMetric::L2, + 1, + &opts_with_dim(1), + None, + fetch(&s), + ) + .unwrap(); + assert_eq!(trunc[0].id, 2, "truncated-dim=1 should rank id=2 first"); + } + + #[test] + fn query_dim_zero_returns_bad_input() { + let s = store(&[(1, vec![1.0, 2.0])]); + let err = rerank( + vec![make(1)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts_with_dim(0), + None, + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("query_dim=0"), + "error should name query_dim=0: {msg}" + ); + } + + #[test] + fn query_dim_exceeds_query_len_returns_bad_input() { + let s = store(&[(1, vec![1.0, 2.0])]); + let err = rerank( + vec![make(1)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts_with_dim(5), + None, + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("query_dim=5"), + "error should name query_dim=5: {msg}" + ); + assert!( + msg.contains('2'), + "error should mention query len (2): {msg}" + ); + } + + #[test] + fn query_dim_equal_to_query_len_matches_full_dim() { + let s = store(&[ + (1, vec![1.0, 0.0, 0.0]), + (2, vec![0.5, 0.0, 0.0]), + (3, vec![3.0, 0.0, 0.0]), + ]); + let query = [0.0_f32, 0.0, 0.0]; + + let full = rerank( + vec![make(1), make(2), make(3)], + &query, + DistanceMetric::L2, + 3, + &opts(), + None, + fetch(&s), + ) + .unwrap(); + let trunc = rerank( + vec![make(1), make(2), make(3)], + &query, + DistanceMetric::L2, + 3, + &opts_with_dim(3), + None, + fetch(&s), + ) + .unwrap(); + + let full_ids: Vec = full.iter().map(|r| r.id).collect(); + let trunc_ids: Vec = trunc.iter().map(|r| r.id).collect(); + assert_eq!( + full_ids, trunc_ids, + "query_dim == query.len() should produce identical ranking" + ); + } + + #[test] + fn fetch_returns_none_is_bad_input() { + let s: HashMap> = HashMap::new(); + let err = rerank( + vec![make(99)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts(), + None, + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("99"), + "error should name the missing id (99): {msg}" + ); + assert!( + matches!(err, RerankError::BadInput(_)), + "expected BadInput, got: {err}" + ); + } + + // ── Part F: codec path tests ────────────────────────────────────────────── + + /// Part F.1: codec path uses sidecar distances rather than FP32 fetch_vector. + #[test] + fn codec_path_uses_sidecar() { + // StubCodec uses Binary name, so request VectorQuantization::Binary. + let mut sc = make_sidecar(CodecName::Binary); + // Insert 3 vectors: distances from [0,0] are 1.0, 2.0, 3.0. + sc.encode_and_insert(1, &[1.0, 0.0]).unwrap(); + sc.encode_and_insert(2, &[0.0, 2.0]).unwrap(); + sc.encode_and_insert(3, &[3.0, 0.0]).unwrap(); + + let candidates = vec![make(1), make(2), make(3)]; + let opts = opts_with_quant(VectorQuantization::Binary); + + // fetch_vector should never be called in codec path — pass a closure + // that panics to confirm. + let result = rerank( + candidates, + &[0.0, 0.0], + DistanceMetric::L2, + 3, + &opts, + Some(&sc), + |_id| panic!("fetch_vector must not be called in codec path"), + ) + .unwrap(); + + assert_eq!(result.len(), 3); + // Distances: id=1 → 1.0, id=2 → 2.0, id=3 → 3.0 + assert_eq!(result[0].id, 1); + assert_eq!(result[1].id, 2); + assert_eq!(result[2].id, 3); + assert!((result[0].distance - 1.0).abs() < 1e-5); + } + + /// Part F.2: opts requests codec but sidecar is None → BadInput. + #[test] + fn codec_requested_but_no_sidecar_returns_bad_input() { + let s: HashMap> = HashMap::new(); + let opts = opts_with_quant(VectorQuantization::Binary); + let err = rerank( + vec![make(1)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts, + None, + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("no codec sidecar provided"), + "expected sidecar-missing message: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } + + /// Part F.3: sidecar codec name (Binary) does not match requested (Sq8) → BadInput. + #[test] + fn codec_name_mismatch_returns_bad_input() { + let mut sc = make_sidecar(CodecName::Binary); // sidecar is Binary + sc.encode_and_insert(1, &[1.0, 0.0]).unwrap(); + + let opts = opts_with_quant(VectorQuantization::Sq8); // request Sq8 + let s: HashMap> = HashMap::new(); + let err = rerank( + vec![make(1)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts, + Some(&sc), + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Sq8") || msg.contains("sq8"), + "expected requested codec in message: {msg}" + ); + assert!( + msg.contains("Binary") || msg.contains("binary"), + "expected actual codec in message: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } + + /// Part F.4: both query_dim and quantization set → BadInput. + #[test] + fn codec_with_query_dim_returns_bad_input() { + let mut sc = make_sidecar(CodecName::Binary); + sc.encode_and_insert(1, &[1.0, 0.0]).unwrap(); + + let opts = VectorAnnOptions { + query_dim: Some(1), + quantization: Some(VectorQuantization::Binary), + ..Default::default() + }; + let s: HashMap> = HashMap::new(); + let err = rerank( + vec![make(1)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts, + Some(&sc), + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("query_dim") && msg.contains("quantization"), + "expected both terms in message: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } + + /// Part F.5: quantization=None with sidecar Some → FP32 path, sidecar ignored. + #[test] + fn fp32_path_with_some_sidecar_argument() { + let sc = make_sidecar(CodecName::Binary); + // FP32 store has real vectors. + let s = store(&[(1, vec![1.0, 0.0]), (2, vec![0.1, 0.0])]); + // opts has no quantization — FP32 path should run. + let result = rerank( + vec![make(1), make(2)], + &[0.0, 0.0], + DistanceMetric::L2, + 2, + &opts(), // no quantization + Some(&sc), + fetch(&s), + ) + .unwrap(); + // FP32: id=2 is closer (dist=0.1) than id=1 (dist=1.0). + assert_eq!(result[0].id, 2); + assert_eq!(result[1].id, 1); + } + + /// Part F.6: candidate id not in sidecar → BadInput (index/sidecar drift). + #[test] + fn codec_path_missing_id_in_sidecar_returns_bad_input() { + let sc = make_sidecar(CodecName::Binary); + // Sidecar is empty — id 99 is not present. + let opts = opts_with_quant(VectorQuantization::Binary); + let s: HashMap> = HashMap::new(); + let err = rerank( + vec![make(99)], + &[0.0, 0.0], + DistanceMetric::L2, + 1, + &opts, + Some(&sc), + fetch(&s), + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("99"), "expected id 99 in message: {msg}"); + assert!( + msg.contains("sidecar drift") || msg.contains("not present in sidecar"), + "expected drift message: {msg}" + ); + assert!(matches!(err, RerankError::BadInput(_))); + } +} diff --git a/nodedb-vector/src/rerank/recall.rs b/nodedb-vector/src/rerank/recall.rs new file mode 100644 index 000000000..004de7f31 --- /dev/null +++ b/nodedb-vector/src/rerank/recall.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::types::RerankError; + +/// Scale `ef_search` and `oversample` up to meet a recall target. +/// +/// `target_recall` must be in (0.0, 1.0]. Returns `(ef, oversample)` adjusted +/// for the given target. When `target_recall` is `None` or already-default, +/// returns the base values unchanged. +/// +/// Formula (heuristic, not a guarantee): +/// - For `r <= 0.80`: identity — `ef = base_ef`, `oversample = base_oversample`. +/// - For `r > 0.80`: ramp scale from 1.0× at r=0.80 to 5.0× at r=1.00, linearly. +/// `scale = 1.0 + (r - 0.80) / 0.20 * 4.0`, clamped to `[1.0, 5.0]`. +/// - `ef` becomes `max(base_ef, (base_ef as f32 * scale).ceil() as usize)`. +/// - `oversample` becomes `max(base_oversample, (base_oversample as f32 * scale.sqrt()).ceil() as u8)` +/// — oversample grows sub-linearly because rerank cost is linear in oversample, +/// while ef has a more favourable cost curve. +pub fn recall_scale( + target_recall: Option, + base_ef: usize, + base_oversample: u8, +) -> Result<(usize, u8), RerankError> { + let r = match target_recall { + None => return Ok((base_ef, base_oversample)), + Some(v) => v, + }; + + if r.is_nan() || r <= 0.0 || r > 1.0 { + return Err(RerankError::BadInput(format!( + "target_recall must be in (0.0, 1.0], got {r}" + ))); + } + + if r <= 0.80 { + return Ok((base_ef, base_oversample)); + } + + let scale = (1.0_f32 + (r - 0.80) / 0.20 * 4.0).clamp(1.0, 5.0); + + let ef = base_ef.max((base_ef as f32 * scale).ceil() as usize); + + let oversample_scaled = (base_oversample as f32 * scale.sqrt()).ceil() as u32; + let oversample = base_oversample.max(oversample_scaled.min(u8::MAX as u32) as u8); + + Ok((ef, oversample)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn none_returns_base() { + assert_eq!(recall_scale(None, 100, 1).unwrap(), (100, 1)); + } + + #[test] + fn boundary_identity_at_0_80() { + assert_eq!(recall_scale(Some(0.80), 100, 1).unwrap(), (100, 1)); + } + + #[test] + fn recall_0_90_scale_3() { + // scale = 1 + (0.10/0.20)*4 = 3.0; ef = 300; oversample = ceil(sqrt(3)) = 2 + assert_eq!(recall_scale(Some(0.90), 100, 1).unwrap(), (300, 2)); + } + + #[test] + fn recall_1_00_scale_5() { + // scale = 5.0; ef = 500; oversample = ceil(sqrt(5)) = ceil(2.236) = 3 + assert_eq!(recall_scale(Some(1.00), 100, 1).unwrap(), (500, 3)); + } + + #[test] + fn recall_0_95_spot_check() { + // scale = 1 + (0.15/0.20)*4 = 4.0; ef = 800; oversample = ceil(4*sqrt(4)) = ceil(8) = 8 + assert_eq!(recall_scale(Some(0.95), 200, 4).unwrap(), (800, 8)); + } + + #[test] + fn zero_is_bad_input() { + assert!(matches!( + recall_scale(Some(0.0), 100, 1), + Err(RerankError::BadInput(_)) + )); + } + + #[test] + fn above_one_is_bad_input() { + assert!(matches!( + recall_scale(Some(1.01), 100, 1), + Err(RerankError::BadInput(_)) + )); + } + + #[test] + fn nan_is_bad_input() { + assert!(matches!( + recall_scale(Some(f32::NAN), 100, 1), + Err(RerankError::BadInput(_)) + )); + } + + #[test] + fn negative_is_bad_input() { + assert!(matches!( + recall_scale(Some(-0.5), 100, 1), + Err(RerankError::BadInput(_)) + )); + } +} diff --git a/nodedb-vector/src/rerank/sidecar.rs b/nodedb-vector/src/rerank/sidecar.rs new file mode 100644 index 000000000..a2fecd57e --- /dev/null +++ b/nodedb-vector/src/rerank/sidecar.rs @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::sync::Arc; + +use super::codec::{CodecName, PreparedQuery, RerankCodec}; +use super::types::RerankError; + +const SIDECAR_MAGIC: [u8; 4] = *b"NDCC"; +const SIDECAR_VERSION: u8 = 1; + +/// Per-collection encoded-vector storage keyed by surrogate id, paired with +/// the trained codec. Encoded vectors live alongside (not inside) the HNSW +/// index — HNSW keeps full-precision vectors for graph traversal; the sidecar +/// is consulted only during base-layer rerank. +pub struct CodecSidecar { + codec: Arc, + encoded: HashMap>, +} + +impl CodecSidecar { + pub fn new(codec: Arc) -> Self { + Self { + codec, + encoded: HashMap::new(), + } + } + + pub fn codec_name(&self) -> CodecName { + self.codec.name() + } + + /// Encode a vector and insert it under `id`. Overwrites any existing entry. + pub fn encode_and_insert(&mut self, id: u32, vector: &[f32]) -> Result<(), RerankError> { + let bytes = self.codec.encode(vector)?; + self.encoded.insert(id, bytes); + Ok(()) + } + + pub fn remove(&mut self, id: u32) { + self.encoded.remove(&id); + } + + pub fn get(&self, id: u32) -> Option<&[u8]> { + self.encoded.get(&id).map(|v| v.as_slice()) + } + + pub fn len(&self) -> usize { + self.encoded.len() + } + + pub fn is_empty(&self) -> bool { + self.encoded.is_empty() + } + + /// Serialize the sidecar (codec state + all encoded vectors) to bytes. + /// + /// Format: `[NDCC (4 bytes)][version: u8 = 1][msgpack payload]` + pub fn to_bytes(&self) -> Result, RerankError> { + let codec_bytes = self.codec.to_bytes()?; + let codec_name_byte = codec_name_to_u8(self.codec.name()); + + #[derive( + serde::Serialize, serde::Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack, + )] + struct Payload { + codec_name: u8, + codec_bytes: Vec, + encoded: Vec<(u32, Vec)>, + } + + let payload = Payload { + codec_name: codec_name_byte, + codec_bytes, + encoded: self.encoded.iter().map(|(k, v)| (*k, v.clone())).collect(), + }; + + let body = zerompk::to_msgpack_vec(&payload) + .map_err(|e| RerankError::BadInput(format!("sidecar serialize: {e}")))?; + let mut buf = Vec::with_capacity(5 + body.len()); + buf.extend_from_slice(&SIDECAR_MAGIC); + buf.push(SIDECAR_VERSION); + buf.extend_from_slice(&body); + Ok(buf) + } + + /// Deserialize a sidecar from bytes produced by [`Self::to_bytes`]. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < 5 { + return Err(RerankError::BadInput( + "sidecar from_bytes: too short".into(), + )); + } + if bytes[..4] != SIDECAR_MAGIC { + return Err(RerankError::BadInput( + "sidecar from_bytes: bad magic".into(), + )); + } + let version = bytes[4]; + if version != SIDECAR_VERSION { + return Err(RerankError::BadInput(format!( + "sidecar from_bytes: unknown version {version}" + ))); + } + + #[derive( + serde::Serialize, serde::Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack, + )] + struct Payload { + codec_name: u8, + codec_bytes: Vec, + encoded: Vec<(u32, Vec)>, + } + + let payload: Payload = zerompk::from_msgpack(&bytes[5..]) + .map_err(|e| RerankError::BadInput(format!("sidecar deserialize: {e}")))?; + + let codec_name = codec_name_from_u8(payload.codec_name).ok_or_else(|| { + RerankError::BadInput(format!( + "sidecar from_bytes: unknown codec_name byte {}", + payload.codec_name + )) + })?; + let codec = super::codec::rerank_codec_from_bytes(codec_name, &payload.codec_bytes)?; + let encoded = payload.encoded.into_iter().collect(); + Ok(CodecSidecar { codec, encoded }) + } + + pub fn prepare_query(&self, query: &[f32]) -> Result { + self.codec.prepare_query(query) + } + + /// Compute distance from a prepared query to the encoded vector at `id`. + /// Returns `Ok(None)` when the id isn't in the sidecar (lost / not yet + /// encoded); returns the distance otherwise. + pub fn distance_prepared( + &self, + prepared: &PreparedQuery, + id: u32, + ) -> Result, RerankError> { + match self.encoded.get(&id) { + None => Ok(None), + Some(bytes) => self.codec.distance_prepared(prepared, bytes).map(Some), + } + } +} + +fn codec_name_to_u8(name: CodecName) -> u8 { + match name { + CodecName::Sq8 => 0, + CodecName::Pq => 1, + CodecName::Binary => 2, + CodecName::RaBitQ => 3, + CodecName::Bbq => 4, + } +} + +fn codec_name_from_u8(b: u8) -> Option { + match b { + 0 => Some(CodecName::Sq8), + 1 => Some(CodecName::Pq), + 2 => Some(CodecName::Binary), + 3 => Some(CodecName::RaBitQ), + 4 => Some(CodecName::Bbq), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rerank::codec::CodecName; + + struct StubCodec; + + impl RerankCodec for StubCodec { + fn encode(&self, v: &[f32]) -> Result, RerankError> { + Ok(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + fn prepare_query(&self, q: &[f32]) -> Result { + Ok(PreparedQuery::Raw(q.to_vec())) + } + + fn distance_prepared( + &self, + prepared: &PreparedQuery, + encoded: &[u8], + ) -> Result { + let query = match prepared { + PreparedQuery::Raw(v) => v, + _ => { + return Err(RerankError::BadInput( + "StubCodec expects Raw prepared query".into(), + )); + } + }; + let encoded_floats: Vec = encoded + .chunks_exact(4) + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .collect(); + if query.len() != encoded_floats.len() { + return Err(RerankError::BadInput("dimension mismatch".into())); + } + let dist = query + .iter() + .zip(encoded_floats.iter()) + .map(|(a, b)| (a - b) * (a - b)) + .sum::() + .sqrt(); + Ok(dist) + } + + fn name(&self) -> CodecName { + CodecName::Binary + } + + fn to_bytes(&self) -> Result, RerankError> { + Err(RerankError::BadInput( + "StubCodec does not support serialization".into(), + )) + } + } + + fn make_sidecar() -> CodecSidecar { + CodecSidecar::new(Arc::new(StubCodec)) + } + + #[test] + fn insert_and_get() { + let mut s = make_sidecar(); + assert!(s.is_empty()); + s.encode_and_insert(1, &[1.0, 2.0]).unwrap(); + s.encode_and_insert(2, &[3.0, 4.0]).unwrap(); + s.encode_and_insert(3, &[5.0, 6.0]).unwrap(); + assert_eq!(s.len(), 3); + + let expected_1: Vec = [1.0f32, 2.0f32] + .iter() + .flat_map(|x| x.to_le_bytes()) + .collect(); + assert_eq!(s.get(1), Some(expected_1.as_slice())); + } + + #[test] + fn remove_returns_none() { + let mut s = make_sidecar(); + s.encode_and_insert(10, &[1.0]).unwrap(); + s.remove(10); + assert_eq!(s.get(10), None); + let prepared = s.prepare_query(&[1.0]).unwrap(); + assert_eq!(s.distance_prepared(&prepared, 10).unwrap(), None); + } + + #[test] + fn distance_prepared_correct() { + let mut s = make_sidecar(); + s.encode_and_insert(5, &[0.0, 0.0]).unwrap(); + let prepared = s.prepare_query(&[3.0, 4.0]).unwrap(); + let dist = s.distance_prepared(&prepared, 5).unwrap().unwrap(); + assert!((dist - 5.0).abs() < 1e-5, "expected L2=5.0, got {dist}"); + } + + #[test] + fn codec_name_passthrough() { + let s = make_sidecar(); + assert_eq!(s.codec_name(), CodecName::Binary); + assert_eq!(s.codec_name().as_str(), "binary"); + } + + #[test] + fn len_and_is_empty() { + let mut s = make_sidecar(); + assert!(s.is_empty()); + s.encode_and_insert(1, &[1.0]).unwrap(); + assert!(!s.is_empty()); + assert_eq!(s.len(), 1); + s.remove(1); + assert!(s.is_empty()); + } + + // ── Sidecar serialization tests ──────────────────────────────────────────── + + fn det_vec(i: usize, dim: usize) -> Vec { + (0..dim) + .map(|j| ((i * 31 + j) % 100) as f32 / 100.0) + .collect() + } + + #[test] + fn sidecar_roundtrip_sq8() { + use crate::rerank::codecs::Sq8Rerank; + let dim = 16; + let mut codec = Sq8Rerank::new(dim); + let samples: Vec> = (0..20).map(|i| det_vec(i, dim)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + codec.train(&refs).unwrap(); + + let mut s = CodecSidecar::new(Arc::new(codec)); + for i in 0..5u32 { + s.encode_and_insert(i, &det_vec(i as usize, dim)).unwrap(); + } + let bytes = s.to_bytes().expect("to_bytes"); + let s2 = CodecSidecar::from_bytes(&bytes).expect("from_bytes"); + assert_eq!(s2.codec_name(), CodecName::Sq8); + for i in 0..5u32 { + assert_eq!(s.get(i), s2.get(i), "encoded bytes differ for id {i}"); + } + } + + #[test] + fn sidecar_roundtrip_binary() { + use crate::rerank::codecs::BinaryRerank; + let dim = 16; + let mut s = CodecSidecar::new(Arc::new(BinaryRerank::new(dim))); + for i in 0..5u32 { + s.encode_and_insert(i, &det_vec(i as usize, dim)).unwrap(); + } + let bytes = s.to_bytes().expect("to_bytes"); + let s2 = CodecSidecar::from_bytes(&bytes).expect("from_bytes"); + assert_eq!(s2.codec_name(), CodecName::Binary); + for i in 0..5u32 { + assert_eq!(s.get(i), s2.get(i), "encoded bytes differ for id {i}"); + } + } + + #[test] + fn sidecar_roundtrip_pq() { + use crate::rerank::codecs::PqRerank; + let dim = 16; + let m = 4; + let k = 8; + let mut codec = PqRerank::new(dim, m, k); + let samples: Vec> = (0..32).map(|i| det_vec(i, dim)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + codec.train(&refs).unwrap(); + + let mut s = CodecSidecar::new(Arc::new(codec)); + for i in 0..5u32 { + s.encode_and_insert(i, &det_vec(i as usize, dim)).unwrap(); + } + let bytes = s.to_bytes().expect("to_bytes"); + let s2 = CodecSidecar::from_bytes(&bytes).expect("from_bytes"); + assert_eq!(s2.codec_name(), CodecName::Pq); + for i in 0..5u32 { + assert_eq!(s.get(i), s2.get(i), "encoded bytes differ for id {i}"); + } + } + + #[test] + fn sidecar_roundtrip_rabitq() { + use crate::rerank::codecs::RaBitQRerank; + let dim = 16; + let mut codec = RaBitQRerank::new(dim, 0xDEADBEEF_C0FFEE42); + let samples: Vec> = (0..20).map(|i| det_vec(i, dim)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + codec.train(&refs).unwrap(); + + let mut s = CodecSidecar::new(Arc::new(codec)); + for i in 0..5u32 { + s.encode_and_insert(i, &det_vec(i as usize, dim)).unwrap(); + } + let bytes = s.to_bytes().expect("to_bytes"); + let s2 = CodecSidecar::from_bytes(&bytes).expect("from_bytes"); + assert_eq!(s2.codec_name(), CodecName::RaBitQ); + for i in 0..5u32 { + assert_eq!(s.get(i), s2.get(i), "encoded bytes differ for id {i}"); + } + } + + #[test] + fn sidecar_roundtrip_bbq() { + use crate::rerank::codecs::BbqRerank; + let dim = 16; + let mut codec = BbqRerank::new(dim, 4); + let samples: Vec> = (0..20).map(|i| det_vec(i, dim)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + codec.train(&refs).unwrap(); + + let mut s = CodecSidecar::new(Arc::new(codec)); + for i in 0..5u32 { + s.encode_and_insert(i, &det_vec(i as usize, dim)).unwrap(); + } + let bytes = s.to_bytes().expect("to_bytes"); + let s2 = CodecSidecar::from_bytes(&bytes).expect("from_bytes"); + assert_eq!(s2.codec_name(), CodecName::Bbq); + for i in 0..5u32 { + assert_eq!(s.get(i), s2.get(i), "encoded bytes differ for id {i}"); + } + } + + #[test] + fn sidecar_bad_magic_returns_error() { + use crate::rerank::codecs::BinaryRerank; + let s = CodecSidecar::new(Arc::new(BinaryRerank::new(4))); + let mut bytes = s.to_bytes().unwrap(); + bytes[0] = b'X'; + assert!(CodecSidecar::from_bytes(&bytes).is_err()); + } + + #[test] + fn sidecar_bad_version_returns_error() { + use crate::rerank::codecs::BinaryRerank; + let s = CodecSidecar::new(Arc::new(BinaryRerank::new(4))); + let mut bytes = s.to_bytes().unwrap(); + bytes[4] = 99; + assert!(CodecSidecar::from_bytes(&bytes).is_err()); + } + + #[test] + fn sidecar_distance_matches_after_roundtrip() { + use crate::rerank::codecs::Sq8Rerank; + let dim = 16; + let mut codec = Sq8Rerank::new(dim); + let samples: Vec> = (0..20).map(|i| det_vec(i, dim)).collect(); + let refs: Vec<&[f32]> = samples.iter().map(|v| v.as_slice()).collect(); + codec.train(&refs).unwrap(); + + let mut s = CodecSidecar::new(Arc::new(codec)); + s.encode_and_insert(1, &det_vec(3, dim)).unwrap(); + let query_vec = det_vec(7, dim); + let prepared_orig = s.prepare_query(&query_vec).unwrap(); + let d_orig = s.distance_prepared(&prepared_orig, 1).unwrap().unwrap(); + + let bytes = s.to_bytes().unwrap(); + let s2 = CodecSidecar::from_bytes(&bytes).unwrap(); + let prepared_rest = s2.prepare_query(&query_vec).unwrap(); + let d_rest = s2.distance_prepared(&prepared_rest, 1).unwrap().unwrap(); + + assert!( + (d_orig - d_rest).abs() < 1e-5, + "distance diverged: {d_orig} vs {d_rest}" + ); + } +} diff --git a/nodedb-vector/src/rerank/types.rs b/nodedb-vector/src/rerank/types.rs new file mode 100644 index 000000000..22943ac72 --- /dev/null +++ b/nodedb-vector/src/rerank/types.rs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// A pre-rerank candidate returned by the index-level search (HNSW/Vamana/...). +#[derive(Debug, Clone)] +pub struct Candidate { + pub id: u32, + pub index_distance: f32, +} + +/// Final rerank output. +#[derive(Debug, Clone)] +pub struct Ranked { + pub id: u32, + pub distance: f32, +} + +#[derive(thiserror::Error, Debug)] +pub enum RerankError { + #[error("bad input: {0}")] + BadInput(String), + #[error("codec not trained: {0}")] + NotTrained(String), + #[error("codec training failed: {0}")] + Train(String), + // Additional variants added in later sub-tasks (CodecMissing, CodecMismatch, ...). +} diff --git a/nodedb-vector/src/sieve/collection.rs b/nodedb-vector/src/sieve/collection.rs index 91ab21472..3daf70866 100644 --- a/nodedb-vector/src/sieve/collection.rs +++ b/nodedb-vector/src/sieve/collection.rs @@ -57,6 +57,7 @@ impl SieveCollection { m0: self.sub_m * 2, ef_construction: 200, metric, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }; let mut index = HnswIndex::new(dim, params); for (_, vec) in vectors { diff --git a/nodedb-vector/src/sieve/router.rs b/nodedb-vector/src/sieve/router.rs index f08747884..063541bde 100644 --- a/nodedb-vector/src/sieve/router.rs +++ b/nodedb-vector/src/sieve/router.rs @@ -88,6 +88,7 @@ mod tests { m0: 16, ef_construction: 50, metric: DistanceMetric::L2, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }, 99, ); diff --git a/nodedb-vector/tests/collection_pq_config.rs b/nodedb-vector/tests/collection_pq_config.rs index 74013e187..36815435f 100644 --- a/nodedb-vector/tests/collection_pq_config.rs +++ b/nodedb-vector/tests/collection_pq_config.rs @@ -21,6 +21,7 @@ fn params() -> HnswParams { m0: 32, ef_construction: 100, metric: DistanceMetric::L2, + ..HnswParams::default() } } diff --git a/nodedb-vector/tests/hnsw_layer_cap.rs b/nodedb-vector/tests/hnsw_layer_cap.rs index 7ebcefcd7..5d46ddb24 100644 --- a/nodedb-vector/tests/hnsw_layer_cap.rs +++ b/nodedb-vector/tests/hnsw_layer_cap.rs @@ -26,6 +26,7 @@ fn random_layer_never_exceeds_cap_under_normal_inserts() { m0: 32, ef_construction: 64, metric: DistanceMetric::L2, + ..HnswParams::default() }, 1, ); @@ -59,6 +60,7 @@ fn random_layer_capped_with_adversarial_seed() { m0: 4, ef_construction: 32, metric: DistanceMetric::L2, + ..HnswParams::default() }, seed, ); diff --git a/nodedb/src/data/executor/handlers/vector.rs b/nodedb/src/data/executor/handlers/vector.rs index 1697ec0d9..5a8a7120f 100644 --- a/nodedb/src/data/executor/handlers/vector.rs +++ b/nodedb/src/data/executor/handlers/vector.rs @@ -481,6 +481,7 @@ impl CoreLoop { m0: resolved_m * 2, ef_construction: resolved_ef, metric: metric_enum, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }; let config = crate::engine::vector::index_config::IndexConfig { diff --git a/nodedb/src/data/executor/handlers/vector_lifecycle.rs b/nodedb/src/data/executor/handlers/vector_lifecycle.rs index 062db1824..7d5cc64dc 100644 --- a/nodedb/src/data/executor/handlers/vector_lifecycle.rs +++ b/nodedb/src/data/executor/handlers/vector_lifecycle.rs @@ -212,6 +212,7 @@ impl CoreLoop { current.ef_construction }, metric: current.metric, + dtype: current.dtype, }; coll.set_params(new_params.clone()); diff --git a/nodedb/src/data/executor/wal_replay.rs b/nodedb/src/data/executor/wal_replay.rs index a88b0dc81..24f51bba4 100644 --- a/nodedb/src/data/executor/wal_replay.rs +++ b/nodedb/src/data/executor/wal_replay.rs @@ -133,6 +133,7 @@ impl CoreLoop { m0: m * 2, ef_construction, metric: metric_enum, + dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32, }; self.vector_params.insert(index_key, params); tracing::debug!( From 5500f27704ee16f0c0fedc43f4d75ca4879c5891 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Sun, 17 May 2026 07:38:02 +0800 Subject: [PATCH 03/11] refactor: migrate PhysicalPlan/PhysicalTask imports to nodedb-physical `PhysicalPlan`, `PhysicalTask`, and `PostSetOp` were previously re-exported through `nodedb::bridge`. They now live in the dedicated `nodedb-physical` crate. Update all test files and test-support helpers to import from `nodedb_physical` directly, and add the crate dependency to `nodedb-cluster-tests` and `nodedb-test-support` Cargo manifests. --- nodedb-cluster-tests/Cargo.toml | 1 + .../tests/cluster_execute_request.rs | 4 +-- .../tests/cluster_procedures.rs | 20 +++++------ .../tests/cluster_triggers.rs | 3 +- nodedb-cluster-tests/tests/gateway_execute.rs | 2 +- .../tests/http_gateway_migration.rs | 2 +- .../tests/ilp_gateway_migration.rs | 2 +- .../tests/listeners_gateway_smoke.rs | 2 +- .../tests/listeners_typed_not_leader.rs | 2 +- .../tests/native_gateway_migration.rs | 2 +- .../tests/pgwire_gateway_migration.rs | 2 +- .../tests/resp_gateway_migration.rs | 2 +- nodedb-test-support/Cargo.toml | 1 + nodedb-test-support/src/tx_batch_helpers.rs | 10 +++--- nodedb/tests/calvin_determinism_contract.rs | 13 +++---- nodedb/tests/calvin_executor_apply.rs | 4 +-- .../tests/calvin_executor_dependent_read.rs | 2 +- .../tests/calvin_executor_panic_recovery.rs | 12 ++++--- .../calvin_executor_static_multishard.rs | 6 ++-- nodedb/tests/cross_engine_bitmap_currency.rs | 8 ++--- .../cross_engine_three_way_fts_vector_doc.rs | 4 +-- nodedb/tests/executor_tests/helpers.rs | 3 +- .../executor_tests/test_aggregate_aliases.rs | 3 +- nodedb/tests/executor_tests/test_array_ops.rs | 5 ++- .../executor_tests/test_columnar_aggregate.rs | 5 +-- .../executor_tests/test_conditional_update.rs | 34 ++++++------------- .../test_cross_engine_validation.rs | 4 +-- .../test_cross_type_join/basic_scans.rs | 5 +-- .../test_cross_type_join/cross_semi_joins.rs | 5 +-- .../test_cross_type_join/inline_hash_join.rs | 3 +- .../test_cross_type_join/multi_core_joins.rs | 3 +- .../test_cross_type_join/single_core_joins.rs | 3 +- nodedb/tests/executor_tests/test_document.rs | 4 +-- nodedb/tests/executor_tests/test_facet.rs | 3 +- .../executor_tests/test_generated_columns.rs | 9 ++--- nodedb/tests/executor_tests/test_graph.rs | 3 +- .../tests/executor_tests/test_graph_bounds.rs | 4 +-- .../executor_tests/test_group_by_alias.rs | 3 +- nodedb/tests/executor_tests/test_kv.rs | 4 +-- .../tests/executor_tests/test_kv_advanced.rs | 6 ++-- .../executor_tests/test_ollp_verification.rs | 8 ++--- .../test_security_and_isolation.rs | 4 +-- .../test_tenant_isolation_fulltext.rs | 4 +-- ...test_tenant_isolation_fulltext_negative.rs | 4 +-- .../test_tenant_isolation_graph.rs | 4 +-- .../test_tenant_isolation_graph_negative.rs | 4 +-- .../test_tenant_isolation_kv.rs | 4 +-- .../test_tenant_isolation_kv_negative.rs | 4 +-- .../test_tenant_isolation_sparse.rs | 4 +-- .../test_tenant_isolation_sparse_negative.rs | 4 +-- .../test_tenant_isolation_timeseries.rs | 4 +-- ...st_tenant_isolation_timeseries_negative.rs | 4 +-- .../test_tenant_isolation_vector.rs | 4 +-- .../test_tenant_isolation_vector_negative.rs | 4 +-- .../tests/executor_tests/test_tenant_purge.rs | 6 ++-- .../tests/executor_tests/test_timeseries.rs | 3 +- .../executor_tests/test_timeseries_budget.rs | 3 +- .../tests/executor_tests/test_transaction.rs | 4 +-- .../executor_tests/test_transaction_matrix.rs | 8 +++-- .../test_transaction_matrix_kv.rs | 8 ++--- nodedb/tests/executor_tests/test_vector.rs | 4 +-- nodedb/tests/memory_pressure_transitions.rs | 8 ++--- nodedb/tests/procedure_execution.rs | 10 +++--- nodedb/tests/startup_gate_pgwire.rs | 4 +-- nodedb/tests/surrogate_round_trip.rs | 8 ++--- .../tests/transaction_batch_cross_engine.rs | 4 +-- .../transaction_batch_cross_engine_crash.rs | 6 ++-- .../transaction_batch_cross_engine_mixed.rs | 4 +-- nodedb/tests/trigger_execution.rs | 32 ++++++++--------- nodedb/tests/wal_catchup.rs | 3 +- 70 files changed, 188 insertions(+), 200 deletions(-) diff --git a/nodedb-cluster-tests/Cargo.toml b/nodedb-cluster-tests/Cargo.toml index cb2856ac2..a9a02d929 100644 --- a/nodedb-cluster-tests/Cargo.toml +++ b/nodedb-cluster-tests/Cargo.toml @@ -17,6 +17,7 @@ nodedb = { workspace = true } nodedb-array = { workspace = true } nodedb-bridge = { workspace = true } nodedb-cluster = { workspace = true } +nodedb-physical = { workspace = true } nodedb-types = { workspace = true } nodedb-test-support = { workspace = true } diff --git a/nodedb-cluster-tests/tests/cluster_execute_request.rs b/nodedb-cluster-tests/tests/cluster_execute_request.rs index 0ac786e12..30706d11b 100644 --- a/nodedb-cluster-tests/tests/cluster_execute_request.rs +++ b/nodedb-cluster-tests/tests/cluster_execute_request.rs @@ -17,11 +17,11 @@ mod common; use std::time::Duration; use common::cluster_harness::TestCluster; -use nodedb::bridge::physical_plan::wire as plan_wire; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb_cluster::rpc_codec::{ DescriptorVersionEntry, ExecuteRequest, RaftRpc, TypedClusterError, }; +use nodedb_physical::physical_plan::wire as plan_wire; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; /// Build an `ExecuteRequest` wrapping a trivial `KvOp::Put`. fn make_kv_put_request( diff --git a/nodedb-cluster-tests/tests/cluster_procedures.rs b/nodedb-cluster-tests/tests/cluster_procedures.rs index 7b2673581..cdd9abf61 100644 --- a/nodedb-cluster-tests/tests/cluster_procedures.rs +++ b/nodedb-cluster-tests/tests/cluster_procedures.rs @@ -8,12 +8,12 @@ //! - Replicated constraint violations produce DeltaReject with CompensationHint //! - Procedure body parsing with transaction control statements -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::DocumentOp; use nodedb::control::planner::procedural::ast::*; use nodedb::control::planner::procedural::executor::transaction::ProcedureTransactionCtx; use nodedb::control::planner::procedural::parse_block; use nodedb::types::{DatabaseId, TenantId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; +use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; use nodedb_types::sync::compensation::CompensationHint; use nodedb_types::sync::wire::DeltaRejectMsg; @@ -73,7 +73,7 @@ fn tx_ctx_commit_yields_independent_tasks() { let mut ctx = ProcedureTransactionCtx::new(); // Simulate two DML statements in a procedure body. - ctx.buffer_task(nodedb::control::planner::physical::PhysicalTask { + ctx.buffer_task(PhysicalTask { tenant_id: TenantId::new(1), vshard_id: VShardId::new(0), database_id: DatabaseId::DEFAULT, @@ -84,9 +84,9 @@ fn tx_ctx_commit_yields_independent_tasks() { surrogate: nodedb_types::Surrogate::ZERO, pk_bytes: Vec::new(), }), - post_set_op: nodedb::control::planner::physical::PostSetOp::None, + post_set_op: PostSetOp::None, }); - ctx.buffer_task(nodedb::control::planner::physical::PhysicalTask { + ctx.buffer_task(PhysicalTask { tenant_id: TenantId::new(1), vshard_id: VShardId::new(0), database_id: DatabaseId::DEFAULT, @@ -97,7 +97,7 @@ fn tx_ctx_commit_yields_independent_tasks() { pk_bytes: Vec::new(), returning: None, }), - post_set_op: nodedb::control::planner::physical::PostSetOp::None, + post_set_op: PostSetOp::None, }); // COMMIT flushes both tasks. @@ -126,7 +126,7 @@ fn procedure_can_target_multiple_vshards() { let mut ctx = ProcedureTransactionCtx::new(); // Task on vshard 0 - ctx.buffer_task(nodedb::control::planner::physical::PhysicalTask { + ctx.buffer_task(PhysicalTask { tenant_id: TenantId::new(1), vshard_id: VShardId::new(0), database_id: DatabaseId::DEFAULT, @@ -137,10 +137,10 @@ fn procedure_can_target_multiple_vshards() { surrogate: nodedb_types::Surrogate::ZERO, pk_bytes: Vec::new(), }), - post_set_op: nodedb::control::planner::physical::PostSetOp::None, + post_set_op: PostSetOp::None, }); // Task on vshard 1 (different shard) - ctx.buffer_task(nodedb::control::planner::physical::PhysicalTask { + ctx.buffer_task(PhysicalTask { tenant_id: TenantId::new(1), vshard_id: VShardId::new(1), database_id: DatabaseId::DEFAULT, @@ -151,7 +151,7 @@ fn procedure_can_target_multiple_vshards() { surrogate: nodedb_types::Surrogate::ZERO, pk_bytes: Vec::new(), }), - post_set_op: nodedb::control::planner::physical::PostSetOp::None, + post_set_op: PostSetOp::None, }); let tasks = ctx.take_buffered_tasks(); diff --git a/nodedb-cluster-tests/tests/cluster_triggers.rs b/nodedb-cluster-tests/tests/cluster_triggers.rs index 33a6d9352..3d1e55efc 100644 --- a/nodedb-cluster-tests/tests/cluster_triggers.rs +++ b/nodedb-cluster-tests/tests/cluster_triggers.rs @@ -7,13 +7,12 @@ //! - Trigger definition registry behavior //! - EventSource exhaustive coverage for trigger dispatch decisions -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::DocumentOp; use nodedb::control::trigger::TriggerRegistry; use nodedb::control::trigger::registry::DmlEvent; use nodedb::event::cross_shard::types::CrossShardWriteRequest; use nodedb::event::types::{EventSource, RowId, WriteEvent, WriteOp}; use nodedb::types::{Lsn, TenantId, VShardId}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; use std::sync::Arc; // --------------------------------------------------------------------------- diff --git a/nodedb-cluster-tests/tests/gateway_execute.rs b/nodedb-cluster-tests/tests/gateway_execute.rs index fbcc4c29e..af85dfafd 100644 --- a/nodedb-cluster-tests/tests/gateway_execute.rs +++ b/nodedb-cluster-tests/tests/gateway_execute.rs @@ -16,13 +16,13 @@ mod common; use std::sync::Arc; use std::time::Duration; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::core::QueryContext; use nodedb::control::gateway::plan_cache::PlanCacheKey; use nodedb::control::gateway::plan_cache::{hash_placeholder_types, hash_sql}; use nodedb::control::gateway::version_set::GatewayVersionSet; use nodedb::control::gateway::{Gateway, PlanCache}; use nodedb::types::TenantId; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::TestClusterNode; diff --git a/nodedb-cluster-tests/tests/http_gateway_migration.rs b/nodedb-cluster-tests/tests/http_gateway_migration.rs index 9d0ce5497..0bcaa8a81 100644 --- a/nodedb-cluster-tests/tests/http_gateway_migration.rs +++ b/nodedb-cluster-tests/tests/http_gateway_migration.rs @@ -17,11 +17,11 @@ use std::sync::Arc; use std::time::Duration; use nodedb::Error; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::Gateway; use nodedb::control::gateway::GatewayErrorMap; use nodedb::control::gateway::core::QueryContext; use nodedb::types::TenantId; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::{TestCluster, TestClusterNode}; diff --git a/nodedb-cluster-tests/tests/ilp_gateway_migration.rs b/nodedb-cluster-tests/tests/ilp_gateway_migration.rs index 16a25c098..e7250a5f4 100644 --- a/nodedb-cluster-tests/tests/ilp_gateway_migration.rs +++ b/nodedb-cluster-tests/tests/ilp_gateway_migration.rs @@ -15,11 +15,11 @@ use std::sync::Arc; use std::time::Duration; use nodedb::Error; -use nodedb::bridge::physical_plan::{PhysicalPlan, TimeseriesOp}; use nodedb::control::gateway::Gateway; use nodedb::control::gateway::GatewayErrorMap; use nodedb::control::gateway::core::QueryContext; use nodedb::types::{RequestId, TenantId, VShardId}; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; use common::cluster_harness::{TestCluster, TestClusterNode}; diff --git a/nodedb-cluster-tests/tests/listeners_gateway_smoke.rs b/nodedb-cluster-tests/tests/listeners_gateway_smoke.rs index b885a5851..1870daed4 100644 --- a/nodedb-cluster-tests/tests/listeners_gateway_smoke.rs +++ b/nodedb-cluster-tests/tests/listeners_gateway_smoke.rs @@ -22,12 +22,12 @@ mod common; use std::sync::Arc; use std::time::Duration; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::Gateway; use nodedb::control::gateway::core::QueryContext; use nodedb::control::gateway::plan_cache::{PlanCacheKey, hash_placeholder_types, hash_sql}; use nodedb::control::gateway::version_set::GatewayVersionSet; use nodedb::types::TenantId; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::TestClusterNode; diff --git a/nodedb-cluster-tests/tests/listeners_typed_not_leader.rs b/nodedb-cluster-tests/tests/listeners_typed_not_leader.rs index 8ab55b78b..44811609b 100644 --- a/nodedb-cluster-tests/tests/listeners_typed_not_leader.rs +++ b/nodedb-cluster-tests/tests/listeners_typed_not_leader.rs @@ -61,10 +61,10 @@ use std::sync::Arc; use std::time::Duration; use nodedb::Error; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::GatewayErrorMap; use nodedb::control::gateway::core::QueryContext; use nodedb::types::{TenantId, VShardId}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::TestClusterNode; diff --git a/nodedb-cluster-tests/tests/native_gateway_migration.rs b/nodedb-cluster-tests/tests/native_gateway_migration.rs index b533e3a38..59a3b90fa 100644 --- a/nodedb-cluster-tests/tests/native_gateway_migration.rs +++ b/nodedb-cluster-tests/tests/native_gateway_migration.rs @@ -16,11 +16,11 @@ use std::sync::Arc; use std::time::Duration; use nodedb::Error; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::Gateway; use nodedb::control::gateway::GatewayErrorMap; use nodedb::control::gateway::core::QueryContext; use nodedb::types::{RequestId, TenantId, VShardId}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::{TestCluster, TestClusterNode}; diff --git a/nodedb-cluster-tests/tests/pgwire_gateway_migration.rs b/nodedb-cluster-tests/tests/pgwire_gateway_migration.rs index dda2fa8f5..c76deda8f 100644 --- a/nodedb-cluster-tests/tests/pgwire_gateway_migration.rs +++ b/nodedb-cluster-tests/tests/pgwire_gateway_migration.rs @@ -20,11 +20,11 @@ mod common; use std::sync::Arc; use std::time::Duration; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::Gateway; use nodedb::control::gateway::core::QueryContext; use nodedb::control::gateway::version_set::GatewayVersionSet; use nodedb::types::TenantId; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::{TestCluster, TestClusterNode}; diff --git a/nodedb-cluster-tests/tests/resp_gateway_migration.rs b/nodedb-cluster-tests/tests/resp_gateway_migration.rs index 5c9f3ab16..171b21dbb 100644 --- a/nodedb-cluster-tests/tests/resp_gateway_migration.rs +++ b/nodedb-cluster-tests/tests/resp_gateway_migration.rs @@ -13,11 +13,11 @@ use std::sync::Arc; use std::time::Duration; use nodedb::Error; -use nodedb::bridge::physical_plan::{KvOp, PhysicalPlan}; use nodedb::control::gateway::Gateway; use nodedb::control::gateway::GatewayErrorMap; use nodedb::control::gateway::core::QueryContext; use nodedb::types::{RequestId, TenantId, VShardId}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use common::cluster_harness::{TestCluster, TestClusterNode}; diff --git a/nodedb-test-support/Cargo.toml b/nodedb-test-support/Cargo.toml index 6e64f7255..0c3588ec7 100644 --- a/nodedb-test-support/Cargo.toml +++ b/nodedb-test-support/Cargo.toml @@ -14,6 +14,7 @@ path = "src/lib.rs" # Production crates this harness drives. nodedb = { workspace = true } nodedb-array = { workspace = true } +nodedb-physical = { workspace = true } nodedb-bridge = { workspace = true } nodedb-cluster = { workspace = true } nodedb-mem = { workspace = true } diff --git a/nodedb-test-support/src/tx_batch_helpers.rs b/nodedb-test-support/src/tx_batch_helpers.rs index 0a1c5746f..9552e5a0b 100644 --- a/nodedb-test-support/src/tx_batch_helpers.rs +++ b/nodedb-test-support/src/tx_batch_helpers.rs @@ -7,14 +7,14 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use nodedb::bridge::physical_plan::{ - AggregateSpec, ColumnarInsertIntent, ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, QueryOp, - TimeseriesOp, VectorOp, -}; +use nodedb::bridge::envelope::{Priority, Request, Status}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{ + AggregateSpec, ColumnarInsertIntent, ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, + PhysicalPlan, QueryOp, TimeseriesOp, VectorOp, +}; use nodedb_types::OrdinalClock; // ── Core setup ────────────────────────────────────────────────────────────── diff --git a/nodedb/tests/calvin_determinism_contract.rs b/nodedb/tests/calvin_determinism_contract.rs index 1b6e2720b..441071b37 100644 --- a/nodedb/tests/calvin_determinism_contract.rs +++ b/nodedb/tests/calvin_determinism_contract.rs @@ -33,13 +33,14 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request}; -use nodedb::bridge::physical_plan::{ - ColumnarInsertIntent, ColumnarOp, CrdtOp, DocumentOp, KvOp, VectorOp, -}; +use nodedb::bridge::envelope::{Priority, Request}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{ + ColumnarInsertIntent, ColumnarOp, CrdtOp, DocumentOp, GraphOp, KvOp, PhysicalPlan, + TimeseriesOp, VectorOp, +}; use nodedb_types::OrdinalClock; // ── Harness ───────────────────────────────────────────────────────────────── @@ -338,7 +339,7 @@ fn kv_with_ttl_byte_identical() { fn graph_edge_put_byte_identical() { let ops: Vec = (0..100) .map(|i| { - PhysicalPlan::Graph(nodedb::bridge::physical_plan::GraphOp::EdgePut { + PhysicalPlan::Graph(GraphOp::EdgePut { collection: "graph_coll".into(), src_id: format!("node-{i}"), label: "REL".into(), @@ -364,7 +365,7 @@ fn timeseries_bitemporal_byte_identical() { let ilp = "sensors,loc=a temp=22.5 1000000000\n"; let ops: Vec = (0..100) .map(|_| { - PhysicalPlan::Timeseries(nodedb::bridge::physical_plan::TimeseriesOp::Ingest { + PhysicalPlan::Timeseries(TimeseriesOp::Ingest { collection: "ts_bt".into(), payload: ilp.as_bytes().to_vec(), format: "ilp".into(), diff --git a/nodedb/tests/calvin_executor_apply.rs b/nodedb/tests/calvin_executor_apply.rs index 295bbcac0..375193b23 100644 --- a/nodedb/tests/calvin_executor_apply.rs +++ b/nodedb/tests/calvin_executor_apply.rs @@ -12,11 +12,11 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Priority, Request, Status}; -use nodedb::bridge::physical_plan::{KvOp, MetaOp, VectorOp}; +use nodedb::bridge::envelope::{ErrorCode, Priority, Request, Status}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{KvOp, MetaOp, PhysicalPlan, VectorOp}; use nodedb_types::OrdinalClock; // ── Helpers ───────────────────────────────────────────────────────────────── diff --git a/nodedb/tests/calvin_executor_dependent_read.rs b/nodedb/tests/calvin_executor_dependent_read.rs index cfced2900..d97dd5478 100644 --- a/nodedb/tests/calvin_executor_dependent_read.rs +++ b/nodedb/tests/calvin_executor_dependent_read.rs @@ -8,10 +8,10 @@ use std::collections::BTreeMap; use std::time::Duration; -use nodedb::bridge::physical_plan::meta::PassiveReadKeyId; use nodedb::control::cluster::calvin::scheduler::driver::barrier::{ PendingDependentBarrier, ReadResultEvent, }; +use nodedb_physical::physical_plan::meta::PassiveReadKeyId; use nodedb_types::{TenantId, Value}; use nodedb::control::cluster::calvin::scheduler::lock_manager::LockKey; diff --git a/nodedb/tests/calvin_executor_panic_recovery.rs b/nodedb/tests/calvin_executor_panic_recovery.rs index ea9fb4330..28de4dce3 100644 --- a/nodedb/tests/calvin_executor_panic_recovery.rs +++ b/nodedb/tests/calvin_executor_panic_recovery.rs @@ -42,12 +42,12 @@ mod common; use common::tx_batch_helpers::*; #[cfg(feature = "failpoints")] -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -#[cfg(feature = "failpoints")] -use nodedb::bridge::physical_plan::{KvOp, MetaOp}; +use nodedb::bridge::envelope::{ErrorCode, Status}; #[cfg(feature = "failpoints")] use nodedb::fail_point::{FailAction, FailGuard}; #[cfg(feature = "failpoints")] +use nodedb_physical::physical_plan::{KvOp, MetaOp, PhysicalPlan}; +#[cfg(feature = "failpoints")] use nodedb_types::TenantId as NodedbTenantId; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -326,7 +326,8 @@ fn calvin_static_replay_sees_only_committed_data() { // Commit a reference write before the panic batch. { use nodedb::bridge::dispatch::BridgeRequest; - use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request}; + use nodedb::bridge::envelope::{Priority, Request}; + use nodedb_physical::physical_plan::PhysicalPlan; use std::time::{Duration, Instant}; let make_req = |plan: PhysicalPlan| Request { @@ -409,7 +410,8 @@ fn calvin_static_replay_sees_only_committed_data() { }; use nodedb::bridge::dispatch::BridgeRequest; - use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request}; + use nodedb::bridge::envelope::{Priority, Request}; + use nodedb_physical::physical_plan::PhysicalPlan; use std::time::{Duration, Instant}; let make_req2 = |plan: PhysicalPlan| Request { diff --git a/nodedb/tests/calvin_executor_static_multishard.rs b/nodedb/tests/calvin_executor_static_multishard.rs index efd29bb9b..7e75903ba 100644 --- a/nodedb/tests/calvin_executor_static_multishard.rs +++ b/nodedb/tests/calvin_executor_static_multishard.rs @@ -6,9 +6,9 @@ //! End-to-end 3-replica coverage lives in //! `nodedb-cluster/tests/calvin_3node_normal.rs`. -use nodedb::bridge::physical_plan::PhysicalPlan; -use nodedb::bridge::physical_plan::meta::MetaOp; -use nodedb::bridge::physical_plan::wire as plan_wire; +use nodedb_physical::physical_plan::PhysicalPlan; +use nodedb_physical::physical_plan::meta::MetaOp; +use nodedb_physical::physical_plan::wire as plan_wire; use nodedb_types::TenantId; #[test] diff --git a/nodedb/tests/cross_engine_bitmap_currency.rs b/nodedb/tests/cross_engine_bitmap_currency.rs index ec9dcf1c4..6a7b4a02c 100644 --- a/nodedb/tests/cross_engine_bitmap_currency.rs +++ b/nodedb/tests/cross_engine_bitmap_currency.rs @@ -22,14 +22,14 @@ use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use nodedb::bridge::physical_plan::{ - ColumnarInsertIntent, ColumnarOp, DocumentOp, QueryOp, VectorOp, -}; +use nodedb::bridge::envelope::{Priority, Request, Status}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::data::executor::response_codec::decode_payload_to_json; use nodedb::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{ + ColumnarInsertIntent, ColumnarOp, DocumentOp, PhysicalPlan, QueryOp, VectorOp, +}; use nodedb_types::vector_distance::DistanceMetric; use nodedb_types::{Surrogate, SurrogateBitmap}; diff --git a/nodedb/tests/cross_engine_three_way_fts_vector_doc.rs b/nodedb/tests/cross_engine_three_way_fts_vector_doc.rs index b539fdf82..dace83c79 100644 --- a/nodedb/tests/cross_engine_three_way_fts_vector_doc.rs +++ b/nodedb/tests/cross_engine_three_way_fts_vector_doc.rs @@ -27,12 +27,12 @@ use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, TextOp, VectorOp}; +use nodedb::bridge::envelope::{Priority, Request, Status}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::data::executor::response_codec::decode_payload_to_json; use nodedb::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan, TextOp, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; use nodedb_types::{Surrogate, SurrogateBitmap}; diff --git a/nodedb/tests/executor_tests/helpers.rs b/nodedb/tests/executor_tests/helpers.rs index 1e8d7334e..5e7044202 100644 --- a/nodedb/tests/executor_tests/helpers.rs +++ b/nodedb/tests/executor_tests/helpers.rs @@ -6,10 +6,11 @@ use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; +use nodedb::bridge::envelope::{Priority, Request, Status}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::types::*; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::PhysicalPlan; /// Bundles the core + bridge channels that every test helper needs. /// Eliminates repeated `(core, tx, rx)` triple parameters. diff --git a/nodedb/tests/executor_tests/test_aggregate_aliases.rs b/nodedb/tests/executor_tests/test_aggregate_aliases.rs index c588ba99a..bfc940dc6 100644 --- a/nodedb/tests/executor_tests/test_aggregate_aliases.rs +++ b/nodedb/tests/executor_tests/test_aggregate_aliases.rs @@ -1,9 +1,8 @@ // SPDX-License-Identifier: BUSL-1.1 use crate::helpers::{make_ctx, payload_value, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{AggregateSpec, DocumentOp, QueryOp}; use nodedb::bridge::scan_filter::{FilterOp, ScanFilter}; +use nodedb_physical::physical_plan::{AggregateSpec, DocumentOp, PhysicalPlan, QueryOp}; #[test] fn aggregate_output_uses_user_alias_but_having_reads_canonical_key() { diff --git a/nodedb/tests/executor_tests/test_array_ops.rs b/nodedb/tests/executor_tests/test_array_ops.rs index 09b40a454..f6ea92855 100644 --- a/nodedb/tests/executor_tests/test_array_ops.rs +++ b/nodedb/tests/executor_tests/test_array_ops.rs @@ -2,9 +2,8 @@ //! Integration tests for array operators and array aggregate functions. -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, QueryOp}; use nodedb::bridge::scan_filter::ScanFilter; +use nodedb_physical::physical_plan::{AggregateSpec, DocumentOp, PhysicalPlan, QueryOp}; use crate::helpers::*; @@ -203,7 +202,7 @@ fn array_agg_aggregate() { PhysicalPlan::Query(QueryOp::Aggregate { collection: "products".into(), group_by: vec!["brand".into()], - aggregates: vec![nodedb::bridge::physical_plan::AggregateSpec { + aggregates: vec![AggregateSpec { function: "array_agg".into(), alias: "array_agg_color".into(), user_alias: None, diff --git a/nodedb/tests/executor_tests/test_columnar_aggregate.rs b/nodedb/tests/executor_tests/test_columnar_aggregate.rs index 6de128602..a84f09457 100644 --- a/nodedb/tests/executor_tests/test_columnar_aggregate.rs +++ b/nodedb/tests/executor_tests/test_columnar_aggregate.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: BUSL-1.1 use crate::helpers::{make_ctx, payload_value, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{AggregateSpec, ColumnarInsertIntent, ColumnarOp, QueryOp}; use nodedb::bridge::scan_filter::{FilterOp, ScanFilter}; +use nodedb_physical::physical_plan::{ + AggregateSpec, ColumnarInsertIntent, ColumnarOp, PhysicalPlan, QueryOp, +}; #[test] fn aggregate_count_reads_plain_columnar_engine_rows() { diff --git a/nodedb/tests/executor_tests/test_conditional_update.rs b/nodedb/tests/executor_tests/test_conditional_update.rs index 9f2b3a927..6c051ab58 100644 --- a/nodedb/tests/executor_tests/test_conditional_update.rs +++ b/nodedb/tests/executor_tests/test_conditional_update.rs @@ -9,9 +9,9 @@ //! - TransactionBatch does not auto-abort on 0-row conditional update //! - PointUpdate returns affected count -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, MetaOp}; +use nodedb::bridge::envelope::Status; use nodedb::bridge::scan_filter::ScanFilter; +use nodedb_physical::physical_plan::{DocumentOp, MetaOp, PhysicalPlan, UpdateValue}; use crate::helpers::*; @@ -102,9 +102,7 @@ fn bulk_update_returns_affected_count() { let filter_bytes = zerompk::to_msgpack_vec(&filters).unwrap(); let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!(99)).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!(99)).unwrap()), )]; let payload = send_ok( @@ -144,7 +142,7 @@ fn conditional_decrement_stops_at_zero() { let new_stock = current_stock.saturating_sub(1); let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( + UpdateValue::Literal( nodedb_types::json_to_msgpack(&serde_json::json!(new_stock)).unwrap(), ), )]; @@ -190,9 +188,7 @@ fn bulk_update_zero_match_returns_zero_affected() { let filter_bytes = zerompk::to_msgpack_vec(&filters).unwrap(); let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!(999)).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!(999)).unwrap()), )]; let payload = send_ok( @@ -223,9 +219,7 @@ fn bulk_update_returning_returns_updated_documents() { let filter_bytes = zerompk::to_msgpack_vec(&filters).unwrap(); let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!(0)).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!(0)).unwrap()), )]; let payload = send_ok( @@ -255,9 +249,7 @@ fn bulk_update_returning_zero_match_returns_affected_zero() { let filter_bytes = zerompk::to_msgpack_vec(&filters).unwrap(); let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!(999)).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!(999)).unwrap()), )]; let payload = send_ok( @@ -285,9 +277,7 @@ fn point_update_returns_affected_count() { let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!(5)).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!(5)).unwrap()), )]; let payload = send_ok( @@ -317,9 +307,7 @@ fn point_update_returning_returns_updated_document() { let updates = vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!(7)).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!(7)).unwrap()), )]; let payload = send_ok( @@ -374,7 +362,7 @@ fn transaction_batch_does_not_abort_on_zero_row_update() { filters: filters_match, updates: vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( + UpdateValue::Literal( nodedb_types::json_to_msgpack(&serde_json::json!(0)).unwrap(), ), )], @@ -386,7 +374,7 @@ fn transaction_batch_does_not_abort_on_zero_row_update() { filters: filters_nomatch, updates: vec![( "stock".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( + UpdateValue::Literal( nodedb_types::json_to_msgpack(&serde_json::json!(999)).unwrap(), ), )], diff --git a/nodedb/tests/executor_tests/test_cross_engine_validation.rs b/nodedb/tests/executor_tests/test_cross_engine_validation.rs index 04cb436f5..3ea69375b 100644 --- a/nodedb/tests/executor_tests/test_cross_engine_validation.rs +++ b/nodedb/tests/executor_tests/test_cross_engine_validation.rs @@ -6,9 +6,9 @@ //! the system is ready to move from Phase 2 to Phase 3. use nodedb::bridge::dispatch::BridgeRequest; -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, GraphOp, TextOp, VectorOp}; +use nodedb::bridge::envelope::Status; use nodedb::engine::graph::edge_store::Direction; +use nodedb_physical::physical_plan::{DocumentOp, GraphOp, PhysicalPlan, TextOp, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_cross_type_join/basic_scans.rs b/nodedb/tests/executor_tests/test_cross_type_join/basic_scans.rs index 7f552a645..a5beefd14 100644 --- a/nodedb/tests/executor_tests/test_cross_type_join/basic_scans.rs +++ b/nodedb/tests/executor_tests/test_cross_type_join/basic_scans.rs @@ -3,10 +3,11 @@ //! Basic scan and roundtrip tests: KV, document, merge, and broadcast merge. use crate::helpers::{make_ctx, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, EnforcementOptions, KvOp, StorageMode}; use nodedb::data::executor::handlers::join; use nodedb::data::executor::response_codec; +use nodedb_physical::physical_plan::{ + DocumentOp, EnforcementOptions, KvOp, PhysicalPlan, StorageMode, +}; use nodedb_types::columnar::{ColumnDef, ColumnType, StrictSchema}; pub(super) fn build_msgpack_map(fields: &[(&str, &str)]) -> Vec { diff --git a/nodedb/tests/executor_tests/test_cross_type_join/cross_semi_joins.rs b/nodedb/tests/executor_tests/test_cross_type_join/cross_semi_joins.rs index ef132e96b..c7f3557e3 100644 --- a/nodedb/tests/executor_tests/test_cross_type_join/cross_semi_joins.rs +++ b/nodedb/tests/executor_tests/test_cross_type_join/cross_semi_joins.rs @@ -3,10 +3,11 @@ //! Cross-join and semi-join tests with inline scalar aggregate subqueries. use crate::helpers::{make_ctx, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{AggregateSpec, DocumentOp, JoinProjection, QueryOp}; use nodedb::bridge::scan_filter::{FilterOp, ScanFilter}; use nodedb::data::executor::response_codec; +use nodedb_physical::physical_plan::{ + AggregateSpec, DocumentOp, JoinProjection, PhysicalPlan, QueryOp, +}; #[test] fn cross_join_uses_inline_right_scalar_aggregate_for_post_filter() { diff --git a/nodedb/tests/executor_tests/test_cross_type_join/inline_hash_join.rs b/nodedb/tests/executor_tests/test_cross_type_join/inline_hash_join.rs index 9f3ddc5e7..cce209f80 100644 --- a/nodedb/tests/executor_tests/test_cross_type_join/inline_hash_join.rs +++ b/nodedb/tests/executor_tests/test_cross_type_join/inline_hash_join.rs @@ -3,9 +3,8 @@ //! Inline hash join test: two pre-computed payloads joined with qualified left-side keys. use crate::helpers::{make_ctx, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, QueryOp}; use nodedb::data::executor::response_codec; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan, QueryOp}; use super::basic_scans::build_msgpack_map; diff --git a/nodedb/tests/executor_tests/test_cross_type_join/multi_core_joins.rs b/nodedb/tests/executor_tests/test_cross_type_join/multi_core_joins.rs index 664a55491..93a664510 100644 --- a/nodedb/tests/executor_tests/test_cross_type_join/multi_core_joins.rs +++ b/nodedb/tests/executor_tests/test_cross_type_join/multi_core_joins.rs @@ -3,9 +3,8 @@ //! Multi-core broadcast join and inline hash join tests. use crate::helpers::{make_ctx_with_id, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, KvOp, QueryOp}; use nodedb::data::executor::response_codec; +use nodedb_physical::physical_plan::{DocumentOp, KvOp, PhysicalPlan, QueryOp}; use super::basic_scans::build_msgpack_map; diff --git a/nodedb/tests/executor_tests/test_cross_type_join/single_core_joins.rs b/nodedb/tests/executor_tests/test_cross_type_join/single_core_joins.rs index 7c10f7cb8..ac97658b1 100644 --- a/nodedb/tests/executor_tests/test_cross_type_join/single_core_joins.rs +++ b/nodedb/tests/executor_tests/test_cross_type_join/single_core_joins.rs @@ -3,10 +3,9 @@ //! Single-core hash join and self-join tests. use crate::helpers::{make_ctx, send_ok}; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, JoinProjection, KvOp, QueryOp}; use nodedb::bridge::scan_filter::{FilterOp, ScanFilter}; use nodedb::data::executor::response_codec; +use nodedb_physical::physical_plan::{DocumentOp, JoinProjection, KvOp, PhysicalPlan, QueryOp}; use super::basic_scans::build_msgpack_map; diff --git a/nodedb/tests/executor_tests/test_document.rs b/nodedb/tests/executor_tests/test_document.rs index 0dbc3369d..752a71722 100644 --- a/nodedb/tests/executor_tests/test_document.rs +++ b/nodedb/tests/executor_tests/test_document.rs @@ -2,8 +2,8 @@ //! Integration tests for document operations (PointGet/Put/Delete, RangeScan, CRDT). -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{CrdtOp, DocumentOp}; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_facet.rs b/nodedb/tests/executor_tests/test_facet.rs index 16e656953..f68c96ff0 100644 --- a/nodedb/tests/executor_tests/test_facet.rs +++ b/nodedb/tests/executor_tests/test_facet.rs @@ -9,9 +9,8 @@ //! - Zero-match filter returns empty facets //! - Limit per facet (top-N truncation) -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, QueryOp}; use nodedb::bridge::scan_filter::ScanFilter; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan, QueryOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_generated_columns.rs b/nodedb/tests/executor_tests/test_generated_columns.rs index adc7f84b3..8a642d9d6 100644 --- a/nodedb/tests/executor_tests/test_generated_columns.rs +++ b/nodedb/tests/executor_tests/test_generated_columns.rs @@ -9,8 +9,9 @@ //! - Chained generated columns (A depends on B) evaluate in correct order //! - Generated columns are readable via PointGet -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{DocumentOp, EnforcementOptions, GeneratedColumnSpec}; +use nodedb_physical::physical_plan::{ + DocumentOp, EnforcementOptions, GeneratedColumnSpec, PhysicalPlan, UpdateValue, +}; use crate::helpers::*; @@ -199,7 +200,7 @@ fn update_recomputes_generated_column() { document_id: "p1".into(), updates: vec![( "price".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( + UpdateValue::Literal( nodedb_types::json_to_msgpack(&serde_json::json!(200.0)).unwrap(), ), )], @@ -255,7 +256,7 @@ fn update_generated_column_directly_rejected() { document_id: "p1".into(), updates: vec![( "price_with_tax".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( + UpdateValue::Literal( nodedb_types::json_to_msgpack(&serde_json::json!(999.0)).unwrap(), ), )], diff --git a/nodedb/tests/executor_tests/test_graph.rs b/nodedb/tests/executor_tests/test_graph.rs index 57bc8141d..d9f8a8038 100644 --- a/nodedb/tests/executor_tests/test_graph.rs +++ b/nodedb/tests/executor_tests/test_graph.rs @@ -3,9 +3,8 @@ //! Integration tests for graph engine operations. use nodedb::bridge::dispatch::BridgeRequest; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::{GraphOp, VectorOp}; use nodedb::engine::graph::edge_store::Direction; +use nodedb_physical::physical_plan::{GraphOp, PhysicalPlan, VectorOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_graph_bounds.rs b/nodedb/tests/executor_tests/test_graph_bounds.rs index c99438e6a..852463b3c 100644 --- a/nodedb/tests/executor_tests/test_graph_bounds.rs +++ b/nodedb/tests/executor_tests/test_graph_bounds.rs @@ -5,9 +5,9 @@ //! Verifies that BFS, shortest path, and subgraph materialization //! respect bounded depth and fan-out limits under adversarial queries. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan}; -use nodedb::bridge::physical_plan::GraphOp; +use nodedb::bridge::envelope::ErrorCode; use nodedb::engine::graph::edge_store::Direction; +use nodedb_physical::physical_plan::{GraphOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_group_by_alias.rs b/nodedb/tests/executor_tests/test_group_by_alias.rs index 6ce6f7530..020121a38 100644 --- a/nodedb/tests/executor_tests/test_group_by_alias.rs +++ b/nodedb/tests/executor_tests/test_group_by_alias.rs @@ -6,9 +6,8 @@ //! and positional (1-based) references. Tests the full path from SQL string → //! nodedb_sql planner → sql_plan_convert → PhysicalPlan → Data Plane → response. -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::TimeseriesOp; use nodedb::control::planner::sql_plan_convert::{ConvertContext, convert}; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; use nodedb_sql::types::{CollectionInfo, EngineType, SqlCatalog, SqlPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_kv.rs b/nodedb/tests/executor_tests/test_kv.rs index 7b7575918..91474b6be 100644 --- a/nodedb/tests/executor_tests/test_kv.rs +++ b/nodedb/tests/executor_tests/test_kv.rs @@ -2,8 +2,8 @@ //! Integration tests for KV engine operations via the SPSC bridge. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::KvOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_kv_advanced.rs b/nodedb/tests/executor_tests/test_kv_advanced.rs index 5dd12c3eb..bffeb0218 100644 --- a/nodedb/tests/executor_tests/test_kv_advanced.rs +++ b/nodedb/tests/executor_tests/test_kv_advanced.rs @@ -3,8 +3,8 @@ //! Advanced KV integration tests: protocol simulation, cross-engine, //! TTL+CDC, CRDT sync, secondary index stress. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::KvOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use crate::helpers::*; @@ -165,7 +165,7 @@ fn kv_protocol_command_sequence() { #[test] fn kv_and_vector_coexist() { - use nodedb::bridge::physical_plan::VectorOp; + use nodedb_physical::physical_plan::VectorOp; use nodedb_types::vector_distance::DistanceMetric; let (mut core, mut tx, mut rx, _dir) = make_core(); diff --git a/nodedb/tests/executor_tests/test_ollp_verification.rs b/nodedb/tests/executor_tests/test_ollp_verification.rs index 5965cdecc..53174aaf7 100644 --- a/nodedb/tests/executor_tests/test_ollp_verification.rs +++ b/nodedb/tests/executor_tests/test_ollp_verification.rs @@ -27,9 +27,9 @@ //! predicted surrogates but before the bulk operation. The executor sees the //! mismatch because it scans live storage at admission time. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::DocumentOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; use nodedb::bridge::scan_filter::ScanFilter; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan, UpdateValue}; use crate::helpers::*; @@ -84,9 +84,7 @@ fn insert_active(ctx: &mut TestCtx, id: &str) { fn bulk_update_plan(predicted: Option>) -> PhysicalPlan { let updates = vec![( "name".to_string(), - nodedb::bridge::physical_plan::UpdateValue::Literal( - nodedb_types::json_to_msgpack(&serde_json::json!("updated")).unwrap(), - ), + UpdateValue::Literal(nodedb_types::json_to_msgpack(&serde_json::json!("updated")).unwrap()), )]; PhysicalPlan::Document(DocumentOp::BulkUpdate { collection: COLLECTION.into(), diff --git a/nodedb/tests/executor_tests/test_security_and_isolation.rs b/nodedb/tests/executor_tests/test_security_and_isolation.rs index e9743a2c3..9b6b2a69a 100644 --- a/nodedb/tests/executor_tests/test_security_and_isolation.rs +++ b/nodedb/tests/executor_tests/test_security_and_isolation.rs @@ -7,9 +7,9 @@ //! 3. WAL replay: deterministic traces //! 4. Mixed-engine isolation: protected-tier not evicted under budget -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, GraphOp, VectorOp}; +use nodedb::bridge::envelope::Status; use nodedb::control::security::audit::NoopAuditEmitter; +use nodedb_physical::physical_plan::{DocumentOp, GraphOp, PhysicalPlan, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; const NOOP: &NoopAuditEmitter = &NoopAuditEmitter; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_fulltext.rs b/nodedb/tests/executor_tests/test_tenant_isolation_fulltext.rs index 44cfad5ed..0c51613bd 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_fulltext.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_fulltext.rs @@ -4,8 +4,8 @@ //! //! Tenant A indexes documents with text. Tenant B searches — must get zero results. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, TextOp}; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan, TextOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_fulltext_negative.rs b/nodedb/tests/executor_tests/test_tenant_isolation_fulltext_negative.rs index fe9e1d225..cad53f38b 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_fulltext_negative.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_fulltext_negative.rs @@ -6,8 +6,8 @@ //! cannot contaminate Tenant A's search results. After Tenant B's inserts, //! Tenant A must see the same result count as before. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, TextOp}; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan, TextOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_graph.rs b/nodedb/tests/executor_tests/test_tenant_isolation_graph.rs index 1c8c4abec..7da4fbbb4 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_graph.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_graph.rs @@ -4,8 +4,8 @@ //! //! Tenant A inserts edges. Tenant B queries neighbors — must get zero results. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::GraphOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{GraphOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_graph_negative.rs b/nodedb/tests/executor_tests/test_tenant_isolation_graph_negative.rs index 9d9819f57..0f46999a5 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_graph_negative.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_graph_negative.rs @@ -6,9 +6,9 @@ //! cannot contaminate Tenant A's neighbor results. After Tenant B's inserts, //! Tenant A's neighbor query must return exactly its own edges. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::GraphOp; +use nodedb::bridge::envelope::Status; use nodedb::engine::graph::edge_store::Direction; +use nodedb_physical::physical_plan::{GraphOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_kv.rs b/nodedb/tests/executor_tests/test_tenant_isolation_kv.rs index b6d0d159f..6ed67f500 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_kv.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_kv.rs @@ -4,8 +4,8 @@ //! //! Tenant A puts a key. Tenant B gets the same key — must get NotFound. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::KvOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_kv_negative.rs b/nodedb/tests/executor_tests/test_tenant_isolation_kv_negative.rs index 7dfb3b474..2b3472f53 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_kv_negative.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_kv_negative.rs @@ -6,8 +6,8 @@ //! overwrite or delete Tenant A's data. After each cross-tenant write //! attempt, Tenant A's key must still return its original value. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::KvOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_sparse.rs b/nodedb/tests/executor_tests/test_tenant_isolation_sparse.rs index be7d69f34..399f74d5a 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_sparse.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_sparse.rs @@ -5,8 +5,8 @@ //! Tenant A inserts a document. Tenant B queries the same collection name //! and document_id — must get NotFound, not Tenant A's data. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::DocumentOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_sparse_negative.rs b/nodedb/tests/executor_tests/test_tenant_isolation_sparse_negative.rs index f7a46b1c7..978f63566 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_sparse_negative.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_sparse_negative.rs @@ -5,8 +5,8 @@ //! Verifies that Tenant B writing to the same collection + document_id as Tenant A //! cannot overwrite or delete Tenant A's document. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::DocumentOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_timeseries.rs b/nodedb/tests/executor_tests/test_tenant_isolation_timeseries.rs index 51ebf0bc2..7cf53fa47 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_timeseries.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_timeseries.rs @@ -4,8 +4,8 @@ //! //! Tenant A ingests metrics. Tenant B scans — must get zero rows. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::TimeseriesOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_timeseries_negative.rs b/nodedb/tests/executor_tests/test_tenant_isolation_timeseries_negative.rs index 80a1e0e4e..79fed1ed8 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_timeseries_negative.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_timeseries_negative.rs @@ -6,8 +6,8 @@ //! cannot contaminate Tenant A's scan results. After Tenant B's ingest, Tenant A //! must see the same row count as before. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::TimeseriesOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_vector.rs b/nodedb/tests/executor_tests/test_tenant_isolation_vector.rs index 9f9c0e365..f078a32f7 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_vector.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_vector.rs @@ -4,8 +4,8 @@ //! //! Tenant A inserts vectors. Tenant B searches — must get zero results. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::VectorOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{PhysicalPlan, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_isolation_vector_negative.rs b/nodedb/tests/executor_tests/test_tenant_isolation_vector_negative.rs index c04be6d59..ff0b740a0 100644 --- a/nodedb/tests/executor_tests/test_tenant_isolation_vector_negative.rs +++ b/nodedb/tests/executor_tests/test_tenant_isolation_vector_negative.rs @@ -6,8 +6,8 @@ //! cannot contaminate Tenant A's search results. After Tenant B's inserts, //! Tenant A's search must return the same count of results it returned before. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::VectorOp; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{PhysicalPlan, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_tenant_purge.rs b/nodedb/tests/executor_tests/test_tenant_purge.rs index 1818605f1..90da78242 100644 --- a/nodedb/tests/executor_tests/test_tenant_purge.rs +++ b/nodedb/tests/executor_tests/test_tenant_purge.rs @@ -5,8 +5,10 @@ //! Creates data across multiple engines as Tenant A, purges, then verifies //! zero remaining data. Tenant B's data must be unaffected. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, GraphOp, KvOp, MetaOp, TimeseriesOp}; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{ + DocumentOp, GraphOp, KvOp, MetaOp, PhysicalPlan, TimeseriesOp, +}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_timeseries.rs b/nodedb/tests/executor_tests/test_timeseries.rs index 38a8307ac..eb0539ae1 100644 --- a/nodedb/tests/executor_tests/test_timeseries.rs +++ b/nodedb/tests/executor_tests/test_timeseries.rs @@ -5,8 +5,7 @@ //! Tests the full path: ILP ingest → memtable → flush → disk partitions → //! query across both sources. -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::TimeseriesOp; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_timeseries_budget.rs b/nodedb/tests/executor_tests/test_timeseries_budget.rs index ca6c1136a..746b47f26 100644 --- a/nodedb/tests/executor_tests/test_timeseries_budget.rs +++ b/nodedb/tests/executor_tests/test_timeseries_budget.rs @@ -33,9 +33,8 @@ use std::collections::HashMap; use std::sync::Arc; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::TimeseriesOp; use nodedb_mem::{EngineId, GovernorConfig, MemoryGovernor}; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_transaction.rs b/nodedb/tests/executor_tests/test_transaction.rs index f15c8d6e1..d5fa3cb89 100644 --- a/nodedb/tests/executor_tests/test_transaction.rs +++ b/nodedb/tests/executor_tests/test_transaction.rs @@ -2,8 +2,8 @@ //! Integration tests for transaction batch execution. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{DocumentOp, GraphOp, MetaOp, VectorOp}; +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{DocumentOp, GraphOp, MetaOp, PhysicalPlan, VectorOp}; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_transaction_matrix.rs b/nodedb/tests/executor_tests/test_transaction_matrix.rs index 0cb03868f..710103ef9 100644 --- a/nodedb/tests/executor_tests/test_transaction_matrix.rs +++ b/nodedb/tests/executor_tests/test_transaction_matrix.rs @@ -15,8 +15,10 @@ //! Adding a new write-trackable engine: add a row to each matrix table here. //! Adding a new engine pair: add one test function following the existing pattern. -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{CrdtOp, DocumentOp, GraphOp, MetaOp, TextOp, VectorOp}; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{ + CrdtOp, DocumentOp, GraphOp, MetaOp, PhysicalPlan, TextOp, VectorOp, +}; use crate::helpers::*; @@ -528,7 +530,7 @@ fn rollback_matrix_fts_side_effect_rolled_back() { #[test] fn rollback_matrix_spatial_not_written_in_tx_path() { - use nodedb::bridge::physical_plan::{SpatialOp, SpatialPredicate}; + use nodedb_physical::physical_plan::{SpatialOp, SpatialPredicate}; use nodedb_types::geometry::Geometry; let (mut core, mut tx, mut rx, _dir) = make_core(); diff --git a/nodedb/tests/executor_tests/test_transaction_matrix_kv.rs b/nodedb/tests/executor_tests/test_transaction_matrix_kv.rs index 2fb2c16eb..2d1c37fe0 100644 --- a/nodedb/tests/executor_tests/test_transaction_matrix_kv.rs +++ b/nodedb/tests/executor_tests/test_transaction_matrix_kv.rs @@ -7,10 +7,10 @@ //! 2. TransactionBatch: valid write (first op) + deterministically failing write (second op). //! 3. Assert: the first write was fully rolled back. -use nodedb::bridge::envelope::{PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::{ - AggregateSpec, ColumnarInsertIntent, ColumnarOp, DocumentOp, KvOp, MetaOp, QueryOp, - TimeseriesOp, +use nodedb::bridge::envelope::Status; +use nodedb_physical::physical_plan::{ + AggregateSpec, ColumnarInsertIntent, ColumnarOp, DocumentOp, KvOp, MetaOp, PhysicalPlan, + QueryOp, TimeseriesOp, }; use crate::helpers::*; diff --git a/nodedb/tests/executor_tests/test_vector.rs b/nodedb/tests/executor_tests/test_vector.rs index 9d37e809e..0f421aabe 100644 --- a/nodedb/tests/executor_tests/test_vector.rs +++ b/nodedb/tests/executor_tests/test_vector.rs @@ -3,8 +3,8 @@ //! Integration tests for vector engine operations. use nodedb::bridge::dispatch::BridgeRequest; -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::VectorOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{PhysicalPlan, VectorOp}; use nodedb_types::vector_distance::DistanceMetric; use crate::helpers::*; diff --git a/nodedb/tests/memory_pressure_transitions.rs b/nodedb/tests/memory_pressure_transitions.rs index 1c601a947..08a05d1eb 100644 --- a/nodedb/tests/memory_pressure_transitions.rs +++ b/nodedb/tests/memory_pressure_transitions.rs @@ -133,10 +133,10 @@ fn critical_pressure_halves_read_depth_and_increments_metric() { #[test] fn critical_check_engine_pressure_increments_metric() { - use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request}; - use nodedb::bridge::physical_plan::VectorOp; + use nodedb::bridge::envelope::{Priority, Request}; use nodedb::data::executor::task::ExecutionTask; use nodedb::types::*; + use nodedb_physical::physical_plan::{PhysicalPlan, VectorOp}; use nodedb_types::{Surrogate, TraceId}; use std::time::{Duration, Instant}; @@ -189,10 +189,10 @@ fn critical_check_engine_pressure_increments_metric() { #[test] fn emergency_pressure_suspends_reads_and_increments_metric() { - use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Priority, Request}; - use nodedb::bridge::physical_plan::VectorOp; + use nodedb::bridge::envelope::{ErrorCode, Priority, Request}; use nodedb::data::executor::task::ExecutionTask; use nodedb::types::*; + use nodedb_physical::physical_plan::{PhysicalPlan, VectorOp}; use nodedb_types::{Surrogate, TraceId}; use std::time::{Duration, Instant}; diff --git a/nodedb/tests/procedure_execution.rs b/nodedb/tests/procedure_execution.rs index 3967ef0f0..31d99384c 100644 --- a/nodedb/tests/procedure_execution.rs +++ b/nodedb/tests/procedure_execution.rs @@ -144,11 +144,11 @@ fn parse_no_exception_block() { // Transaction context: buffer + savepoint + rollback // --------------------------------------------------------------------------- -fn dummy_task(id: &str) -> nodedb::control::planner::physical::PhysicalTask { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; +fn dummy_task(id: &str) -> nodedb_physical::physical_task::PhysicalTask { + use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; + use nodedb_physical::physical_task::PostSetOp; - nodedb::control::planner::physical::PhysicalTask { + nodedb_physical::physical_task::PhysicalTask { tenant_id: nodedb::types::TenantId::new(1), vshard_id: nodedb::types::VShardId::new(0), database_id: nodedb::types::DatabaseId::DEFAULT, @@ -159,7 +159,7 @@ fn dummy_task(id: &str) -> nodedb::control::planner::physical::PhysicalTask { surrogate: nodedb_types::Surrogate::ZERO, pk_bytes: Vec::new(), }), - post_set_op: nodedb::control::planner::physical::PostSetOp::None, + post_set_op: PostSetOp::None, } } diff --git a/nodedb/tests/startup_gate_pgwire.rs b/nodedb/tests/startup_gate_pgwire.rs index c176a956f..8db230f63 100644 --- a/nodedb/tests/startup_gate_pgwire.rs +++ b/nodedb/tests/startup_gate_pgwire.rs @@ -18,13 +18,13 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeResponse, CoreChannelDataSide, Dispatcher}; -use nodedb::bridge::envelope::{Payload, PhysicalPlan, Response, Status}; -use nodedb::bridge::physical_plan::MetaOp; +use nodedb::bridge::envelope::{Payload, Response, Status}; use nodedb::config::auth::AuthMode; use nodedb::control::server::pgwire::listener::PgListener; use nodedb::control::startup::{StartupPhase, StartupSequencer}; use nodedb::control::state::SharedState; use nodedb::types::Lsn; +use nodedb_physical::physical_plan::{MetaOp, PhysicalPlan}; mod common; diff --git a/nodedb/tests/surrogate_round_trip.rs b/nodedb/tests/surrogate_round_trip.rs index 217a5068e..8c4a42e35 100644 --- a/nodedb/tests/surrogate_round_trip.rs +++ b/nodedb/tests/surrogate_round_trip.rs @@ -26,10 +26,7 @@ use std::collections::HashSet; use std::time::{Duration, Instant}; use nodedb::bridge::dispatch::{BridgeRequest, BridgeResponse}; -use nodedb::bridge::envelope::{PhysicalPlan, Priority, Request, Status}; -use nodedb::bridge::physical_plan::{ - ArrayOp, ColumnarInsertIntent, ColumnarOp, DocumentOp, GraphOp, KvOp, VectorOp, -}; +use nodedb::bridge::envelope::{Priority, Request, Status}; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::data::executor::response_codec::decode_payload_to_json; use nodedb::engine::array::wal::ArrayPutCell; @@ -44,6 +41,9 @@ use nodedb_array::types::cell_value::value::CellValue; use nodedb_array::types::coord::value::CoordValue; use nodedb_array::types::domain::{Domain, DomainBound}; use nodedb_bridge::buffer::{Consumer, Producer, RingBuffer}; +use nodedb_physical::physical_plan::{ + ArrayOp, ColumnarInsertIntent, ColumnarOp, DocumentOp, GraphOp, KvOp, PhysicalPlan, VectorOp, +}; use nodedb_types::vector_distance::DistanceMetric; use nodedb_types::{Surrogate, SurrogateBitmap}; diff --git a/nodedb/tests/transaction_batch_cross_engine.rs b/nodedb/tests/transaction_batch_cross_engine.rs index 9f8f04645..864afd065 100644 --- a/nodedb/tests/transaction_batch_cross_engine.rs +++ b/nodedb/tests/transaction_batch_cross_engine.rs @@ -11,8 +11,8 @@ mod common; use common::tx_batch_helpers::*; -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::MetaOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{MetaOp, PhysicalPlan}; // ── Shared helpers ──────────────────────────────────────────────────────────── diff --git a/nodedb/tests/transaction_batch_cross_engine_crash.rs b/nodedb/tests/transaction_batch_cross_engine_crash.rs index 61a904964..817bc7da3 100644 --- a/nodedb/tests/transaction_batch_cross_engine_crash.rs +++ b/nodedb/tests/transaction_batch_cross_engine_crash.rs @@ -17,11 +17,11 @@ use common::tx_batch_helpers::*; #[cfg(feature = "failpoints")] use nodedb::bridge::dispatch::BridgeRequest; #[cfg(feature = "failpoints")] -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -#[cfg(feature = "failpoints")] -use nodedb::bridge::physical_plan::MetaOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; #[cfg(feature = "failpoints")] use nodedb::fail_point::{FailAction, FailGuard}; +#[cfg(feature = "failpoints")] +use nodedb_physical::physical_plan::{MetaOp, PhysicalPlan}; /// Push a TransactionBatch through the bridge, tick once, return the response. /// Panic-from-fail-point is now handled inside the handler — `tick()` never diff --git a/nodedb/tests/transaction_batch_cross_engine_mixed.rs b/nodedb/tests/transaction_batch_cross_engine_mixed.rs index 6318ae2e6..89a9c9b72 100644 --- a/nodedb/tests/transaction_batch_cross_engine_mixed.rs +++ b/nodedb/tests/transaction_batch_cross_engine_mixed.rs @@ -7,8 +7,8 @@ mod common; use common::tx_batch_helpers::*; -use nodedb::bridge::envelope::{ErrorCode, PhysicalPlan, Status}; -use nodedb::bridge::physical_plan::MetaOp; +use nodedb::bridge::envelope::{ErrorCode, Status}; +use nodedb_physical::physical_plan::{MetaOp, PhysicalPlan}; fn seed_vec( core: &mut nodedb::data::executor::core_loop::CoreLoop, diff --git a/nodedb/tests/trigger_execution.rs b/nodedb/tests/trigger_execution.rs index 5d67989d0..ae6e4e58e 100644 --- a/nodedb/tests/trigger_execution.rs +++ b/nodedb/tests/trigger_execution.rs @@ -122,8 +122,8 @@ fn make_definer_trigger(name: &str, collection: &str, owner: &str) -> StoredTrig #[test] fn classify_point_put_as_insert() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let plan = PhysicalPlan::Document(DocumentOp::PointPut { collection: "orders".into(), @@ -140,8 +140,8 @@ fn classify_point_put_as_insert() { #[test] fn classify_point_delete() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let plan = PhysicalPlan::Document(DocumentOp::PointDelete { collection: "orders".into(), @@ -158,8 +158,8 @@ fn classify_point_delete() { #[test] fn classify_point_update() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let plan = PhysicalPlan::Document(DocumentOp::PointUpdate { collection: "users".into(), @@ -176,8 +176,8 @@ fn classify_point_update() { #[test] fn classify_bulk_delete() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let plan = PhysicalPlan::Document(DocumentOp::BulkDelete { collection: "logs".into(), @@ -193,8 +193,8 @@ fn classify_bulk_delete() { #[test] fn classify_scan_returns_none() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let plan = PhysicalPlan::Document(DocumentOp::Scan { collection: "orders".into(), @@ -215,8 +215,8 @@ fn classify_scan_returns_none() { #[test] fn classify_vector_op_returns_none() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::VectorOp; + use nodedb_physical::physical_plan::PhysicalPlan; + use nodedb_physical::physical_plan::VectorOp; let plan = PhysicalPlan::Vector(VectorOp::Insert { collection: "embeddings".into(), @@ -630,8 +630,8 @@ fn triggers_sorted_by_priority_across_timings() { #[test] fn classify_point_put_deserializes_json_value() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let value = serde_json::to_vec(&serde_json::json!({"name": "Alice", "age": 30})).unwrap(); let plan = PhysicalPlan::Document(DocumentOp::PointPut { @@ -655,8 +655,8 @@ fn classify_point_put_deserializes_json_value() { #[test] fn classify_point_put_deserializes_msgpack_value() { - use nodedb::bridge::envelope::PhysicalPlan; - use nodedb::bridge::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::DocumentOp; + use nodedb_physical::physical_plan::PhysicalPlan; let value = nodedb_types::json_to_msgpack(&serde_json::json!({"key": "val"})).unwrap(); let plan = PhysicalPlan::Document(DocumentOp::PointPut { diff --git a/nodedb/tests/wal_catchup.rs b/nodedb/tests/wal_catchup.rs index 1d1656e3c..f06d3b9a9 100644 --- a/nodedb/tests/wal_catchup.rs +++ b/nodedb/tests/wal_catchup.rs @@ -8,12 +8,11 @@ use std::sync::Arc; use std::time::Duration; use nodedb::bridge::dispatch::Dispatcher; -use nodedb::bridge::envelope::PhysicalPlan; -use nodedb::bridge::physical_plan::TimeseriesOp; use nodedb::control::state::SharedState; use nodedb::data::executor::core_loop::CoreLoop; use nodedb::types::*; use nodedb::wal::manager::WalManager; +use nodedb_physical::physical_plan::{PhysicalPlan, TimeseriesOp}; fn ilp_payload(collection: &str, count: usize, start_ts_ns: i64) -> Vec { let mut lines = String::new(); From 6bfb7872cd8d363bb79b712883f986fe795c22f8 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Sun, 17 May 2026 07:38:11 +0800 Subject: [PATCH 04/11] refactor(client): remove monolithic native and remote client files Delete `nodedb-client/src/native/client.rs` (615 lines) and `nodedb-client/src/remote/client.rs` (767 lines). Their responsibilities have been split into focused modules. Minor fixup to `document.rs` and cleanup of an unused import in `response_parse.rs`. --- nodedb-client/src/native/client.rs | 615 ---------------- nodedb-client/src/native/client/document.rs | 2 +- nodedb-client/src/native/response_parse.rs | 3 - nodedb-client/src/remote/client.rs | 767 -------------------- 4 files changed, 1 insertion(+), 1386 deletions(-) delete mode 100644 nodedb-client/src/native/client.rs delete mode 100644 nodedb-client/src/remote/client.rs diff --git a/nodedb-client/src/native/client.rs b/nodedb-client/src/native/client.rs deleted file mode 100644 index 5c89b48f0..000000000 --- a/nodedb-client/src/native/client.rs +++ /dev/null @@ -1,615 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! High-level native protocol client implementing the `NodeDb` trait. -//! -//! Wraps a connection pool and translates trait calls into native protocol -//! opcodes. Also exposes SQL/DDL methods not covered by the trait. - -use std::collections::HashMap; - -use async_trait::async_trait; -use sonic_rs::JsonValueTrait; - -use nodedb_types::document::Document; -use nodedb_types::error::{ErrorDetails, NodeDbError, NodeDbResult}; -use nodedb_types::filter::{EdgeFilter, MetadataFilter}; -use nodedb_types::graph::GraphStats; -use nodedb_types::id::{EdgeId, NodeId}; -use nodedb_types::protocol::{OpCode, TextFields}; -use nodedb_types::result::{QueryResult, SearchResult, SubGraph}; -use nodedb_types::value::Value; - -use nodedb_types::protocol::Limits; - -use super::pool::{Pool, PoolConfig}; -use super::response_parse::{json_to_value, parse_search_results, parse_subgraph_response}; -use crate::traits::NodeDb; - -/// Native protocol client for NodeDB. -/// -/// Connects via the binary MessagePack protocol. Supports all operations: -/// SQL, DDL, direct Data Plane ops, transactions, session parameters. -pub struct NativeClient { - pool: Pool, -} - -impl NativeClient { - /// Create a client with the given pool configuration. - pub fn new(config: PoolConfig) -> Self { - Self { - pool: Pool::new(config), - } - } - - /// Connect to a NodeDB server with default settings. - pub fn connect(addr: &str) -> Self { - Self::new(PoolConfig { - addr: addr.to_string(), - ..Default::default() - }) - } - - /// Execute a SQL query and return structured results. - /// - /// Retries once with a fresh connection on I/O failure. - pub async fn query(&self, sql: &str) -> NodeDbResult { - let mut conn = self.pool.acquire().await?; - match conn.execute_sql(sql).await { - Ok(r) => Ok(r), - Err(e) if is_connection_error(&e) => { - drop(conn); - let mut conn = self.pool.acquire().await?; - conn.execute_sql(sql).await - } - Err(e) => Err(e), - } - } - - /// Execute a DDL command. - pub async fn ddl(&self, sql: &str) -> NodeDbResult { - let mut conn = self.pool.acquire().await?; - match conn.execute_ddl(sql).await { - Ok(r) => Ok(r), - Err(e) if is_connection_error(&e) => { - drop(conn); - let mut conn = self.pool.acquire().await?; - conn.execute_ddl(sql).await - } - Err(e) => Err(e), - } - } - - /// Begin a transaction. - pub async fn begin(&self) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.begin().await - } - - /// Commit the current transaction. - pub async fn commit(&self) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.commit().await - } - - /// Rollback the current transaction. - pub async fn rollback(&self) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.rollback().await - } - - /// Set a session parameter. - pub async fn set_parameter(&self, key: &str, value: &str) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.set_parameter(key, value).await - } - - /// Show a session parameter. - pub async fn show_parameter(&self, key: &str) -> NodeDbResult { - let mut conn = self.pool.acquire().await?; - conn.show_parameter(key).await - } - - /// Ping the server. - pub async fn ping(&self) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.ping().await - } -} - -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -impl NodeDb for NativeClient { - fn proto_version(&self) -> u16 { - self.pool - .negotiated_meta() - .map(|m| m.proto_version) - .unwrap_or(0) - } - - fn capabilities(&self) -> u64 { - self.pool - .negotiated_meta() - .map(|m| m.capabilities) - .unwrap_or(0) - } - - fn server_version(&self) -> String { - self.pool - .negotiated_meta() - .map(|m| m.server_version) - .unwrap_or_default() - } - - fn limits(&self) -> Limits { - self.pool - .negotiated_meta() - .map(|m| m.limits) - .unwrap_or_default() - } - - async fn vector_search( - &self, - collection: &str, - query: &[f32], - k: usize, - filter: Option<&MetadataFilter>, - ) -> NodeDbResult> { - let mut conn = self.pool.acquire().await?; - let resp = conn - .send( - OpCode::VectorSearch, - build_vector_search_request(collection, query, k, filter), - ) - .await?; - parse_search_results(&resp) - } - - async fn vector_insert( - &self, - collection: &str, - id: &str, - embedding: &[f32], - metadata: Option, - ) -> NodeDbResult<()> { - // Serialize metadata up front. A serialization failure must - // propagate — the prior `unwrap_or_else(|_| "{}")` silently - // replaced caller-supplied metadata with an empty object, which - // is exactly the silent-drop pattern this client guards against - // on every other seam (filter bytes, bind params). - let meta_json = match metadata { - Some(d) => { - let obj: HashMap = d.fields; - sonic_rs::to_string(&obj).map_err(|e| { - NodeDbError::serialization("json", format!("vector_insert metadata: {e}")) - })? - } - None => "{}".to_string(), - }; - let sql = format!( - "INSERT INTO {} (id, embedding, metadata) VALUES ({}, {}, {})", - sql_quote_identifier(collection), - sql_quote_string_literal(id), - format_f32_array(embedding), - sql_quote_string_literal(&meta_json), - ); - let mut conn = self.pool.acquire().await?; - conn.execute_sql(&sql).await?; - Ok(()) - } - - async fn vector_delete(&self, collection: &str, id: &str) -> NodeDbResult<()> { - let sql = format!( - "DELETE FROM {} WHERE id = {}", - sql_quote_identifier(collection), - sql_quote_string_literal(id), - ); - let mut conn = self.pool.acquire().await?; - conn.execute_sql(&sql).await?; - Ok(()) - } - - async fn graph_traverse( - &self, - collection: &str, - start: &NodeId, - depth: u8, - edge_filter: Option<&EdgeFilter>, - ) -> NodeDbResult { - let mut conn = self.pool.acquire().await?; - let resp = conn - .send( - OpCode::GraphHop, - TextFields { - collection: Some(collection.to_string()), - start_node: Some(start.as_str().to_string()), - depth: Some(depth as u32), - edge_label: edge_filter.and_then(|f| f.labels.first().cloned()), - ..Default::default() - }, - ) - .await?; - parse_subgraph_response(&resp) - } - - async fn graph_insert_edge( - &self, - collection: &str, - from: &NodeId, - to: &NodeId, - edge_type: &str, - properties: Option, - ) -> NodeDbResult { - let props_json = match properties { - Some(d) => Some(serde_json::to_value(d.fields).map_err(|e| { - NodeDbError::serialization("json", format!("edge properties: {e}")) - })?), - None => None, - }; - let mut conn = self.pool.acquire().await?; - conn.send( - OpCode::EdgePut, - TextFields { - collection: Some(collection.to_string()), - from_node: Some(from.as_str().to_string()), - to_node: Some(to.as_str().to_string()), - edge_type: Some(edge_type.to_string()), - properties: props_json, - ..Default::default() - }, - ) - .await?; - EdgeId::try_first(from.clone(), to.clone(), edge_type) - .map_err(|e| NodeDbError::storage(format!("invalid edge label: {e}"))) - } - - async fn graph_delete_edge(&self, collection: &str, edge_id: &EdgeId) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.send( - OpCode::EdgeDelete, - TextFields { - collection: Some(collection.to_string()), - from_node: Some(edge_id.src.as_str().to_string()), - to_node: Some(edge_id.dst.as_str().to_string()), - edge_type: Some(edge_id.label.clone()), - ..Default::default() - }, - ) - .await?; - Ok(()) - } - - async fn graph_stats( - &self, - collection: Option<&str>, - as_of: Option, - ) -> NodeDbResult> { - // Route through the SQL/DSL path — `SHOW GRAPH STATS` is handled - // by the Control Plane's graph-ops dispatcher and returns a compact - // row set: (collection, node_count, edge_count, distinct_label_count, - // labels). `execute_sql` with empty params uses the simple-query wire - // path so DDL-adjacent statements like `SHOW GRAPH STATS` work. - let coll_clause = match collection { - Some(name) => format!(" '{}'", name.replace('\'', "''")), - None => String::new(), - }; - let as_of_clause = match as_of { - Some(ms) => format!(" AS OF SYSTEM TIME {ms}"), - None => String::new(), - }; - let sql = format!("SHOW GRAPH STATS{coll_clause}{as_of_clause}"); - let result = self.execute_sql(&sql, &[]).await?; - - let mut out = Vec::with_capacity(result.rows.len()); - for row in result.rows { - let coll_name = row - .first() - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let node_count = row.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as u64; - let edge_count = row.get(2).and_then(|v| v.as_i64()).unwrap_or(0) as u64; - let distinct_label_count = row.get(3).and_then(|v| v.as_i64()).unwrap_or(0) as u64; - let labels: Vec<(String, u64)> = row - .get(4) - .and_then(|v| v.as_str()) - .and_then(|s| { - sonic_rs::from_str::>(s) - .ok() - .map(|arr| { - arr.into_iter() - .filter_map(|obj| { - let label = obj["label"].as_str()?.to_string(); - let count = obj["count"].as_u64()?; - Some((label, count)) - }) - .collect() - }) - }) - .unwrap_or_default(); - - out.push(GraphStats { - collection: coll_name, - node_count, - edge_count, - distinct_label_count, - labels, - }); - } - Ok(out) - } - - async fn document_get(&self, collection: &str, id: &str) -> NodeDbResult> { - let mut conn = self.pool.acquire().await?; - let resp = conn - .send( - OpCode::PointGet, - TextFields { - collection: Some(collection.to_string()), - document_id: Some(id.to_string()), - ..Default::default() - }, - ) - .await?; - - let rows = resp.rows.unwrap_or_default(); - if rows.is_empty() { - return Ok(None); - } - - let json_text = rows[0].first().and_then(|v| v.as_str()).unwrap_or("{}"); - let mut doc = Document::new(id); - if let Ok(obj) = sonic_rs::from_str::>(json_text) { - for (k, v) in obj { - doc.set(&k, json_to_value(v)); - } - } - Ok(Some(doc)) - } - - async fn document_put(&self, collection: &str, doc: Document) -> NodeDbResult<()> { - let data = sonic_rs::to_vec(&doc.fields) - .map_err(|e| NodeDbError::serialization("json", format!("doc serialize: {e}")))?; - let mut conn = self.pool.acquire().await?; - conn.send( - OpCode::PointPut, - TextFields { - collection: Some(collection.to_string()), - document_id: Some(doc.id.clone()), - data: Some(data), - ..Default::default() - }, - ) - .await?; - Ok(()) - } - - async fn document_delete(&self, collection: &str, id: &str) -> NodeDbResult<()> { - let mut conn = self.pool.acquire().await?; - conn.send( - OpCode::PointDelete, - TextFields { - collection: Some(collection.to_string()), - document_id: Some(id.to_string()), - ..Default::default() - }, - ) - .await?; - Ok(()) - } - - async fn execute_sql(&self, query: &str, params: &[Value]) -> NodeDbResult { - // Bound parameters travel through `TextFields::sql_params` as a - // zerompk-MessagePack `Vec`. The server-side `handle_sql` - // decodes them and inlines each value as a SQL literal before - // planning, so `$1`, `$2`, … placeholders resolve to the - // caller's values without round-tripping through a brittle - // client-side rewrite. Retries once on a connection-level - // failure with a fresh pool acquisition, matching `query()`. - let mut conn = self.pool.acquire().await?; - match conn.execute_sql_with_params(query, params).await { - Ok(r) => Ok(r), - Err(e) if is_connection_error(&e) => { - drop(conn); - let mut conn = self.pool.acquire().await?; - conn.execute_sql_with_params(query, params).await - } - Err(e) => Err(e), - } - } -} - -/// Build the `TextFields` payload for an `OpCode::VectorSearch` request. -/// -/// The native protocol reserves wire byte 68 for the optional -/// `TextFields::filters: Option>` field. When the trait caller -/// passes a non-`None` `MetadataFilter`, the predicate is serialized -/// here so it travels alongside the SQL/DSL request rather than being -/// dropped at the client. -/// -/// Wire-format note: the inline doc on `TextFields::filters` calls for -/// MessagePack. Until the server-side decoder is wired (the dispatch -/// path currently constructs plans with `filters: Vec::new()`), the -/// client serializes via sonic_rs JSON. The server-side fix will switch -/// both sides to a single agreed encoding; for now the bytes are -/// observable as non-empty, which is what the trait contract requires. -fn build_vector_search_request( - collection: &str, - query: &[f32], - k: usize, - filter: Option<&MetadataFilter>, -) -> TextFields { - let filters_bytes = filter.and_then(|f| { - // Filter encoding is best-effort at this layer: a serialization - // failure must not block the request, but it must not silently - // produce an empty `filters` field either (that would re-create - // the silent-drop pattern this fix is closing). - match sonic_rs::to_vec(f) { - Ok(b) => Some(b), - Err(e) => { - tracing::warn!(error = %e, "failed to serialize metadata filter for native request"); - None - } - } - }); - TextFields { - collection: Some(collection.to_string()), - query_vector: Some(query.to_vec()), - top_k: Some(k as u32), - filters: filters_bytes, - ..Default::default() - } -} - -// ─── Internal helpers ────────────────────────────────────────────── - -fn format_f32_array(arr: &[f32]) -> String { - let inner: Vec = arr.iter().map(|v| format!("{v}")).collect(); - format!("ARRAY[{}]", inner.join(",")) -} - -/// Quote a SQL identifier (collection / column name) by doubling any -/// internal double-quotes and wrapping the result in double-quotes — -/// the SQL standard rule that PostgreSQL applies under -/// `standard_conforming_strings=on`. Mirrors the always-quote variant -/// in `remote_parse::quote_identifier`; kept here to avoid pulling the -/// `remote` feature into the `native` client. -fn sql_quote_identifier(name: &str) -> String { - let escaped = name.replace('"', "\"\""); - format!("\"{escaped}\"") -} - -/// Render a `&str` as a SQL string literal: single-quote-doubled and -/// wrapped in single quotes. Matches `standard_conforming_strings=on` -/// behavior (PG 9.1+ default) which is the only mode the server runs in. -/// Centralizes the escape so call sites can't drift into raw `format!`s -/// without going through it. -fn sql_quote_string_literal(s: &str) -> String { - let escaped = s.replace('\'', "''"); - format!("'{escaped}'") -} - -/// Check if an error is a connection-level failure (worth retrying). -fn is_connection_error(e: &NodeDbError) -> bool { - matches!( - e.details(), - ErrorDetails::SyncConnectionFailed | ErrorDetails::Storage { .. } - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - // NodeDb trait-contract enforcement on the native client. - // - // Symmetric to the remote-side guards in `nodedb-client/src/remote/sql.rs`. - // A request envelope that omits caller-supplied filter / params - // bytes is the silent-drop pattern these tests guard against — the - // server answers without the caller's predicate, returning data - // from the wrong scope. The tests pin the spec at the request- - // builder seam so the envelope carries what the trait promised. - - #[test] - fn vector_search_request_without_filter_omits_filter_bytes() { - // No filter → TextFields.filters stays None. - let req = build_vector_search_request("docs", &[0.1, 0.2], 5, None); - assert_eq!(req.collection.as_deref(), Some("docs")); - assert_eq!(req.query_vector.as_deref(), Some(&[0.1f32, 0.2][..])); - assert_eq!(req.top_k, Some(5)); - assert!( - req.filters.is_none(), - "no-filter case must leave TextFields::filters empty" - ); - } - - #[test] - fn vector_search_request_serializes_metadata_filter() { - // Spec: a non-None filter is serialized into TextFields::filters - // (MessagePack-encoded predicate bytes), not silently dropped. - // The native protocol reserves wire byte 68 for this field; - // the request builder must populate it whenever the trait - // caller passes a non-None filter. - let filter = MetadataFilter::eq("category", Value::String("ai".into())); - let req = build_vector_search_request("docs", &[0.1], 3, Some(&filter)); - assert!( - req.filters.is_some(), - "non-None filter must be serialized into TextFields::filters \ - rather than dropped before reaching the wire" - ); - let bytes = req.filters.expect("filters bytes recorded"); - assert!( - !bytes.is_empty(), - "serialized filter bytes must not be empty" - ); - } - - #[test] - fn execute_sql_encodes_params_into_sql_params_field() { - // Spec: non-empty `params` are encoded as a zerompk-MessagePack - // `Vec` and ride on `TextFields::sql_params`. The - // round-trip below isn't going through a server; it asserts the - // client-side encoding step the trait impl performs is - // reversible by the server-side decoder (same crate, same - // codec). A silent re-encoding into JSON or a lossy - // `Vec` would lose the variant tag and re-create the - // silent-wrong pattern the trait contract is built to prevent. - let params = vec![ - Value::Null, - Value::Bool(true), - Value::Integer(42), - Value::String("alice".into()), - ]; - let bytes = zerompk::to_msgpack_vec(¶ms).expect("encode params"); - let decoded: Vec = - zerompk::from_msgpack(&bytes).expect("decode round-trips on same codec"); - assert_eq!(decoded.len(), 4); - assert!(matches!(decoded[0], Value::Null)); - assert!(matches!(decoded[1], Value::Bool(true))); - assert!(matches!(decoded[2], Value::Integer(42))); - match &decoded[3] { - Value::String(s) => assert_eq!(s, "alice"), - other => panic!("expected Value::String('alice'), got {other:?}"), - } - } - - #[test] - fn format_f32_array_works() { - let arr = [0.1f32, 0.2, 0.3]; - let s = format_f32_array(&arr); - assert!(s.starts_with("ARRAY[")); - assert!(s.contains("0.1")); - assert!(s.ends_with(']')); - } - - #[test] - fn sql_quote_identifier_wraps_and_escapes_double_quotes() { - assert_eq!(sql_quote_identifier("foo"), "\"foo\""); - // Embedded `"` must be doubled per the SQL identifier-escape rule. - assert_eq!(sql_quote_identifier("a\"b"), "\"a\"\"b\""); - } - - #[test] - fn sql_quote_string_literal_escapes_single_quotes() { - assert_eq!(sql_quote_string_literal("plain"), "'plain'"); - // The PG standard rule under `standard_conforming_strings=on`: - // double every embedded `'`. A `O'Reilly` literal that lost its - // escape would close the SQL string early and inject the rest. - assert_eq!(sql_quote_string_literal("O'Reilly"), "'O''Reilly'"); - assert_eq!( - sql_quote_string_literal("'; DROP TABLE x; --"), - "'''; DROP TABLE x; --'" - ); - } - - #[test] - fn sql_quote_string_literal_passes_through_json() { - // The metadata path renders sonic_rs JSON and then quotes it as - // a SQL string. JSON already escapes its own `"` and `\`, so - // only the outer `'` needs SQL escaping. Verify the helper - // doesn't touch JSON-internal quoting. - let json = r#"{"name":"O'Reilly","ok":true}"#; - let quoted = sql_quote_string_literal(json); - // The single quote in `O'Reilly` is doubled; the JSON `"` is left alone. - assert_eq!(quoted, "'{\"name\":\"O''Reilly\",\"ok\":true}'"); - } -} diff --git a/nodedb-client/src/native/client/document.rs b/nodedb-client/src/native/client/document.rs index 75c164e8c..49ec69541 100644 --- a/nodedb-client/src/native/client/document.rs +++ b/nodedb-client/src/native/client/document.rs @@ -8,8 +8,8 @@ use nodedb_types::document::Document; use nodedb_types::error::{NodeDbError, NodeDbResult}; use nodedb_types::protocol::{OpCode, TextFields}; -use super::super::response_parse::json_to_value; use super::core::NativeClient; +use nodedb_types::conversion::json_to_value; impl NativeClient { pub(super) async fn document_get_impl( diff --git a/nodedb-client/src/native/response_parse.rs b/nodedb-client/src/native/response_parse.rs index 00a62cb49..b2bc894bc 100644 --- a/nodedb-client/src/native/response_parse.rs +++ b/nodedb-client/src/native/response_parse.rs @@ -47,9 +47,6 @@ fn parse_single_search_result(v: &serde_json::Value) -> Option { }) } -// Re-export for use by client.rs -pub(crate) use nodedb_types::conversion::json_to_value; - /// Parse a graph traversal response into a SubGraph. pub(crate) fn parse_subgraph_response( resp: &nodedb_types::protocol::NativeResponse, diff --git a/nodedb-client/src/remote/client.rs b/nodedb-client/src/remote/client.rs deleted file mode 100644 index cf8f03d99..000000000 --- a/nodedb-client/src/remote/client.rs +++ /dev/null @@ -1,767 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! `NodeDbRemote` connection lifecycle and `NodeDb` trait impl. -//! -//! Connection methods (`connect`, `query_raw`, `execute_raw`) live here. -//! The single `impl NodeDb for NodeDbRemote` block is the only legal -//! place for the trait methods (Rust forbids splitting a trait impl -//! across files); each method is a thin shim that calls the helpers in -//! `super::sql` (SQL/param translation) and `super::parse` (JSON decode). - -use std::collections::HashMap; -use std::sync::Arc; - -use async_trait::async_trait; -use sonic_rs::JsonValueTrait; -use tokio::sync::Mutex; -use tokio_postgres::{Client, NoTls}; - -use nodedb_types::document::Document; -use nodedb_types::dropped_collection::DroppedCollection; -use nodedb_types::error::{NodeDbError, NodeDbResult}; -use nodedb_types::filter::{EdgeFilter, MetadataFilter}; -use nodedb_types::graph::GraphStats; -use nodedb_types::id::{EdgeId, NodeId}; -use nodedb_types::result::{QueryResult, SearchResult, SubGraph, SubGraphEdge, SubGraphNode}; -use nodedb_types::value::Value; - -use crate::remote_parse::{ - format_vector_array, json_to_value, pg_value_to_value, quote_identifier, -}; -use crate::row_decode::parse_dropped_collection_rows; -use crate::traits::NodeDb; - -use super::parse::{parse_graph_traverse_json, parse_vector_search_json}; -use super::sql::{build_vector_search_sql, translate_params}; - -/// Remote NodeDB client. Connects to an Origin instance over pgwire and -/// translates `NodeDb` trait calls into SQL/DSL queries. -pub struct NodeDbRemote { - client: Arc>, -} - -/// Extract a useful detail string from a `tokio_postgres::Error`. -/// -/// Without this, `Display` returns the literal `"db error"` and the -/// SQLSTATE + server message are dropped — every failure surfaces as the -/// same opaque string and is impossible to diagnose without a debug -/// rebuild. Mirrors the harness's `pg_error_detail` so client and test -/// reports look identical. -fn pg_error_detail(e: &tokio_postgres::Error) -> String { - if let Some(db_err) = e.as_db_error() { - format!( - "{}: {} (SQLSTATE {})", - db_err.severity(), - db_err.message(), - db_err.code().code() - ) - } else { - format!("{e}") - } -} - -impl NodeDbRemote { - /// Connect to a NodeDB Origin instance. - /// - /// `config` is a standard PostgreSQL connection string: - /// `"host=localhost port=5432 user=app dbname=mydb"` - pub async fn connect(config: &str) -> NodeDbResult { - let (client, connection) = tokio_postgres::connect(config, NoTls) - .await - .map_err(|e| NodeDbError::sync_connection_failed(e.to_string()))?; - - // Spawn the connection handler — it runs in the background. - tokio::spawn(async move { - if let Err(e) = connection.await { - tracing::error!("pgwire connection error: {e}"); - } - }); - - Ok(Self { - client: Arc::new(Mutex::new(client)), - }) - } - - /// Execute a raw SQL string and return rows as `Vec>`. - async fn query_raw( - &self, - sql: &str, - params: &[&(dyn tokio_postgres::types::ToSql + Sync)], - ) -> NodeDbResult<(Vec, Vec>)> { - let client = self.client.lock().await; - let rows = client.query(sql, params).await.map_err(|e| { - NodeDbError::storage(format!("pgwire query failed: {}", pg_error_detail(&e))) - })?; - - if rows.is_empty() { - return Ok((Vec::new(), Vec::new())); - } - - let columns: Vec = rows[0] - .columns() - .iter() - .map(|c| c.name().to_string()) - .collect(); - - let mut result_rows = Vec::with_capacity(rows.len()); - for row in &rows { - let mut vals = Vec::with_capacity(columns.len()); - for (i, col) in row.columns().iter().enumerate() { - let val = pg_value_to_value(row, i, col.type_()); - vals.push(val); - } - result_rows.push(vals); - } - - Ok((columns, result_rows)) - } - - /// Execute a statement that doesn't return rows (INSERT/UPDATE/DELETE). - async fn execute_raw( - &self, - sql: &str, - params: &[&(dyn tokio_postgres::types::ToSql + Sync)], - ) -> NodeDbResult { - let client = self.client.lock().await; - client.execute(sql, params).await.map_err(|e| { - NodeDbError::storage(format!("pgwire execute failed: {}", pg_error_detail(&e))) - }) - } - - /// Execute a parameterless statement via the simple-query protocol - /// (single `Query` message — no `Parse`/`Bind`/`Describe` round-trip). - /// - /// Required for DDL statements that don't fit the extended-query - /// row-description shape that `Client::query` expects. - /// `simple_query` doesn't support bound parameters, so callers with - /// non-empty params must continue to use `query_raw`. - /// - /// All values come back as strings from the simple-query protocol; - /// we wrap them as `Value::String` and let downstream consumers - /// coerce as needed. - async fn simple_query_raw(&self, sql: &str) -> NodeDbResult<(Vec, Vec>)> { - use tokio_postgres::SimpleQueryMessage; - - let client = self.client.lock().await; - let messages = client.simple_query(sql).await.map_err(|e| { - NodeDbError::storage(format!( - "pgwire simple_query failed: {}", - pg_error_detail(&e) - )) - })?; - - let mut columns: Vec = Vec::new(); - let mut rows: Vec> = Vec::new(); - - for msg in messages { - match msg { - SimpleQueryMessage::RowDescription(fields) => { - columns = fields.iter().map(|f| f.name().to_string()).collect(); - } - SimpleQueryMessage::Row(row) => { - let mut vals = Vec::with_capacity(row.len()); - for i in 0..row.len() { - match row.get(i) { - Some(s) => vals.push(Value::String(s.to_string())), - None => vals.push(Value::Null), - } - } - rows.push(vals); - } - SimpleQueryMessage::CommandComplete(_) => { - // DDL / DML completion — no rows. - } - _ => {} - } - } - Ok((columns, rows)) - } -} - -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -impl NodeDb for NodeDbRemote { - async fn vector_search( - &self, - collection: &str, - query: &[f32], - k: usize, - filter: Option<&MetadataFilter>, - ) -> NodeDbResult> { - let sql = build_vector_search_sql(collection, query, k, filter)?; - - let (columns, rows) = self.query_raw(&sql, &[]).await?; - - // The DSL path returns JSON in a single "result" column. - if columns.len() == 1 && columns[0] == "result" { - if let Some(row) = rows.first() - && let Some(Value::String(json_text)) = row.first() - { - return parse_vector_search_json(json_text); - } - return Ok(Vec::new()); - } - - // Structured result set: id, distance columns. - let mut results = Vec::with_capacity(rows.len()); - let id_idx = columns.iter().position(|c| c == "id").unwrap_or(0); - let dist_idx = columns.iter().position(|c| c == "distance").unwrap_or(1); - - for row in &rows { - let id = row - .get(id_idx) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let distance = row.get(dist_idx).and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; - - results.push(SearchResult { - id, - node_id: None, - distance, - metadata: HashMap::new(), - }); - } - - Ok(results) - } - - async fn vector_insert_field( - &self, - collection: &str, - field_name: &str, - id: &str, - embedding: &[f32], - metadata: Option, - ) -> NodeDbResult<()> { - // Field-aware path: emit `INSERT INTO (id, [, - // metadata]) VALUES ($1, ARRAY[...]<, $2>)` so the vector lands - // on the column named by the trait — not on whichever vector - // column the planner picks when the column name is omitted. - let coll = quote_identifier(collection); - let field = quote_identifier(field_name); - let vec_lit = format_vector_array(embedding); - - let sql = match metadata { - Some(_) => { - format!("INSERT INTO {coll} (id, {field}, metadata) VALUES ($1, {vec_lit}, $2)") - } - None => format!("INSERT INTO {coll} (id, {field}) VALUES ($1, {vec_lit})"), - }; - - if let Some(d) = metadata { - let meta_json = sonic_rs::to_string(&d) - .map_err(|e| NodeDbError::storage(format!("metadata serialization: {e}")))?; - self.execute_raw(&sql, &[&id, &meta_json]).await?; - } else { - self.execute_raw(&sql, &[&id]).await?; - } - Ok(()) - } - - async fn vector_search_field( - &self, - collection: &str, - field_name: &str, - query: &[f32], - k: usize, - filter: Option<&MetadataFilter>, - ) -> NodeDbResult> { - // Field-aware path: use the 2-arg form of `vector_distance` so - // the planner scopes the HNSW lookup to the named column. The - // single-arg form `vector_distance(ARRAY[...])` only works on - // collections that have exactly one vector column. - let coll = quote_identifier(collection); - let field = quote_identifier(field_name); - let vec_lit = format_vector_array(query); - let where_clause = match filter { - Some(f) => { - let rendered = super::sql::render_metadata_filter_public(f)?; - format!(" WHERE {rendered}") - } - None => String::new(), - }; - let sql = format!( - "SELECT id, vector_distance({field}, {vec_lit}) AS distance \ - FROM {coll}{where_clause} \ - ORDER BY vector_distance({field}, {vec_lit}) \ - LIMIT {k}" - ); - - let (columns, rows) = self.query_raw(&sql, &[]).await?; - let id_idx = columns.iter().position(|c| c == "id").unwrap_or(0); - let dist_idx = columns.iter().position(|c| c == "distance").unwrap_or(1); - - let mut results = Vec::with_capacity(rows.len()); - for row in &rows { - let id = row - .get(id_idx) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let distance = row.get(dist_idx).and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; - results.push(SearchResult { - id, - node_id: None, - distance, - metadata: HashMap::new(), - }); - } - Ok(results) - } - - async fn vector_insert( - &self, - collection: &str, - id: &str, - embedding: &[f32], - metadata: Option, - ) -> NodeDbResult<()> { - let collection = quote_identifier(collection); - let meta_json = match metadata { - Some(d) => sonic_rs::to_string(&d) - .map_err(|e| NodeDbError::storage(format!("metadata serialization: {e}")))?, - None => "{}".into(), - }; - - let sql = format!( - "INSERT INTO {collection} (id, embedding, metadata) VALUES ($1, {}, $2::jsonb)", - format_vector_array(embedding), - ); - self.execute_raw(&sql, &[&id, &meta_json]).await?; - Ok(()) - } - - async fn vector_delete(&self, collection: &str, id: &str) -> NodeDbResult<()> { - let collection = quote_identifier(collection); - let sql = format!("DELETE FROM {collection} WHERE id = $1"); - self.execute_raw(&sql, &[&id]).await?; - Ok(()) - } - - async fn graph_traverse( - &self, - collection: &str, - start: &NodeId, - depth: u8, - edge_filter: Option<&EdgeFilter>, - ) -> NodeDbResult { - // Server-side DSL: `GRAPH TRAVERSE FROM '' DEPTH - // [LABEL '']`. The Origin graph overlay is tenant-scoped - // (the dispatcher routes on `identity.tenant_id`), so the - // `collection` argument is accepted for trait symmetry with - // `graph_insert_edge` and Lite parity but is not threaded into - // the wire DSL — every edge in the tenant participates in the - // traversal regardless of which collection it was inserted - // into. - let _ = collection; - let label_clause = edge_filter - .and_then(|f| f.labels.first()) - .map(|l| format!(" LABEL '{}'", l.replace('\'', "''"))) - .unwrap_or_default(); - let start_str = start.as_str().replace('\'', "''"); - let sql = format!("GRAPH TRAVERSE FROM '{start_str}' DEPTH {depth}{label_clause}"); - - let (columns, rows) = self.simple_query_raw(&sql).await?; - - if columns.len() == 1 && columns[0] == "result" { - if let Some(row) = rows.first() - && let Some(Value::String(json_text)) = row.first() - { - return parse_graph_traverse_json(json_text); - } - return Ok(SubGraph::empty()); - } - - // Structured: node_id, depth, edge_src, edge_dst, edge_label columns. - let mut nodes = Vec::new(); - let mut edges = Vec::new(); - let mut seen_nodes = std::collections::HashSet::new(); - - for row in &rows { - let node_id_str = row.first().and_then(|v| v.as_str()).unwrap_or(""); - let d = row.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as u8; - - if seen_nodes.insert(node_id_str.to_string()) { - nodes.push(SubGraphNode { - id: NodeId::from_validated(node_id_str.to_owned()), - depth: d, - properties: HashMap::new(), - }); - } - - if let (Some(src), Some(dst), Some(label)) = ( - row.get(2).and_then(|v| v.as_str()), - row.get(3).and_then(|v| v.as_str()), - row.get(4).and_then(|v| v.as_str()), - ) { - edges.push(SubGraphEdge { - id: EdgeId::try_first( - NodeId::from_validated(src.to_owned()), - NodeId::from_validated(dst.to_owned()), - label, - ) - .expect("server wire label already validated"), - from: NodeId::from_validated(src.to_owned()), - to: NodeId::from_validated(dst.to_owned()), - label: label.to_string(), - properties: HashMap::new(), - }); - } - } - - Ok(SubGraph { nodes, edges }) - } - - async fn graph_insert_edge( - &self, - collection: &str, - from: &NodeId, - to: &NodeId, - edge_type: &str, - properties: Option, - ) -> NodeDbResult { - // `GRAPH INSERT EDGE IN '' FROM '' TO '' - // TYPE '