From bb20a8d301f4c79aee6d3345a99fe01ac2ae6f13 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Tue, 13 Oct 2026 14:33:59 +0800 Subject: [PATCH 01/18] docs: describe continuous aggregate DDL with BUCKET and AGGREGATE clauses Document the refresh policies, retention option, supported aggregate functions and the read-only per-bucket relation returned by a continuous aggregate. --- docs/ai/evaluation-tracking.md | 25 ++++++++---------- docs/query-language.md | 35 ++++++++++++++++++-------- docs/timeseries.md | 46 +++++++++++++++++++++++----------- docs/vectors.md | 4 +-- 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/docs/ai/evaluation-tracking.md b/docs/ai/evaluation-tracking.md index 154e978e2..e251ecd31 100644 --- a/docs/ai/evaluation-tracking.md +++ b/docs/ai/evaluation-tracking.md @@ -96,25 +96,20 @@ CREATE COLLECTION model_perf ( ) WITH (engine='timeseries', partition_by='1h', retention='90d'); -- Continuous aggregation: 5-minute rollups for dashboards -CREATE CONTINUOUS AGGREGATE model_perf_5m -ON model_perf -AS - SELECT time_bucket('5 minutes', ts) AS bucket, - model_name, - endpoint, - AVG(latency_ms) AS avg_latency, - AVG(score) AS avg_score, - SUM(CASE WHEN error THEN 1 ELSE 0 END) AS error_count, - COUNT(*) AS request_count - FROM model_perf - GROUP BY bucket, model_name, endpoint; +CREATE CONTINUOUS AGGREGATE model_perf_5m ON model_perf + BUCKET '5m' + AGGREGATE avg(latency_ms) AS avg_latency, + avg(score) AS avg_score, + max(latency_ms) AS max_latency, + count(*) AS request_count + GROUP BY model_name, endpoint; -- Dashboard query: latency trend for the last 24 hours -SELECT ts, model_name, avg_latency, avg_score, error_count, request_count +SELECT bucket, model_name, avg_latency, avg_score, max_latency, request_count FROM model_perf_5m WHERE model_name = 'retriever-v3' - AND ts > NOW() - INTERVAL '24 hours' -ORDER BY ts; + AND bucket > NOW() - INTERVAL '24 hours' +ORDER BY bucket; -- Drift detection: compare this week's average to last week's SELECT diff --git a/docs/query-language.md b/docs/query-language.md index 711f09d87..570c5db60 100644 --- a/docs/query-language.md +++ b/docs/query-language.md @@ -766,18 +766,14 @@ SHOW MATERIALIZED VIEWS; ### Continuous Aggregates -Continuous aggregates are incrementally maintained views over timeseries collections. Unlike `REFRESH MATERIALIZED VIEW` (full re-scan), continuous aggregates update only the new watermark window. +Continuous aggregates are incrementally maintained views over timeseries collections. Unlike `REFRESH MATERIALIZED VIEW` (full re-scan), continuous aggregates fold only new data into per-bucket state. ```sql -CREATE CONTINUOUS AGGREGATE cpu_hourly -ON cpu_metrics -AS - SELECT time_bucket('1 hour', ts) AS hour, - host, - AVG(cpu) AS avg_cpu - FROM cpu_metrics - GROUP BY hour, host -WITH (refresh_interval = '1m'); +CREATE CONTINUOUS AGGREGATE cpu_hourly ON cpu_metrics + BUCKET '1h' + AGGREGATE avg(cpu) AS avg_cpu, count(*) AS samples + GROUP BY host + WITH (refresh_interval = '1m', retention = '365d'); -- Manually trigger a refresh REFRESH CONTINUOUS AGGREGATE cpu_hourly; @@ -787,6 +783,23 @@ SHOW CONTINUOUS AGGREGATES; DROP CONTINUOUS AGGREGATE cpu_hourly; ``` +`BUCKET` sets the bucket width. `AGGREGATE` functions: `count`, `sum`, `avg`, `min`, `max`, `first`, `last`, `count_distinct`, `approx_count_distinct`, `percentile(col, q)` (also `ts_percentile`, `approx_percentile`), `topk(col, k)` (also `approx_topk`). `GROUP BY` is optional. + +`WITH` options: + +| Option | Value | +| ------ | ----- | +| `refresh_policy` | `on_flush` (default), `on_seal`, `manual`, or an interval such as `'5m'` (folds periodically) | +| `refresh_interval` | Interval such as `'1m'`. Folds periodically on the maintenance tick. | +| `retention` | Interval after which the aggregate drops old buckets | + +- `on_flush` and `on_seal` aggregates fold each flushed partition once. +- An interval `refresh_policy` or `refresh_interval` folds on the maintenance tick at that interval. +- `manual` aggregates fold only on `REFRESH CONTINUOUS AGGREGATE`. A read serves the last refresh. +- Aggregate state survives restart and keeps its history after raw retention removes the source rows. +- `SELECT * FROM cpu_hourly` returns one row per bucket and group: `bucket`, the `GROUP BY` columns, then the outputs. +- The aggregate is read-only. + ## Engine-Specific SQL ### Vector Search @@ -1257,7 +1270,7 @@ SHOW USERS; `NOW()`, `CURRENT_TIMESTAMP()`, `EXTRACT(field FROM ts)`, `DATE_TRUNC(unit, ts)`, `DATE_FORMAT(ts, fmt)`, `time_bucket(interval, ts)` -`time_bucket(interval, ts)` — truncates `ts` to the nearest `interval` boundary. Registered as a DataFusion ScalarUDF. Accepts interval literals (`'5m'`, `'1h'`, `'1d'`) and ISO 8601 durations (`'PT5M'`, `'PT1H'`). Used in timeseries aggregations and continuous aggregate definitions. +`time_bucket(interval, ts)` — truncates `ts` to the nearest `interval` boundary. Registered as a DataFusion ScalarUDF. Accepts interval literals (`'5m'`, `'1h'`, `'1d'`) and ISO 8601 durations (`'PT5M'`, `'PT1H'`). Used in timeseries aggregations. Continuous aggregate definitions do not use it. They set the bucket with `BUCKET`. ### Type diff --git a/docs/timeseries.md b/docs/timeseries.md index 7b5f2386c..8bc8ab046 100644 --- a/docs/timeseries.md +++ b/docs/timeseries.md @@ -14,7 +14,7 @@ Timeseries is a **columnar profile**. It extends the [Columnar engine](columnar. - **Cascading compression** — ALP + FastLanes + FSST + Pcodec + rANS + LZ4. Achieves 20-40x compression on typical telemetry, compared to 3-5x from single-pass approaches. - **ILP ingest** — InfluxDB Line Protocol for high-throughput metric ingestion with adaptive batching and per-series core routing. -- **Continuous aggregation** — Incrementally maintained materialized views with watermarks, multi-tier chaining, and out-of-order handling. No full re-scan on refresh. +- **Continuous aggregation** — Incrementally maintained per-bucket state, folded from flushed partitions. No full re-scan on refresh. - **Retention policies** — Automatic expiry of old data by time partition. - **Approximate aggregation** — HyperLogLog (cardinality), t-digest (percentiles), SpaceSaving (top-K), Count-Min Sketch (frequency). All mergeable across shards and usable in continuous aggregation. - **Block-level skip** — Sparse primary index with min/max statistics per block. Queries skip irrelevant time ranges without decompression. @@ -53,11 +53,10 @@ GROUP BY bucket, host ORDER BY bucket DESC; -- Continuous aggregation (incremental, no full re-scan) -CREATE CONTINUOUS AGGREGATE cpu_hourly -ON cpu_metrics -BUCKET '1 hour' -AGGREGATE AVG(cpu_usage) AS avg_cpu, ts_percentile(cpu_usage, 0.99) AS p99_cpu -GROUP BY time_bucket('1 hour', ts) AS hour, host +CREATE CONTINUOUS AGGREGATE cpu_hourly ON cpu_metrics +BUCKET '1h' +AGGREGATE avg(cpu_usage) AS avg_cpu, ts_percentile(cpu_usage, 0.99) AS p99_cpu +GROUP BY host WITH (refresh_interval = '1m'); -- Approximate aggregation @@ -107,16 +106,15 @@ GROUP BY bucket; ## Continuous Aggregation DDL -Continuous aggregates are incrementally maintained views over a timeseries collection. They refresh automatically as new data arrives — no full re-scan on each refresh. +A continuous aggregate folds new rows of a timeseries collection into per-bucket state. It never re-scans the source. ```sql -- Create a continuous aggregate -CREATE CONTINUOUS AGGREGATE cpu_hourly -ON cpu_metrics -BUCKET '1 hour' -AGGREGATE AVG(cpu_usage) AS avg_cpu, ts_percentile(cpu_usage, 0.99) AS p99_cpu -GROUP BY time_bucket('1 hour', ts) AS hour, host -WITH (refresh_interval = '1m'); +CREATE CONTINUOUS AGGREGATE cpu_hourly ON cpu_metrics +BUCKET '1h' +AGGREGATE avg(cpu_usage) AS avg_cpu, ts_percentile(cpu_usage, 0.99) AS p99_cpu +GROUP BY host +WITH (refresh_interval = '1m', retention = '365d'); -- Manually trigger a refresh REFRESH CONTINUOUS AGGREGATE cpu_hourly; @@ -128,7 +126,25 @@ SHOW CONTINUOUS AGGREGATES; DROP CONTINUOUS AGGREGATE cpu_hourly; ``` -The `refresh_interval` controls how often the engine checks for new data since the last watermark. Out-of-order data within the watermark window is handled automatically. +`BUCKET` sets the bucket width. Do not use `time_bucket(...)` in the definition. + +| Clause | Meaning | +| ------ | ------- | +| `AGGREGATE f(col) [AS alias], ...` | Outputs. Functions: `count`, `sum`, `avg`, `min`, `max`, `first`, `last`, `count_distinct`, `approx_count_distinct`, `percentile(col, q)` (also `ts_percentile`, `approx_percentile`), `topk(col, k)` (also `approx_topk`). `q` is in `[0, 1]`. `k` is at least 1. Use `count(*)` to count rows. | +| `GROUP BY col, ...` | Optional. Adds group columns to each bucket. | +| `refresh_policy` | `on_flush` (default), `on_seal`, `manual`, or an interval such as `'5m'` (folds periodically). | +| `refresh_interval` | Interval such as `'1m'`. Folds periodically on the maintenance tick. | +| `retention` | Interval after which the aggregate drops old buckets. | + +Refresh behavior: + +- `on_flush` and `on_seal` aggregates fold each flushed partition once. +- `refresh_interval` makes the aggregate periodic. It folds on the maintenance tick at that interval. +- `manual` aggregates fold only on `REFRESH CONTINUOUS AGGREGATE`. A read serves the last refresh. + +Aggregate state survives restart. It keeps its history after raw retention removes the source rows. + +`SELECT * FROM cpu_hourly` returns one row per bucket and group. Columns are `bucket`, the `GROUP BY` columns, then the outputs. The aggregate is read-only. Continuous aggregate refreshes can also be triggered from the cron scheduler: @@ -140,7 +156,7 @@ AS BEGIN END; ``` -This is useful when you want deterministic refresh timing rather than relying on the `refresh_interval` background timer. See [Real-Time Features](real-time.md#cron-scheduler) for the full cron scheduler reference. +This is useful when you want deterministic refresh timing rather than relying on the `refresh_interval` maintenance tick or the flush-time fold. See [Real-Time Features](real-time.md#cron-scheduler) for the full cron scheduler reference. ## ILP Ingest (InfluxDB Line Protocol) diff --git a/docs/vectors.md b/docs/vectors.md index 2a1d43b69..db22dcfca 100644 --- a/docs/vectors.md +++ b/docs/vectors.md @@ -113,7 +113,7 @@ CREATE COLLECTION corpus ( tenant_id UUID, created_at TIMESTAMP DEFAULT now() ) WITH ( - primary='vector', + engine='vector', vector_field='embedding', dim=384, metric='cosine', @@ -125,7 +125,7 @@ CREATE COLLECTION corpus ( ); ``` -`primary='vector'` is purely an access-path hint — not a different engine. Cross-engine queries, CRDT sync, SQL semantics all keep working. `payload_indexes` are per-field equality / range / boolean indexes over the metadata sidecar for filtered ANN (replaces Pinecone metadata filters). +`engine='vector'` implies `primary='vector'`. It requires `vector_field` and `dim`; a missing one, or a contradicting `primary`, is refused with SQLSTATE `42601`. A document collection searched through a vector index uses `engine='document_schemaless'`. Cross-engine queries, CRDT sync, SQL semantics all keep working. `payload_indexes` are per-field equality / range / boolean indexes over the metadata sidecar for filtered ANN (replaces Pinecone metadata filters). `storage_dtype` controls the precision of raw HNSW vector storage (independent of quantization). Accepted values: `F32` (default, 4 bytes/dimension), `F16` (2 bytes/dimension, IEEE half), `BF16` (2 bytes/dimension, bfloat16 — same exponent range as F32, better dynamic range for embeddings, preferred). Using `BF16` or `F16` halves resident vector bytes, reducing memory pressure on large indexes with a small accuracy cost. The HNSW graph structure and search quality remain unaffected. From 1785d267943d0f24ed12df67de66be77de624577 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Tue, 13 Oct 2026 14:34:04 +0800 Subject: [PATCH 02/18] feat(timeseries): serve continuous aggregates as relations and route aggregates natively Continuous aggregates now keep persisted per-bucket state folded from flushed partitions, survive restart, and are readable as read-only relations with refresh policies and retention. Timeseries aggregation, ordering and tier selection run natively in the data plane, with partition rewrite and flush recovery. Planner and executor gain aggregate FILTER clauses, aggregates over joins, UPDATE ... FROM and MERGE row sources, unqualified join column resolution, generated columns, state-transition checks by role and typeguard defaults across write forms. PromQL gains remote read and metric registration over the shared source layer. --- Cargo.lock | 3 + .../tests/document_declared_key_round_trip.rs | 2 +- .../tests/document_typeguard_round_trip.rs | 91 ++ .../tests/vector_insert_field_round_trip.rs | 5 +- .../vector_search_allowed_ids_round_trip.rs | 2 +- .../tests/vector_search_filter_round_trip.rs | 11 +- .../vector_search_metadata_filter_window.rs | 2 +- nodedb-cluster-tests/Cargo.toml | 3 + .../cases/cluster_execute_request.rs | 3 + .../cases/continuous_aggregate_serving.rs | 131 +++ .../tests/common_suite/cases/mod.rs | 2 + .../cases/promql_remote_storage.rs | 215 ++++ .../cases/shuffle_aggregate_cross_node.rs | 2 + .../cases/shuffle_consume_cross_node.rs | 1 + .../cases/shuffle_produce_cross_node.rs | 1 + .../cases/vector_index_dispatch_cross_node.rs | 1 + .../src/calvin/types/transaction.rs | 13 + nodedb-cluster/src/rpc_codec/execute/codec.rs | 5 + nodedb-cluster/src/rpc_codec/execute/types.rs | 4 + .../src/transport/client/transport.rs | 1 + nodedb-crdt/src/state/preview.rs | 22 + .../src/physical_plan/aggregate_functions.rs | 60 + .../src/physical_plan/collection.rs | 8 +- .../src/physical_plan/document/mod.rs | 2 + .../src/physical_plan/document/op.rs | 52 +- .../src/physical_plan/document/row_source.rs | 120 ++ .../document/timeseries_schema.rs | 4 + .../src/physical_plan/document/types.rs | 5 + .../src/physical_plan/gathered_source.rs | 29 + nodedb-physical/src/physical_plan/meta.rs | 35 +- nodedb-physical/src/physical_plan/mod.rs | 23 +- nodedb-physical/src/physical_plan/query.rs | 44 +- nodedb-physical/src/physical_plan/tiering.rs | 210 ++++ .../src/physical_plan/timeseries.rs | 108 ++ nodedb-physical/src/physical_plan/wire.rs | 2 - nodedb-query/src/expr_parse/mod.rs | 2 + nodedb-query/src/expr_parse/parser.rs | 153 +-- nodedb-query/src/expr_parse/predicate.rs | 228 ++++ nodedb-query/src/expr_parse/validate.rs | 120 ++ nodedb-query/src/msgpack_scan/filter.rs | 8 +- nodedb-query/src/scan_filter/types.rs | 36 +- nodedb-sql/src/aggregate_walk.rs | 87 +- nodedb-sql/src/catalog.rs | 5 + .../src/ddl_ast/collection_type/build.rs | 7 + nodedb-sql/src/ddl_ast/collection_type/kv.rs | 72 +- nodedb-sql/src/ddl_ast/graph_parse/cursor.rs | 278 +++++ nodedb-sql/src/ddl_ast/graph_parse/entry.rs | 153 ++- .../src/ddl_ast/graph_parse/fusion_params.rs | 211 ++-- nodedb-sql/src/ddl_ast/graph_parse/helpers.rs | 205 ---- nodedb-sql/src/ddl_ast/graph_parse/mod.rs | 16 +- .../src/ddl_ast/graph_parse/tokenizer.rs | 129 +- .../src/ddl_ast/graph_parse/variants.rs | 295 +++-- nodedb-sql/src/ddl_ast/mod.rs | 7 +- nodedb-sql/src/ddl_ast/parse/alert.rs | 434 ++++--- nodedb-sql/src/ddl_ast/parse/backup.rs | 113 +- nodedb-sql/src/ddl_ast/parse/change_stream.rs | 353 +++--- nodedb-sql/src/ddl_ast/parse/clause/cursor.rs | 543 +++++++++ nodedb-sql/src/ddl_ast/parse/clause/mod.rs | 7 + nodedb-sql/src/ddl_ast/parse/clause/token.rs | 225 ++++ nodedb-sql/src/ddl_ast/parse/cluster_admin.rs | 54 +- .../src/ddl_ast/parse/collection/alter_ops.rs | 544 +++++---- .../src/ddl_ast/parse/collection/body.rs | 474 +++++--- .../ddl_ast/parse/collection/column_list.rs | 619 +++++----- .../ddl_ast/parse/collection/dispatcher.rs | 173 ++- .../ddl_ast/parse/collection/engine_suffix.rs | 114 +- .../src/ddl_ast/parse/collection/flags.rs | 157 +-- .../src/ddl_ast/parse/collection/mod.rs | 1 + .../ddl_ast/parse/collection/type_clause.rs | 83 ++ .../ddl_ast/parse/collection/with_clause.rs | 310 +++-- .../src/ddl_ast/parse/conflict_policy.rs | 124 +- nodedb-sql/src/ddl_ast/parse/copy_from.rs | 368 +++--- nodedb-sql/src/ddl_ast/parse/copy_to.rs | 212 +--- nodedb-sql/src/ddl_ast/parse/custom_type.rs | 350 +++--- .../src/ddl_ast/parse/database/alter.rs | 158 ++- .../ddl_ast/parse/database/backup_restore.rs | 115 +- .../src/ddl_ast/parse/database/clone.rs | 145 ++- .../src/ddl_ast/parse/database/create.rs | 34 +- .../src/ddl_ast/parse/database/dispatch.rs | 99 +- .../src/ddl_ast/parse/database/drop_db.rs | 46 +- .../src/ddl_ast/parse/database/mirror.rs | 92 +- .../src/ddl_ast/parse/database/move_tenant.rs | 100 +- .../src/ddl_ast/parse/database/quota_spec.rs | 45 +- .../src/ddl_ast/parse/database/show_extras.rs | 59 +- .../src/ddl_ast/parse/database/use_db.rs | 21 +- .../ddl_ast/parse/database/with_options.rs | 77 +- nodedb-sql/src/ddl_ast/parse/dispatch.rs | 14 + nodedb-sql/src/ddl_ast/parse/grant.rs | 364 +++--- nodedb-sql/src/ddl_ast/parse/helpers.rs | 59 +- nodedb-sql/src/ddl_ast/parse/index.rs | 351 +++--- .../src/ddl_ast/parse/materialized_view.rs | 605 +++++----- nodedb-sql/src/ddl_ast/parse/mod.rs | 1 + nodedb-sql/src/ddl_ast/parse/oidc_provider.rs | 683 ++++------- nodedb-sql/src/ddl_ast/parse/schedule.rs | 372 +++--- nodedb-sql/src/ddl_ast/parse/sequence.rs | 379 +++--- nodedb-sql/src/ddl_ast/parse/synonym_group.rs | 155 +-- nodedb-sql/src/ddl_ast/parse/trigger.rs | 589 +++++----- nodedb-sql/src/ddl_ast/parse/user_auth.rs | 693 ++++++----- .../src/ddl_ast/parse/vector_primary.rs | 374 +++--- .../ddl_ast/statement/types/stream_view.rs | 5 + nodedb-sql/src/engine_rules/array.rs | 7 + nodedb-sql/src/engine_rules/columnar.rs | 31 +- .../src/engine_rules/continuous_aggregate.rs | 162 +++ .../src/engine_rules/document_schemaless.rs | 12 +- .../src/engine_rules/document_strict.rs | 12 +- nodedb-sql/src/engine_rules/kv.rs | 12 +- nodedb-sql/src/engine_rules/mod.rs | 6 +- nodedb-sql/src/engine_rules/params.rs | 48 +- nodedb-sql/src/engine_rules/resolve.rs | 6 +- nodedb-sql/src/engine_rules/rules.rs | 9 +- nodedb-sql/src/engine_rules/spatial.rs | 31 +- nodedb-sql/src/engine_rules/timeseries.rs | 93 +- .../src/engine_rules/timeseries_native.rs | 96 ++ nodedb-sql/src/error.rs | 12 + nodedb-sql/src/functions/arg_types.rs | 3 + .../src/functions/builtins/aggregates.rs | 57 + nodedb-sql/src/optimizer/point_get.rs | 2 +- nodedb-sql/src/parser/mod.rs | 1 + nodedb-sql/src/parser/query_function.rs | 488 ++++++++ nodedb-sql/src/planner/agg_naming.rs | 14 +- nodedb-sql/src/planner/aggregate.rs | 422 ++----- nodedb-sql/src/planner/aggregate_clauses.rs | 129 ++ nodedb-sql/src/planner/catalog_fold/walk.rs | 6 +- nodedb-sql/src/planner/cte/join_link.rs | 55 +- nodedb-sql/src/planner/cte/recursive_scan.rs | 5 +- nodedb-sql/src/planner/defaults/compiled.rs | 38 + nodedb-sql/src/planner/dml/insert.rs | 221 +++- nodedb-sql/src/planner/dml/mod.rs | 1 + nodedb-sql/src/planner/dml/update_delete.rs | 258 +--- nodedb-sql/src/planner/dml/update_from.rs | 634 ++++++++++ .../planner/dml_helpers/insert_select_bind.rs | 87 +- nodedb-sql/src/planner/dml_helpers/mod.rs | 4 +- nodedb-sql/src/planner/group_by.rs | 71 +- nodedb-sql/src/planner/having.rs | 24 +- nodedb-sql/src/planner/join/constraint.rs | 85 +- nodedb-sql/src/planner/join/plan.rs | 100 +- nodedb-sql/src/planner/lateral/plan.rs | 1 + nodedb-sql/src/planner/merge/plan.rs | 1 + nodedb-sql/src/planner/mod.rs | 1 + nodedb-sql/src/planner/search_scope/scope.rs | 2 + .../src/planner/select/comma_lateral.rs | 1 + .../src/planner/select/entry/join_columns.rs | 246 ++++ nodedb-sql/src/planner/select/entry/mod.rs | 2 + nodedb-sql/src/planner/select/mod.rs | 1 + .../src/planner/select/order_by/apply.rs | 27 +- nodedb-sql/src/planner/select/order_by/mod.rs | 2 + .../select/order_by/timeseries_native.rs | 99 ++ nodedb-sql/src/planner/select/select_stmt.rs | 36 +- nodedb-sql/src/planner/select/where_search.rs | 6 +- nodedb-sql/src/resolver/column_naming.rs | 70 ++ nodedb-sql/src/resolver/columns.rs | 335 +++++- .../src/resolver/expr/convert/identifier.rs | 11 +- nodedb-sql/src/resolver/mod.rs | 1 + nodedb-sql/src/resolver/scope.rs | 23 + nodedb-sql/src/temporal.rs | 13 + nodedb-sql/src/types/plan/variants/plan.rs | 27 +- .../src/types/plan/variants/timeseries.rs | 2 + nodedb-sql/src/types/query.rs | 13 + nodedb-sql/src/visitor/plan_visitor/args.rs | 3 + .../src/visitor/plan_visitor/dispatch.rs | 8 +- .../src/visitor/plan_visitor/trait_def.rs | 2 +- .../cases/aggregate_filter_planning.rs | 107 ++ nodedb-sql/tests/sql_suite/cases/mod.rs | 3 + .../cases/timeseries_aggregate_routing.rs | 183 +++ .../sql_suite/cases/unbounded_defaults.rs | 154 +++ .../node/inspect/timeseries.rs | 2 + .../src/pgwire_harness/start.rs | 21 + nodedb-test-support/src/tx_batch_helpers.rs | 3 + nodedb-types/src/config/tuning/config.rs | 4 +- nodedb-types/src/config/tuning/data_plane.rs | 6 - nodedb-types/src/config/tuning/engines.rs | 9 + nodedb-types/src/config/tuning/mod.rs | 2 + nodedb-types/src/config/tuning/promql.rs | 65 + nodedb-types/src/crdt_preview.rs | 4 + nodedb-types/src/error/sqlstate.rs | 5 + nodedb-types/src/numeric_cmp.rs | 20 + nodedb-types/src/timeseries/continuous_agg.rs | 11 + nodedb-types/src/timeseries/mod.rs | 5 +- nodedb-types/src/timeseries/partition.rs | 25 +- nodedb-types/src/timeseries/sync.rs | 14 - nodedb-types/src/value/coerce.rs | 136 +++ .../src/bootstrap/background_loops/spawn.rs | 6 + .../src/bootstrap/write_group_settle/scan.rs | 1 + nodedb/src/bridge/envelope/error_code_from.rs | 1 + .../control/array_sync/raft_apply/common.rs | 2 + nodedb/src/control/backup/node_snapshot.rs | 1 + nodedb/src/control/backup/restore/guard.rs | 1 + .../backup/restore/redo_reissue/commit.rs | 2 + nodedb/src/control/backup/snapshot_keys.rs | 2 + .../async_dispatch/continuous_aggregate.rs | 44 +- .../post_apply/gateway_invalidation.rs | 37 +- .../control/catalog_entry/post_apply/mod.rs | 3 + .../post_apply/retention_policy.rs | 53 +- .../control/clone/resolver/rewrite_engine.rs | 4 + .../control/cluster/array_executor/refusal.rs | 1 + .../scheduler/driver/core/commit_redo.rs | 3 + .../driver/core/commit_resolve/verdict.rs | 1 + .../driver/core/dispatch/active_dispatch.rs | 4 +- .../driver/core/dispatch/static_dispatch.rs | 2 + .../scheduler/driver/core/redo_propose.rs | 1 + .../calvin/scheduler/driver/core/routing.rs | 19 +- .../cluster/calvin/scheduler/recovery.rs | 1 + .../control/cluster/execution_error_wire.rs | 1 + .../control/cluster/metadata_applier/wedge.rs | 1 + .../cluster/start_raft/sequencer_route.rs | 1 + nodedb/src/control/crdt_admission/apply.rs | 5 + nodedb/src/control/crdt_admission/restore.rs | 1 + .../control/crdt_admission/test_support.rs | 1 + nodedb/src/control/crdt_post_image_policy.rs | 108 ++ .../distributed_applier/apply_loop/lane.rs | 1 + .../distributed_applier/apply_loop/start.rs | 11 + .../apply_loop/write_dispatch.rs | 16 +- nodedb/src/control/exec_receiver/executor.rs | 3 + nodedb/src/control/gateway/core.rs | 47 +- nodedb/src/control/gateway/dispatch_local.rs | 8 + nodedb/src/control/gateway/dispatch_remote.rs | 6 + .../control/gateway/dispatch_remote_stream.rs | 2 + nodedb/src/control/gateway/dispatcher.rs | 9 + .../error_map/class_parity/error_index.rs | 3 +- .../error_map/class_parity/error_samples.rs | 1 + nodedb/src/control/gateway/error_map/resp.rs | 1 + nodedb/src/control/gateway/outcome.rs | 7 +- nodedb/src/control/gateway/sql_execute.rs | 22 +- nodedb/src/control/gateway/stream.rs | 2 +- .../src/control/gateway/version_set/keys.rs | 18 + nodedb/src/control/gateway/version_set/mod.rs | 7 +- .../control/gateway/version_set/plan_keys.rs | 21 +- .../gateway/version_set/provider_keys.rs | 144 +++ nodedb/src/control/insert_select/copy_rows.rs | 116 +- .../control/insert_select/expand_staged.rs | 149 +-- .../src/control/insert_select/orchestrator.rs | 290 +++-- .../maintenance/clone_materializer/chained.rs | 2 + .../clone_materializer/dispatch.rs | 32 +- .../clone_materializer/document.rs | 39 - .../maintenance/clone_materializer/mod.rs | 2 +- .../merge_orchestrator/expand_staged_merge.rs | 33 +- .../merge_orchestrator/orchestrator.rs | 53 +- nodedb/src/control/mod.rs | 1 + nodedb/src/control/orchestrated_write.rs | 19 +- nodedb/src/control/planner/auto_tier.rs | 351 ++---- .../control/planner/calvin/dependent_recon.rs | 25 +- .../control/planner/calvin/dispatch_multi.rs | 55 +- .../control/planner/calvin/edge_truncate.rs | 2 + .../calvin/tx_class/write_keys/columnar.rs | 11 +- .../calvin/tx_class/write_keys/plan.rs | 1 + .../src/control/planner/calvin/write_class.rs | 12 +- .../planner/catalog_adapter/adapter.rs | 5 +- .../planner/catalog_adapter/cagg_relation.rs | 226 ++++ .../control/planner/catalog_adapter/mod.rs | 1 + .../catalog_adapter/sql_catalog_impl.rs | 9 +- .../planner/materialized_sum/resolve.rs | 1 + nodedb/src/control/planner/plan_error_map.rs | 3 + .../planner/redaction_refusal/aggregate.rs | 114 +- .../control/planner/redaction_refusal/mod.rs | 1 + .../planner/redaction_refusal/orchestrated.rs | 209 ++++ .../control/planner/redaction_refusal/plan.rs | 226 +++- .../control/planner/rls_injection/columnar.rs | 22 +- .../control/planner/rls_injection/document.rs | 117 +- .../src/control/planner/rls_injection/meta.rs | 2 + .../rls_injection/permission_tree/columnar.rs | 22 +- .../rls_injection/permission_tree/document.rs | 80 +- .../rls_injection/permission_tree/meta.rs | 2 + .../rls_injection/permission_tree/query.rs | 8 +- .../control/planner/rls_injection/query.rs | 27 +- .../aggregate/input_sourced.rs | 36 +- .../planner/sql_plan_convert/aggregate/mod.rs | 6 +- .../sql_plan_convert/aggregate/plan.rs | 322 +++-- .../sql_plan_convert/aggregate/spec.rs | 331 +++++- .../planner/sql_plan_convert/convert.rs | 1 + .../sql_plan_convert/dml/balanced_gate.rs | 50 +- .../sql_plan_convert/dml/insert/convert.rs | 123 +- .../planner/sql_plan_convert/dml/merge.rs | 34 +- .../planner/sql_plan_convert/dml/mod.rs | 2 + .../dml/update_delete/update_from.rs | 53 +- .../planner/sql_plan_convert/dml/upsert.rs | 22 +- .../sql_plan_convert/dml/write_checks.rs | 25 + .../sql_plan_convert/expr/inline_cte.rs | 34 +- .../control/planner/sql_plan_convert/mod.rs | 1 + .../output_schema/build/aggregate.rs | 9 +- .../output_schema/build/fixtures.rs | 2 + .../sql_plan_convert/output_schema_types.rs | 2 + .../planner/sql_plan_convert/row_source.rs | 385 ++++++ .../planner/sql_plan_convert/scan/core.rs | 63 +- .../planner/sql_plan_convert/scan/join.rs | 22 +- .../planner/sql_plan_convert/scan/mod.rs | 1 + .../sql_plan_convert/scan/timeseries.rs | 86 +- .../planner/sql_plan_convert/scan_params.rs | 1 + .../planner/sql_plan_convert/set_ops.rs | 195 +-- .../sql_plan_convert/visitor/arms_dml.rs | 2 + .../visitor/arms_scan_search.rs | 2 + .../sql_plan_convert/visitor/arms_set_ops.rs | 9 +- nodedb/src/control/promql/ast.rs | 5 +- nodedb/src/control/promql/error.rs | 54 + nodedb/src/control/promql/evaluator/call.rs | 18 +- .../src/control/promql/evaluator/context.rs | 12 +- .../src/control/promql/evaluator/dispatch.rs | 63 +- nodedb/src/control/promql/evaluator/query.rs | 78 +- .../src/control/promql/evaluator/selector.rs | 28 +- nodedb/src/control/promql/label.rs | 50 +- nodedb/src/control/promql/lexer.rs | 77 +- nodedb/src/control/promql/parser.rs | 138 ++- nodedb/src/control/promql/remote_proto.rs | 195 ++- nodedb/src/control/promql/types.rs | 4 + .../security/catalog/collection_generated.rs | 86 ++ nodedb/src/control/security/catalog/mod.rs | 1 + .../security/identity/authenticated.rs | 6 + .../security/identity/plan_permission.rs | 18 + .../src/control/security/permission/check.rs | 8 + .../src/control/security/redaction/apply.rs | 34 + .../dispatch_utils/change_events/extract.rs | 11 +- .../dispatch_utils/change_events/redo.rs | 1 + .../control/server/dispatch_utils/dispatch.rs | 17 + .../server/dispatch_utils/durable_write.rs | 11 +- .../submit_write/funnel/dispatch.rs | 4 +- .../submit_write/funnel/driver.rs | 3 + .../submit_write/funnel/pending.rs | 1 + .../dispatch_utils/submit_write/params.rs | 4 + .../control/server/dispatch_utils/types.rs | 5 + .../dispatch_utils/unlogged_dispatch.rs | 3 + .../server/exchange/all_cores/dispatch.rs | 2 + .../control/server/exchange/cluster_leaf.rs | 18 +- .../src/control/server/exchange/full_scan.rs | 6 +- nodedb/src/control/server/exchange/mod.rs | 1 + .../control/server/exchange/owner_dispatch.rs | 54 + .../server/exchange/resolve/cagg_rows.rs | 99 ++ .../resolve/exchange/aggregate_input_arm.rs | 20 +- .../resolve/exchange/by_name_join_arm.rs | 158 +++ .../exchange/resolve/exchange/dispatch.rs | 20 +- .../server/exchange/resolve/exchange/entry.rs | 2 +- .../exchange/resolve/exchange/gather_arm.rs | 7 + .../resolve/exchange/hash_join_arm.rs | 29 +- .../server/exchange/resolve/exchange/mod.rs | 2 + .../resolve/exchange/post_process_arm.rs | 27 +- .../exchange/resolve/exchange/set_op_arm.rs | 19 +- .../exchange/resolve/exchange/shuffle_arm.rs | 17 + .../resolve/exchange/txn_source_arm.rs | 132 +++ .../server/exchange/resolve/join_input.rs | 9 +- .../server/exchange/resolve/materialize.rs | 81 +- .../control/server/exchange/resolve/mod.rs | 1 + .../server/graph_dispatch/cluster_resolve.rs | 1 + .../src/control/server/graph_dispatch/hop.rs | 1 + .../server/graph_dispatch/keyed_read.rs | 1 + .../match_scatter/round_loop.rs | 1 + .../match_scatter/round_zero.rs | 1 + .../graph_dispatch/rag_fusion/stages.rs | 1 + nodedb/src/control/server/http/routes/crdt.rs | 113 +- .../routes/promql/handlers/annotations.rs | 212 ++-- .../http/routes/promql/handlers/instant.rs | 72 +- .../http/routes/promql/handlers/labels.rs | 173 ++- .../http/routes/promql/handlers/metadata.rs | 136 +-- .../http/routes/promql/handlers/range.rs | 163 ++- .../http/routes/promql/handlers/series.rs | 136 ++- .../server/http/routes/promql/helpers.rs | 243 ++-- .../http/routes/promql/metric_registration.rs | 82 ++ .../control/server/http/routes/promql/mod.rs | 10 +- .../server/http/routes/promql/params.rs | 197 ++++ .../server/http/routes/promql/remote.rs | 288 ++--- .../server/http/routes/promql/remote_read.rs | 214 ++++ .../http/routes/promql/source/budget.rs | 62 + .../http/routes/promql/source/discovery.rs | 177 +++ .../server/http/routes/promql/source/load.rs | 68 ++ .../server/http/routes/promql/source/mod.rs | 22 + .../http/routes/promql/source/node_metrics.rs | 141 +++ .../http/routes/promql/source/pushdown.rs | 132 +++ .../server/http/routes/promql/source/rows.rs | 263 +++++ .../server/http/routes/promql/source/scan.rs | 70 ++ .../server/http/routes/promql/source/scope.rs | 34 + .../http/routes/promql/source/windows.rs | 191 +++ .../server/http/routes/promql/types.rs | 37 - .../routes/query/materialized/orchestrated.rs | 4 +- .../http/routes/query/materialized/shape.rs | 3 +- .../server/http/routes/query/ndjson.rs | 10 +- .../control/server/http/routes/wasm_upload.rs | 22 +- .../server/http/routes/ws_rpc/execute_sql.rs | 11 +- nodedb/src/control/server/http/server.rs | 10 +- .../src/control/server/ilp_batch/dispatch.rs | 4 +- nodedb/src/control/server/ilp_batch/mod.rs | 3 +- .../src/control/server/ilp_batch/preflight.rs | 56 +- nodedb/src/control/server/ilp_listener.rs | 4 +- nodedb/src/control/server/mod.rs | 1 - .../native/dispatch/plan_builder/columnar.rs | 2 +- .../native/dispatch/plan_builder/document.rs | 13 +- .../dispatch/plan_builder/document_bulk.rs | 48 +- .../dispatch/plan_builder/generated_gate.rs | 93 ++ .../native/dispatch/plan_builder/helpers.rs | 29 + .../native/dispatch/plan_builder/mod.rs | 5 +- .../native/dispatch/plan_builder/query.rs | 2 +- .../native/dispatch/plan_builder/spatial.rs | 2 +- .../dispatch/plan_builder/timeseries.rs | 28 +- .../server/native/dispatch/raw_dispatch.rs | 20 +- .../server/native/dispatch/response.rs | 3 +- .../server/native/dispatch/streaming.rs | 1 + .../pgwire/catalog/dropped_collections.rs | 21 +- .../control/server/pgwire/catalog/provider.rs | 15 +- .../pgwire/catalog/tables/catalog_compat.rs | 3 +- .../pgwire/catalog/tables/collections.rs | 9 +- .../pgwire/catalog/tables/pg_attribute.rs | 3 +- .../server/pgwire/catalog/tables/pg_class.rs | 3 +- .../server/pgwire/catalog/tables/pg_index.rs | 3 +- .../server/pgwire/handler/dispatch/local.rs | 2 + .../server/pgwire/handler/dispatch/routing.rs | 5 +- .../pgwire/handler/routing/calvin_dispatch.rs | 2 +- .../handler/routing/dispatch_loop/run.rs | 11 +- .../handler/routing/dispatch_loop/task.rs | 2 +- .../handler/routing/execute_dml_hooks.rs | 6 +- .../handler/routing/gateway_dispatch.rs | 6 +- .../pgwire/handler/routing/streaming.rs | 14 +- .../control/server/pgwire/handler/sql_exec.rs | 4 +- .../control/server/pgwire/handler/submit.rs | 7 + .../control/server/pgwire/types/error_map.rs | 3 + nodedb/src/control/server/post_aggregate.rs | 289 ----- .../server/response_shape/calvin_fold.rs | 8 +- .../response_shape/compose/materialized.rs | 1 + .../server/response_shape/redaction/query.rs | 196 +++- .../types/plan_kind/columnar_family.rs | 6 + .../types/plan_kind/describe.rs | 5 +- .../server/shared/authorization/capability.rs | 22 +- .../server/shared/authorization/mod.rs | 2 +- .../shared/authorization/requirements.rs | 140 ++- .../authorization/requirements/collect.rs | 54 +- .../authorization/requirements/gathered.rs | 104 ++ .../authorization/requirements/query.rs | 39 +- .../server/shared/authorization/service.rs | 2 +- .../control/server/shared/clone_write/gate.rs | 5 + .../server/shared/ddl/neutral/alert/create.rs | 34 +- .../server/shared/ddl/neutral/alert/show.rs | 2 +- .../ddl/neutral/collection/copy_to/entry.rs | 2 +- .../ddl/neutral/collection/create/build.rs | 18 +- .../collection/create/build_primary_engine.rs | 59 + .../collection/create/engine_option/mod.rs | 11 +- .../collection/create/engine_option/parse.rs | 402 ------- .../create/engine_option/validate.rs | 94 +- .../shared/ddl/neutral/collection/describe.rs | 21 +- .../ddl/neutral/collection/dml/insert.rs | 18 +- .../collection/dml/parse/dispatch_write.rs | 2 +- .../ddl/neutral/collection/dml/upsert.rs | 21 +- .../ddl/neutral/collection/enforcement.rs | 12 +- .../ddl/neutral/collection/index/kv_index.rs | 2 + .../ddl/neutral/collection/index_fanout.rs | 1 + .../shared/ddl/neutral/collection/register.rs | 17 +- .../shared/ddl/neutral/column_default.rs | 71 +- .../shared/ddl/neutral/constraint/handlers.rs | 42 +- .../shared/ddl/neutral/constraint/parse.rs | 12 +- .../shared/ddl/neutral/constraint/show.rs | 3 +- .../shared/ddl/neutral/constraint/support.rs | 25 +- .../ddl/neutral/continuous_agg/create.rs | 202 ++-- .../shared/ddl/neutral/continuous_agg/mod.rs | 6 +- .../ddl/neutral/continuous_agg/parse.rs | 203 +++- .../ddl/neutral/continuous_agg/periodic.rs | 133 +++ .../ddl/neutral/continuous_agg/refresh.rs | 191 +++ .../ddl/neutral/continuous_agg/register.rs | 28 +- .../shared/ddl/neutral/continuous_agg/show.rs | 145 ++- .../shared/ddl/neutral/convert/driver.rs | 78 +- .../shared/ddl/neutral/convert/fields.rs | 67 ++ .../server/shared/ddl/neutral/convert/mod.rs | 2 + .../shared/ddl/neutral/convert/source_key.rs | 161 +++ .../ddl/neutral/convert/typeguard_columns.rs | 252 +++- .../server/shared/ddl/neutral/crdt_ops.rs | 3 +- .../shared/ddl/neutral/dsl/crdt_merge.rs | 3 +- .../shared/ddl/neutral/dsl/search_fusion.rs | 24 +- .../server/shared/ddl/neutral/explain_ddl.rs | 43 +- .../shared/ddl/neutral/explain_tiers.rs | 3 +- .../shared/ddl/neutral/function/alter.rs | 10 +- .../ddl/neutral/function/create/handler.rs | 4 +- .../shared/ddl/neutral/function/drop.rs | 4 +- .../ddl/neutral/function/wasm_aggregate.rs | 4 +- .../ddl/neutral/function/wasm_create.rs | 4 +- .../shared/ddl/neutral/graph_ops/dispatch.rs | 4 +- .../shared/ddl/neutral/graph_ops/edge.rs | 14 +- .../shared/ddl/neutral/kv_atomic/dispatch.rs | 23 +- .../shared/ddl/neutral/kv_atomic/handlers.rs | 103 +- .../shared/ddl/neutral/kv_atomic/mod.rs | 3 +- .../ddl/neutral/kv_sorted_index/dispatch.rs | 2 + .../server/shared/ddl/neutral/period_lock.rs | 6 +- .../ddl/neutral/procedure/create/handler.rs | 4 +- .../shared/ddl/neutral/procedure/drop.rs | 4 +- .../neutral/query_functions/balance_as_of.rs | 24 +- .../convert_currency_lookup.rs | 34 +- .../ddl/neutral/query_functions/helpers.rs | 23 - .../ddl/neutral/query_functions/router.rs | 66 +- .../query_functions/temporal_lookup.rs | 25 +- .../query_functions/verify_audit_chain.rs | 36 +- .../neutral/query_functions/verify_balance.rs | 16 +- .../query_functions/verify_hash_chain.rs | 13 +- .../server/shared/ddl/neutral/rate_gate.rs | 2 + .../shared/ddl/neutral/router/dispatch.rs | 124 +- .../server/shared/ddl/neutral/router/mod.rs | 1 + .../shared/ddl/neutral/router/route_tokens.rs | 214 ++++ .../ddl/neutral/router/string_engine_ops.rs | 117 +- .../neutral/router/string_introspection.rs | 11 + .../ddl/neutral/router/string_schema.rs | 279 +++-- .../ddl/neutral/router/string_streaming.rs | 81 +- .../ddl/neutral/router/string_versioning.rs | 162 ++- .../ddl/neutral/router/typed_automation.rs | 19 +- .../ddl/neutral/router/typed_streamview.rs | 4 + .../server/shared/ddl/neutral/show_changes.rs | 22 +- .../shared/ddl/neutral/timeseries/alter.rs | 118 +- .../shared/ddl/neutral/timeseries/create.rs | 34 +- .../shared/ddl/neutral/timeseries/helpers.rs | 23 + .../shared/ddl/neutral/timeseries/rewrite.rs | 174 +-- .../shared/ddl/neutral/timeseries/show.rs | 173 +-- .../server/shared/ddl/neutral/transfer.rs | 59 +- .../ddl/neutral/transfer_cross_shard.rs | 2 +- .../ddl/neutral/tree_ops/create_index.rs | 9 +- .../shared/ddl/neutral/trigger/create.rs | 4 +- .../server/shared/ddl/neutral/trigger/drop.rs | 24 +- .../shared/ddl/neutral/typeguard/handlers.rs | 179 +-- .../ddl/neutral/typeguard/injected_expr.rs | 31 +- .../shared/ddl/neutral/typeguard/parse.rs | 33 + .../shared/ddl/neutral/typeguard/validate.rs | 12 +- .../ddl/neutral/version_history/restore.rs | 4 +- .../shared/ddl/neutral/weighted_pick.rs | 38 +- .../src/control/server/shared/ddl/result.rs | 11 + nodedb/src/control/server/shared/plan_util.rs | 8 +- nodedb/src/control/server/shared/retry.rs | 1 + .../control/server/shared/returning/inject.rs | 14 +- .../shared/session/commit/read_validation.rs | 1 + .../server/shared/session/commit/run.rs | 4 +- .../shared/session/commit/single_shard.rs | 13 +- .../server/shared/session/commit_calvin.rs | 6 + .../server/shared/session/expander_stage.rs | 23 +- .../server/shared/session/leader_forward.rs | 2 + .../server/shared/statement_exec/answer.rs | 3 +- .../server/shared/statement_exec/dispatch.rs | 4 +- .../control/server/shared/txn_route/route.rs | 4 +- .../shared/write_admission/admission_keys.rs | 1 + .../shared/write_admission/leader_gate.rs | 2 +- .../predicate/txn_buffering/classify.rs | 72 +- .../predicate/txn_buffering/mod.rs | 2 +- .../server/shared/write_admission/route.rs | 15 +- .../control/server/shuffle/producer_hook.rs | 2 + .../server/sync/async_dispatch/delta/apply.rs | 20 +- .../sync/async_dispatch/delta/compensation.rs | 1 + .../sync/async_dispatch/shape/payload.rs | 2 +- .../sync/async_dispatch/shape/snapshot.rs | 5 +- .../server/sync/raft_dispatch/propose.rs | 9 +- .../server/sync/raft_dispatch/response.rs | 12 +- .../server/sync/raft_dispatch/write.rs | 3 +- nodedb/src/control/server/sync/refusal.rs | 2 + .../src/control/server/sync/shape/registry.rs | 38 +- .../src/control/server/wal_dispatch/core.rs | 1 + .../control/server/wal_dispatch/timeseries.rs | 20 +- nodedb/src/control/source_read/context.rs | 22 + nodedb/src/control/source_read/mod.rs | 13 + nodedb/src/control/source_read/pages.rs | 136 +++ nodedb/src/control/source_read/query.rs | 104 ++ nodedb/src/control/source_read/reader.rs | 117 ++ nodedb/src/control/source_read/shape.rs | 124 ++ nodedb/src/control/state/fields.rs | 9 - nodedb/src/control/state/init.rs | 1 - nodedb/src/control/state/init_prod/open.rs | 1 - nodedb/src/control/state/methods.rs | 16 +- nodedb/src/control/system_txn/live.rs | 11 +- .../expand_staged_update_from_join.rs | 31 +- .../orchestrator.rs | 82 +- .../wal_replication/decode/columnar.rs | 31 +- .../wal_replication/decode/document.rs | 26 +- .../wal_replication/decode/document_join.rs | 46 +- .../control/wal_replication/decode/entry.rs | 4 +- .../decode/entry_columnar_family.rs | 3 + .../wal_replication/decode/entry_document.rs | 23 +- .../decode/transaction_redo.rs | 1 + .../wal_replication/encode/document.rs | 34 +- .../wal_replication/encode/document_join.rs | 10 +- .../control/wal_replication/encode/entry.rs | 22 +- .../encode/entry_columnar_family.rs | 15 +- .../wal_replication/encode/entry_document.rs | 36 +- .../encode/transaction_redo.rs | 1 + .../wal_replication/transaction_redo/apply.rs | 2 + .../transaction_redo/payload.rs | 1 + .../wal_replication/types/replicated_entry.rs | 11 + .../wal_replication/types/replicated_write.rs | 38 +- nodedb/src/control/write_resolve/propose.rs | 6 +- nodedb/src/control/write_resolve/run.rs | 18 +- .../src/control/write_resolve/timeseries.rs | 6 +- nodedb/src/data/executor/array_checkpoint.rs | 1 + .../data/executor/cascade_journal_tests.rs | 1 + .../data/executor/columnar_checkpoint/load.rs | 3 +- .../executor/core_loop/maintenance_state.rs | 13 + nodedb/src/data/executor/core_loop/tick.rs | 8 +- nodedb/src/data/executor/dispatch/crdt.rs | 1 + nodedb/src/data/executor/dispatch/document.rs | 2 + .../data/executor/dispatch/document_dml.rs | 33 +- .../src/data/executor/dispatch/graph/route.rs | 9 +- nodedb/src/data/executor/dispatch/meta.rs | 44 +- .../dispatch/meta_retention/handlers.rs | 40 +- .../dispatch/meta_retention/ts_retention.rs | 263 +++++ nodedb/src/data/executor/dispatch/query.rs | 8 + .../src/data/executor/dispatch/timeseries.rs | 19 + .../data/executor/enforcement/chain_link.rs | 3 + .../enforcement/materialized_sum/apply.rs | 10 +- .../enforcement/materialized_sum/rmw.rs | 3 + nodedb/src/data/executor/enforcement/mod.rs | 1 + .../data/executor/enforcement/typeguard.rs | 537 --------- .../executor/enforcement/typeguard/check.rs | 501 ++++++++ .../enforcement/typeguard/compiled.rs | 156 +++ .../executor/enforcement/typeguard/hook.rs | 173 +++ .../executor/enforcement/typeguard/inject.rs | 50 + .../executor/enforcement/typeguard/mod.rs | 15 + .../src/data/executor/enforcement/unique.rs | 23 +- .../enforcement/update_transitions.rs | 99 ++ .../src/data/executor/handlers/accum/feed.rs | 36 +- .../data/executor/handlers/accum/finalize.rs | 100 +- .../src/data/executor/handlers/accum/merge.rs | 187 ++- .../src/data/executor/handlers/accum/new.rs | 158 ++- .../src/data/executor/handlers/accum/state.rs | 32 +- .../executor/handlers/aggregate/cache_key.rs | 20 +- .../data/executor/handlers/aggregate/exec.rs | 319 ++--- .../executor/handlers/aggregate/input_docs.rs | 81 ++ .../data/executor/handlers/aggregate/mod.rs | 4 +- .../data/executor/handlers/aggregate/rows.rs | 1 + .../handlers/aggregate/shuffle_merge.rs | 5 +- .../executor/handlers/aggregate/state_emit.rs | 34 +- .../aggregate/streaming/accumulate.rs | 13 +- .../handlers/aggregate/streaming/finalize.rs | 30 +- .../handlers/bulk_dml/update_project.rs | 64 +- .../data/executor/handlers/columnar_agg.rs | 765 ------------ .../executor/handlers/columnar_agg_support.rs | 252 ---- .../executor/handlers/columnar_filter/dict.rs | 3 +- .../executor/handlers/columnar_filter/eval.rs | 111 +- .../columnar_filter/memtable_source.rs | 12 +- .../executor/handlers/columnar_filter/mod.rs | 6 +- .../columnar_filter/partition_source.rs | 12 +- .../columnar_read/materialize_scan.rs | 163 +-- .../columnar_read/materialize_scan_ts.rs | 6 +- .../handlers/columnar_read/scan/execute.rs | 16 +- .../executor/handlers/compact/maintenance.rs | 28 + .../handlers/control/calvin_overlay_stage.rs | 2 + .../handlers/control/calvin_reply/images.rs | 62 +- .../handlers/control/calvin_reply/payload.rs | 3 + .../handlers/control/calvin_reply/stage.rs | 25 +- .../handlers/control/calvin_reply/target.rs | 21 +- .../control/checkpoint_durable_lsn.rs | 23 +- .../executor/handlers/control/crdt_doc.rs | 203 ++++ .../handlers/control/crdt_materialize.rs | 3 + .../executor/handlers/control/crdt_preview.rs | 1 + .../handlers/control/range_scan_versioned.rs | 53 +- .../executor/handlers/control/snapshot.rs | 37 +- nodedb/src/data/executor/handlers/convert.rs | 33 +- .../executor/handlers/document/index_fetch.rs | 44 +- .../executor/handlers/document/read/fetch.rs | 11 +- .../document/read/materialize_scan.rs | 27 + .../handlers/document/resolve/apply_row.rs | 8 + .../handlers/document/resolve/upsert.rs | 17 + .../handlers/document/sort/compare.rs | 17 +- .../handlers/document/write/batch_insert.rs | 186 ++- .../document/write/batch_insert_judge.rs | 217 ++++ .../executor/handlers/document/write/mod.rs | 1 + .../handlers/document/write/register.rs | 8 + .../executor/handlers/graph_rag_stages.rs | 13 +- .../executor/handlers/graph_rag_triple.rs | 43 +- .../executor/handlers/grouping_sets_exec.rs | 408 +++---- .../executor/handlers/join/grace_drive.rs | 8 +- .../executor/handlers/join/hash_handlers.rs | 16 +- .../executor/handlers/join/lateral/inner.rs | 111 ++ .../handlers/join/lateral/loop_handler.rs | 116 +- .../executor/handlers/join/lateral/mod.rs | 1 + .../executor/handlers/join/lateral/shared.rs | 69 +- .../executor/handlers/join/lateral/top_k.rs | 119 +- nodedb/src/data/executor/handlers/join/mod.rs | 1 + .../executor/handlers/join/nested_loop.rs | 54 +- .../src/data/executor/handlers/join/params.rs | 8 + .../data/executor/handlers/join/side_rows.rs | 47 + .../data/executor/handlers/join/sort_merge.rs | 56 +- .../data/executor/handlers/join/support.rs | 212 +++- .../data/executor/handlers/join_source_map.rs | 204 ++++ .../executor/handlers/kv/predicate/matches.rs | 7 +- nodedb/src/data/executor/handlers/kv/scan.rs | 140 ++- .../data/executor/handlers/merge/dispatch.rs | 15 +- .../executor/handlers/merge/source_map.rs | 180 +-- .../data/executor/handlers/merge_helpers.rs | 24 +- .../merge_orchestrated/apply/insert_rows.rs | 1 + .../merge_orchestrated/apply/update_rows.rs | 3 + .../merge_orchestrated/apply_support.rs | 3 +- .../handlers/merge_orchestrated/plan.rs | 53 +- nodedb/src/data/executor/handlers/mod.rs | 7 +- .../executor/handlers/point/apply_put/core.rs | 4 + .../handlers/point/apply_put/enforce.rs | 28 +- .../handlers/point/apply_put/index.rs | 1 + .../handlers/point/apply_put/stored_body.rs | 23 +- .../handlers/point/apply_put/types.rs | 6 + .../src/data/executor/handlers/point/get.rs | 24 +- .../data/executor/handlers/point/insert.rs | 1 + .../src/data/executor/handlers/point/put.rs | 1 + .../executor/handlers/point/update/exec.rs | 80 ++ .../handlers/point/update/post_image.rs | 39 +- .../data/executor/handlers/provider_scan.rs | 2 +- .../src/data/executor/handlers/recursive.rs | 250 +--- .../executor/handlers/recursive_iterate.rs | 402 +++++++ .../data/executor/handlers/returning_rows.rs | 39 +- .../data/executor/handlers/snapshot/create.rs | 38 +- .../handlers/snapshot/restore_segments.rs | 128 ++ .../src/data/executor/handlers/sort_utils.rs | 19 +- .../executor/handlers/spatial/full_scan.rs | 11 +- .../executor/handlers/spatial/rtree_scan.rs | 4 +- .../data/executor/handlers/spill/columnar.rs | 105 -- .../src/data/executor/handlers/spill/core.rs | 55 +- .../data/executor/handlers/spill/groupby.rs | 11 +- .../src/data/executor/handlers/spill/mod.rs | 1 - .../executor/handlers/timeseries/aggregate.rs | 68 +- .../executor/handlers/timeseries/cagg_cut.rs | 320 +++++ .../executor/handlers/timeseries/cagg_fold.rs | 476 ++++++++ .../handlers/timeseries/cagg_persist.rs | 326 ++++++ .../handlers/timeseries/cagg_test_support.rs | 125 ++ .../executor/handlers/timeseries/cell_read.rs | 7 +- .../executor/handlers/timeseries/encode.rs | 300 +++-- .../executor/handlers/timeseries/flush.rs | 165 ++- .../handlers/timeseries/flush_buckets.rs | 104 ++ .../handlers/timeseries/flush_commit.rs | 288 +++++ .../handlers/timeseries/flush_crash_tests.rs | 385 ++++++ .../handlers/timeseries/flush_interval.rs | 48 + .../handlers/timeseries/flush_rollforward.rs | 227 ++++ .../handlers/timeseries/group_flush.rs | 19 +- .../timeseries/ingest_resolved_fit.rs | 2 +- .../data/executor/handlers/timeseries/mod.rs | 17 + .../handlers/timeseries/partition_read.rs | 65 +- .../handlers/timeseries/partition_rewrite.rs | 373 ++++++ .../handlers/timeseries/partitions.rs | 64 + .../handlers/timeseries/raw_scan/gather.rs | 165 +++ .../handlers/timeseries/raw_scan/mod.rs | 2 + .../timeseries/raw_scan/partition_scan.rs | 18 +- .../handlers/timeseries/raw_scan/scan.rs | 139 +-- .../handlers/timeseries/read_aggregate.rs | 449 +++++++ .../data/executor/handlers/timeseries/scan.rs | 139 ++- .../handlers/timeseries/staged_fold.rs | 131 +++ .../executor/handlers/timeseries/tier_fold.rs | 123 ++ .../handlers/timeseries/tier_route.rs | 295 +++++ .../handlers/timeseries/time_range.rs | 100 +- .../executor/handlers/timeseries/truncate.rs | 5 +- .../executor/handlers/timeseries_gap_fill.rs | 47 +- .../data/executor/handlers/timeseries_wal.rs | 188 +-- .../handlers/timeseries_wal_payload.rs | 94 +- .../handlers/transaction/overlay/mod.rs | 2 +- .../transaction/overlay/timeseries_merge.rs | 55 +- .../overlay/vector_primary_merge.rs | 47 +- .../redo_apply/calvin_fold_tests.rs | 1 + .../transaction/redo_apply/document.rs | 3 + .../handlers/transaction/redo_apply/entry.rs | 27 +- .../redo_apply/install_refusal_tests.rs | 2 + .../transaction/redo_apply/test_commit.rs | 1 + .../redo_apply/unique_handover_tests.rs | 1 + .../transaction/redo_apply/validate.rs | 9 +- .../handlers/transaction/resolve/entry.rs | 16 +- .../transaction/resolve/timeseries.rs | 14 +- .../handlers/transaction/stage_write/body.rs | 23 +- .../stage_write/stage_columnar_family.rs | 23 +- .../stage_write/stage_document_batch.rs | 104 +- .../transaction/stage_write/stage_upsert.rs | 29 + .../handlers/transaction/undo/graph_node.rs | 1 + .../handlers/transaction/undo/rollback.rs | 1 + .../handlers/transaction/undo/timeseries.rs | 8 + .../transaction/write_version_parity_tests.rs | 1 + .../handlers/unregister_collection.rs | 10 + .../executor/handlers/update_from_join.rs | 14 +- .../handlers/update_from_join_collect.rs | 61 +- .../handlers/update_from_join_source_map.rs | 191 --- .../handlers/update_from_join_types.rs | 13 +- .../handlers/update_from_join_write.rs | 25 +- .../executor/handlers/upsert/exec/insert.rs | 1 + .../handlers/upsert/exec/overwrite.rs | 15 + .../executor/handlers/vector_direct_delete.rs | 48 +- .../vector_direct_resolve/resolve_delete.rs | 29 +- .../vector_direct_resolve/resolve_update.rs | 30 +- .../vector_direct_resolve/resolve_upsert.rs | 11 +- .../executor/handlers/vector_direct_update.rs | 39 +- .../executor/handlers/vector_row_column.rs | 160 +++ .../data/executor/handlers/vector_upsert.rs | 13 +- .../src/data/executor/handlers/write_batch.rs | 2 + .../src/data/executor/handlers/write_body.rs | 43 + nodedb/src/data/executor/mod.rs | 1 + nodedb/src/data/executor/scan_normalize.rs | 79 +- nodedb/src/data/executor/scan_timeseries.rs | 116 ++ nodedb/src/data/executor/snapshot/capture.rs | 33 +- .../src/data/executor/snapshot/live_files.rs | 35 +- .../executor/timeseries_checkpoint/flush.rs | 2 + .../executor/timeseries_checkpoint/load.rs | 8 + .../executor/timeseries_checkpoint/mod.rs | 1 + .../timeseries_checkpoint/rewrite_recovery.rs | 121 ++ .../src/data/executor/wal_replay/crdt_doc.rs | 2 + nodedb/src/data/executor/wal_replay_all.rs | 1 + .../data/executor/wal_replay_redo_document.rs | 2 + .../wal_replay_redo_document_apply.rs | 4 + .../data/executor/wal_replay_redo_graph.rs | 1 + .../executor/wal_replay_redo_graph_cut.rs | 1 + nodedb/src/diag/context/columnar.rs | 68 ++ nodedb/src/diag/context/continuous_agg.rs | 49 + nodedb/src/diag/context/mod.rs | 9 +- nodedb/src/diag/mod.rs | 14 +- nodedb/src/diag/recording/columnar.rs | 51 + nodedb/src/diag/recording/continuous_agg.rs | 28 + nodedb/src/diag/recording/mod.rs | 7 +- nodedb/src/engine/bitemporal/enforcement.rs | 39 +- nodedb/src/engine/document/store/config.rs | 4 + .../engine/graph/pattern/compiler/clauses.rs | 289 +++-- .../engine/graph/pattern/compiler/helpers.rs | 267 +++-- .../engine/graph/pattern/compiler/parser.rs | 348 ++++-- nodedb/src/engine/kv/engine/scan_ops.rs | 83 +- nodedb/src/engine/timeseries/columnar_agg.rs | 23 + .../src/engine/timeseries/columnar_bridge.rs | 56 +- .../columnar_memtable/memtable/drain.rs | 185 ++- .../columnar_memtable/memtable/evolve.rs | 4 +- .../columnar_memtable/memtable/ingest.rs | 119 +- .../columnar_memtable/memtable/mod.rs | 1 + .../memtable/series_recount.rs | 146 +++ .../timeseries/columnar_memtable/mod.rs | 2 +- .../timeseries/columnar_memtable/types.rs | 7 + .../timeseries/columnar_segment/codec.rs | 11 +- .../timeseries/columnar_segment/mmap.rs | 18 +- .../timeseries/columnar_segment/reader.rs | 41 +- .../timeseries/columnar_segment/writer.rs | 66 +- .../engine/timeseries/continuous_agg/cut.rs | 520 ++++++++ .../engine/timeseries/continuous_agg/fold.rs | 581 +++++++++ .../engine/timeseries/continuous_agg/image.rs | 348 ++++++ .../timeseries/continuous_agg/manager.rs | 1043 ++++++----------- .../engine/timeseries/continuous_agg/mod.rs | 16 +- .../continuous_agg/partial/bucket.rs | 10 +- .../continuous_agg/partial/column.rs | 33 +- .../continuous_agg/partial/group.rs | 120 ++ .../continuous_agg/partial/layout.rs | 1 + .../timeseries/continuous_agg/partial/mod.rs | 2 + .../timeseries/continuous_agg/persist.rs | 499 ++++++++ .../timeseries/continuous_agg/refresh.rs | 160 ++- .../timeseries/continuous_agg/rollup.rs | 11 +- .../timeseries/continuous_agg/test_support.rs | 143 +++ .../timeseries/continuous_agg/watermark.rs | 36 +- .../src/engine/timeseries/grouped_filter.rs | 64 +- .../timeseries/grouped_scan/group_key.rs | 87 ++ .../src/engine/timeseries/grouped_scan/mod.rs | 2 + .../timeseries/grouped_scan/partition.rs | 22 +- .../grouped_scan/strategies/bucket.rs | 29 +- .../grouped_scan/strategies/dispatch.rs | 8 +- .../grouped_scan/strategies/hashed.rs | 29 +- .../grouped_scan/strategies/keys.rs | 82 +- .../engine/timeseries/grouped_scan/types.rs | 39 +- nodedb/src/engine/timeseries/ilp_ingest.rs | 38 +- .../src/engine/timeseries/interval_split.rs | 257 ++++ nodedb/src/engine/timeseries/memtable.rs | 577 --------- nodedb/src/engine/timeseries/merge/cycle.rs | 43 +- nodedb/src/engine/timeseries/merge/o3.rs | 40 +- .../src/engine/timeseries/merge/partitions.rs | 73 +- nodedb/src/engine/timeseries/mod.rs | 2 +- .../timeseries/partition_registry/boundary.rs | 154 +++ .../timeseries/partition_registry/listing.rs | 75 ++ .../timeseries/partition_registry/mod.rs | 4 + .../timeseries/partition_registry/registry.rs | 46 +- .../src/engine/timeseries/resolved_ingest.rs | 33 +- .../timeseries/retention_policy/autowire.rs | 161 +-- .../retention_policy/enforcement.rs | 62 +- .../engine/timeseries/retention_policy/mod.rs | 1 + .../retention_policy/rooted_aggregates.rs | 69 ++ nodedb/src/engine/timeseries/s3_archive.rs | 26 +- nodedb/src/engine/timeseries/segment_index.rs | 44 + .../src/engine/timeseries/tag_autocomplete.rs | 27 +- nodedb/src/engine/timeseries/verification.rs | 65 +- nodedb/src/error/types.rs | 7 + nodedb/src/error_classify/public.rs | 4 + nodedb/src/error_classify/unclassified.rs | 1 + nodedb/src/error_from.rs | 1 + nodedb/src/event/alert/executor.rs | 233 ++-- nodedb/src/event/alert/group_key.rs | 171 +++ nodedb/src/event/alert/hysteresis.rs | 114 +- nodedb/src/event/alert/mod.rs | 2 + nodedb/src/event/alert/notify.rs | 3 +- nodedb/src/event/alert/types.rs | 2 + nodedb/src/event/cdc/consume/remote.rs | 1 + nodedb/src/event/cross_shard/dedup.rs | 1 + nodedb/src/event/wal_replay.rs | 8 + nodedb/src/types/snapshot.rs | 9 + nodedb/src/wal/manager/append.rs | 1 + nodedb/src/wal/redo/continuation.rs | 1 + nodedb/src/wal/redo/record.rs | 30 + nodedb/src/wal/redo/replay.rs | 4 + nodedb/src/wal/timeseries_batch_payload.rs | 4 +- nodedb/tests/crash_ilp_timeseries_write.rs | 2 +- .../cases/alert_rule_replication_apply.rs | 12 +- .../inproc/cases/calvin_scheduler_restart.rs | 1 + .../inproc/cases/checkpoint_durability.rs | 19 +- .../cases/continuous_aggregate_relation.rs | 158 +++ .../cases/cross_engine_bitmap_currency.rs | 2 - .../inproc/cases/engine_surface_timeseries.rs | 26 +- .../executor_tests/test_aggregate_aliases.rs | 4 + .../test_aggregate_chunk_limit.rs | 2 + .../cases/executor_tests/test_array_ops.rs | 1 + .../executor_tests/test_columnar_aggregate.rs | 4 + .../by_name_join_inputs.rs | 161 +++ .../test_cross_type_join/cross_semi_joins.rs | 11 +- .../test_cross_type_join/inline_hash_join.rs | 6 +- .../test_cross_type_join/join_budget.rs | 30 +- .../test_cross_type_join/mod.rs | 1 + .../test_cross_type_join/multi_core_joins.rs | 9 +- .../test_cross_type_join/single_core_joins.rs | 10 - .../cases/executor_tests/test_document.rs | 1 + .../test_tenant_isolation_timeseries.rs | 4 + ...st_tenant_isolation_timeseries_negative.rs | 4 + .../cases/executor_tests/test_tenant_purge.rs | 2 + .../cases/executor_tests/test_timeseries.rs | 6 + .../test_timeseries_scan_budget.rs | 2 + .../test_transaction_matrix_kv.rs | 5 + .../cases/gathered_rows_authorization.rs | 141 +++ .../inproc/cases/gathered_rows_redaction.rs | 159 +++ .../cases/http_promql_storage/isolation.rs | 228 ++++ .../cases/http_promql_storage/limits.rs | 124 ++ .../cases/http_promql_storage/matchers.rs | 172 +++ .../inproc/cases/http_promql_storage/mod.rs | 20 + .../cases/http_promql_storage/round_trip.rs | 349 ++++++ .../cases/http_promql_storage/support.rs | 289 +++++ nodedb/tests/inproc/cases/mod.rs | 5 + .../cases/sync_state_transition_by_role.rs | 346 ++++++ .../inproc/cases/system_task_call_sites.rs | 8 +- .../tests/inproc/cases/wal_catchup/stack.rs | 19 +- .../cases/wal_catchup/startup_replay.rs | 6 +- nodedb/tests/native/cases/mod.rs | 1 + .../native/cases/native_result_projection.rs | 2 +- .../native/cases/native_state_transitions.rs | 130 ++ .../tests/wire/cases/aggregate_dispersion.rs | 105 ++ .../wire/cases/aggregate_filter_clause.rs | 135 +++ .../tests/wire/cases/aggregate_over_join.rs | 132 +++ .../wire/cases/aggregate_routing_multicore.rs | 132 +++ .../tests/wire/cases/clone_refused_shapes.rs | 2 +- .../cases/continuous_aggregate_restart.rs | 235 +++- .../cases/continuous_aggregate_serving.rs | 225 ++++ .../wire/cases/crdt_delta_state_transition.rs | 118 ++ .../wire/cases/engine_surface_routing.rs | 18 +- .../tests/wire/cases/engine_surface_vector.rs | 17 +- .../cases/graph_dsl_argument_validation.rs | 83 +- .../wire/cases/http_result_projection.rs | 2 +- .../tests/wire/cases/insert_page_conflicts.rs | 87 ++ .../tests/wire/cases/insert_select_policy.rs | 175 +++ .../wire/cases/insert_select_relation.rs | 232 ++++ .../tests/wire/cases/insert_select_sources.rs | 218 ++++ nodedb/tests/wire/cases/mod.rs | 30 + .../tests/wire/cases/pgwire_extended_query.rs | 5 +- .../cases/pgwire_extended_query_engines2.rs | 8 +- .../wire/cases/query_function_recognition.rs | 107 ++ .../cases/quota_sieve_routing_composition.rs | 14 +- .../wire/cases/redaction_provider_relation.rs | 153 +++ nodedb/tests/wire/cases/reindex_concurrent.rs | 3 +- .../tests/wire/cases/session_database_ddl.rs | 279 +++++ .../cases/sql_backup_restore_vector_params.rs | 2 +- .../sql_backup_restore_vector_restart.rs | 2 +- .../wire/cases/sql_backup_restore_verify.rs | 2 +- .../tests/wire/cases/sql_check_constraints.rs | 94 ++ .../wire/cases/sql_convert_column_defs.rs | 20 + .../sql_create_collection_type_clause.rs | 268 +++++ .../wire/cases/sql_default_expressions.rs | 33 + .../tests/wire/cases/sql_generated_columns.rs | 87 ++ .../cases/sql_join_unqualified_columns.rs | 300 +++++ nodedb/tests/wire/cases/sql_merge_sources.rs | 201 ++++ .../wire/cases/sql_primary_key_nullability.rs | 23 +- .../cases/sql_state_transition_by_role.rs | 193 +++ .../wire/cases/sql_state_transition_forms.rs | 230 ++++ .../tests/wire/cases/sql_three_source_rrf.rs | 36 + .../cases/sql_transactions_aggregate_ryow.rs | 170 +++ ...transactions_insert_select_cross_engine.rs | 41 + .../wire/cases/sql_transactions_join_ryow.rs | 127 ++ .../sql_transactions_timeseries_overlay.rs | 110 +- .../wire/cases/sql_typeguard_default_gate.rs | 49 + .../sql_typeguard_defaults/convert_carry.rs | 356 ++++++ .../wire/cases/sql_typeguard_defaults/mod.rs | 3 + .../session_database.rs | 53 + .../sql_typeguard_defaults/write_forms.rs | 316 +++++ .../wire/cases/sql_update_from_sources.rs | 108 ++ .../wire/cases/timeseries_aggregate_order.rs | 101 ++ .../wire/cases/timeseries_aggregate_shapes.rs | 215 ++++ .../wire/cases/timeseries_aggregate_values.rs | 181 +++ .../wire/cases/timeseries_auto_tier_reads.rs | 144 +++ .../cases/timeseries_auto_tier_two_tiers.rs | 123 ++ .../tests/wire/cases/timeseries_null_text.rs | 202 ++++ .../tests/wire/cases/timeseries_partitions.rs | 227 ++++ .../timeseries_read_row_level_security.rs | 42 + ...cate_engine_conformance_columnar_family.rs | 7 + nodedb/tests/wire/cases/unbounded_reads.rs | 145 +++ 971 files changed, 50686 insertions(+), 18302 deletions(-) create mode 100644 nodedb-client-tests/tests/document_typeguard_round_trip.rs create mode 100644 nodedb-cluster-tests/tests/common_suite/cases/continuous_aggregate_serving.rs create mode 100644 nodedb-cluster-tests/tests/common_suite/cases/promql_remote_storage.rs create mode 100644 nodedb-physical/src/physical_plan/aggregate_functions.rs create mode 100644 nodedb-physical/src/physical_plan/document/row_source.rs create mode 100644 nodedb-physical/src/physical_plan/gathered_source.rs create mode 100644 nodedb-physical/src/physical_plan/tiering.rs create mode 100644 nodedb-query/src/expr_parse/predicate.rs create mode 100644 nodedb-query/src/expr_parse/validate.rs create mode 100644 nodedb-sql/src/ddl_ast/graph_parse/cursor.rs delete mode 100644 nodedb-sql/src/ddl_ast/graph_parse/helpers.rs create mode 100644 nodedb-sql/src/ddl_ast/parse/clause/cursor.rs create mode 100644 nodedb-sql/src/ddl_ast/parse/clause/mod.rs create mode 100644 nodedb-sql/src/ddl_ast/parse/clause/token.rs create mode 100644 nodedb-sql/src/ddl_ast/parse/collection/type_clause.rs create mode 100644 nodedb-sql/src/engine_rules/continuous_aggregate.rs create mode 100644 nodedb-sql/src/engine_rules/timeseries_native.rs create mode 100644 nodedb-sql/src/parser/query_function.rs create mode 100644 nodedb-sql/src/planner/aggregate_clauses.rs create mode 100644 nodedb-sql/src/planner/dml/update_from.rs create mode 100644 nodedb-sql/src/planner/select/entry/join_columns.rs create mode 100644 nodedb-sql/src/planner/select/order_by/timeseries_native.rs create mode 100644 nodedb-sql/src/resolver/column_naming.rs create mode 100644 nodedb-sql/tests/sql_suite/cases/aggregate_filter_planning.rs create mode 100644 nodedb-sql/tests/sql_suite/cases/timeseries_aggregate_routing.rs create mode 100644 nodedb-sql/tests/sql_suite/cases/unbounded_defaults.rs create mode 100644 nodedb-types/src/config/tuning/promql.rs create mode 100644 nodedb/src/control/gateway/version_set/provider_keys.rs create mode 100644 nodedb/src/control/planner/catalog_adapter/cagg_relation.rs create mode 100644 nodedb/src/control/planner/redaction_refusal/orchestrated.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/dml/write_checks.rs create mode 100644 nodedb/src/control/planner/sql_plan_convert/row_source.rs create mode 100644 nodedb/src/control/security/catalog/collection_generated.rs create mode 100644 nodedb/src/control/server/exchange/owner_dispatch.rs create mode 100644 nodedb/src/control/server/exchange/resolve/cagg_rows.rs create mode 100644 nodedb/src/control/server/exchange/resolve/exchange/by_name_join_arm.rs create mode 100644 nodedb/src/control/server/exchange/resolve/exchange/txn_source_arm.rs create mode 100644 nodedb/src/control/server/http/routes/promql/metric_registration.rs create mode 100644 nodedb/src/control/server/http/routes/promql/params.rs create mode 100644 nodedb/src/control/server/http/routes/promql/remote_read.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/budget.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/discovery.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/load.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/mod.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/node_metrics.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/pushdown.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/rows.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/scan.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/scope.rs create mode 100644 nodedb/src/control/server/http/routes/promql/source/windows.rs delete mode 100644 nodedb/src/control/server/http/routes/promql/types.rs create mode 100644 nodedb/src/control/server/native/dispatch/plan_builder/generated_gate.rs delete mode 100644 nodedb/src/control/server/post_aggregate.rs create mode 100644 nodedb/src/control/server/shared/authorization/requirements/gathered.rs delete mode 100644 nodedb/src/control/server/shared/ddl/neutral/collection/create/engine_option/parse.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/continuous_agg/periodic.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/continuous_agg/refresh.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/convert/fields.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/convert/source_key.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/router/route_tokens.rs create mode 100644 nodedb/src/control/source_read/context.rs create mode 100644 nodedb/src/control/source_read/mod.rs create mode 100644 nodedb/src/control/source_read/pages.rs create mode 100644 nodedb/src/control/source_read/query.rs create mode 100644 nodedb/src/control/source_read/reader.rs create mode 100644 nodedb/src/control/source_read/shape.rs delete mode 100644 nodedb/src/data/executor/enforcement/typeguard.rs create mode 100644 nodedb/src/data/executor/enforcement/typeguard/check.rs create mode 100644 nodedb/src/data/executor/enforcement/typeguard/compiled.rs create mode 100644 nodedb/src/data/executor/enforcement/typeguard/hook.rs create mode 100644 nodedb/src/data/executor/enforcement/typeguard/inject.rs create mode 100644 nodedb/src/data/executor/enforcement/typeguard/mod.rs create mode 100644 nodedb/src/data/executor/enforcement/update_transitions.rs create mode 100644 nodedb/src/data/executor/handlers/aggregate/input_docs.rs delete mode 100644 nodedb/src/data/executor/handlers/columnar_agg.rs delete mode 100644 nodedb/src/data/executor/handlers/columnar_agg_support.rs create mode 100644 nodedb/src/data/executor/handlers/document/write/batch_insert_judge.rs create mode 100644 nodedb/src/data/executor/handlers/join/lateral/inner.rs create mode 100644 nodedb/src/data/executor/handlers/join/side_rows.rs create mode 100644 nodedb/src/data/executor/handlers/join_source_map.rs create mode 100644 nodedb/src/data/executor/handlers/recursive_iterate.rs delete mode 100644 nodedb/src/data/executor/handlers/spill/columnar.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/cagg_cut.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/cagg_fold.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/cagg_persist.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/cagg_test_support.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/flush_buckets.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/flush_commit.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/flush_crash_tests.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/flush_interval.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/flush_rollforward.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/partition_rewrite.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/partitions.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/raw_scan/gather.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/read_aggregate.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/staged_fold.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/tier_fold.rs create mode 100644 nodedb/src/data/executor/handlers/timeseries/tier_route.rs delete mode 100644 nodedb/src/data/executor/handlers/update_from_join_source_map.rs create mode 100644 nodedb/src/data/executor/handlers/vector_row_column.rs create mode 100644 nodedb/src/data/executor/handlers/write_body.rs create mode 100644 nodedb/src/data/executor/scan_timeseries.rs create mode 100644 nodedb/src/data/executor/timeseries_checkpoint/rewrite_recovery.rs create mode 100644 nodedb/src/engine/timeseries/columnar_memtable/memtable/series_recount.rs create mode 100644 nodedb/src/engine/timeseries/continuous_agg/cut.rs create mode 100644 nodedb/src/engine/timeseries/continuous_agg/fold.rs create mode 100644 nodedb/src/engine/timeseries/continuous_agg/image.rs create mode 100644 nodedb/src/engine/timeseries/continuous_agg/partial/group.rs create mode 100644 nodedb/src/engine/timeseries/continuous_agg/persist.rs create mode 100644 nodedb/src/engine/timeseries/continuous_agg/test_support.rs create mode 100644 nodedb/src/engine/timeseries/grouped_scan/group_key.rs create mode 100644 nodedb/src/engine/timeseries/interval_split.rs delete mode 100644 nodedb/src/engine/timeseries/memtable.rs create mode 100644 nodedb/src/engine/timeseries/partition_registry/boundary.rs create mode 100644 nodedb/src/engine/timeseries/partition_registry/listing.rs create mode 100644 nodedb/src/engine/timeseries/retention_policy/rooted_aggregates.rs create mode 100644 nodedb/src/event/alert/group_key.rs create mode 100644 nodedb/tests/inproc/cases/continuous_aggregate_relation.rs create mode 100644 nodedb/tests/inproc/cases/executor_tests/test_cross_type_join/by_name_join_inputs.rs create mode 100644 nodedb/tests/inproc/cases/gathered_rows_authorization.rs create mode 100644 nodedb/tests/inproc/cases/gathered_rows_redaction.rs create mode 100644 nodedb/tests/inproc/cases/http_promql_storage/isolation.rs create mode 100644 nodedb/tests/inproc/cases/http_promql_storage/limits.rs create mode 100644 nodedb/tests/inproc/cases/http_promql_storage/matchers.rs create mode 100644 nodedb/tests/inproc/cases/http_promql_storage/mod.rs create mode 100644 nodedb/tests/inproc/cases/http_promql_storage/round_trip.rs create mode 100644 nodedb/tests/inproc/cases/http_promql_storage/support.rs create mode 100644 nodedb/tests/inproc/cases/sync_state_transition_by_role.rs create mode 100644 nodedb/tests/native/cases/native_state_transitions.rs create mode 100644 nodedb/tests/wire/cases/aggregate_dispersion.rs create mode 100644 nodedb/tests/wire/cases/aggregate_filter_clause.rs create mode 100644 nodedb/tests/wire/cases/aggregate_over_join.rs create mode 100644 nodedb/tests/wire/cases/aggregate_routing_multicore.rs create mode 100644 nodedb/tests/wire/cases/continuous_aggregate_serving.rs create mode 100644 nodedb/tests/wire/cases/crdt_delta_state_transition.rs create mode 100644 nodedb/tests/wire/cases/insert_page_conflicts.rs create mode 100644 nodedb/tests/wire/cases/insert_select_policy.rs create mode 100644 nodedb/tests/wire/cases/insert_select_relation.rs create mode 100644 nodedb/tests/wire/cases/insert_select_sources.rs create mode 100644 nodedb/tests/wire/cases/query_function_recognition.rs create mode 100644 nodedb/tests/wire/cases/redaction_provider_relation.rs create mode 100644 nodedb/tests/wire/cases/session_database_ddl.rs create mode 100644 nodedb/tests/wire/cases/sql_create_collection_type_clause.rs create mode 100644 nodedb/tests/wire/cases/sql_generated_columns.rs create mode 100644 nodedb/tests/wire/cases/sql_join_unqualified_columns.rs create mode 100644 nodedb/tests/wire/cases/sql_merge_sources.rs create mode 100644 nodedb/tests/wire/cases/sql_state_transition_by_role.rs create mode 100644 nodedb/tests/wire/cases/sql_state_transition_forms.rs create mode 100644 nodedb/tests/wire/cases/sql_transactions_aggregate_ryow.rs create mode 100644 nodedb/tests/wire/cases/sql_transactions_join_ryow.rs create mode 100644 nodedb/tests/wire/cases/sql_typeguard_defaults/convert_carry.rs create mode 100644 nodedb/tests/wire/cases/sql_typeguard_defaults/session_database.rs create mode 100644 nodedb/tests/wire/cases/sql_typeguard_defaults/write_forms.rs create mode 100644 nodedb/tests/wire/cases/sql_update_from_sources.rs create mode 100644 nodedb/tests/wire/cases/timeseries_aggregate_order.rs create mode 100644 nodedb/tests/wire/cases/timeseries_aggregate_shapes.rs create mode 100644 nodedb/tests/wire/cases/timeseries_aggregate_values.rs create mode 100644 nodedb/tests/wire/cases/timeseries_auto_tier_reads.rs create mode 100644 nodedb/tests/wire/cases/timeseries_auto_tier_two_tiers.rs create mode 100644 nodedb/tests/wire/cases/timeseries_null_text.rs create mode 100644 nodedb/tests/wire/cases/timeseries_partitions.rs create mode 100644 nodedb/tests/wire/cases/unbounded_reads.rs diff --git a/Cargo.lock b/Cargo.lock index 53ab4b8cd..b5ebe1edb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4430,11 +4430,14 @@ dependencies = [ "nodedb-raft", "nodedb-test-support", "nodedb-types", + "prost", "quinn", "rdkafka", "redb", + "reqwest", "rustls", "serde_json", + "snap", "sonic-rs", "tempfile", "tokio", diff --git a/nodedb-client-tests/tests/document_declared_key_round_trip.rs b/nodedb-client-tests/tests/document_declared_key_round_trip.rs index 87c575274..075c7307e 100644 --- a/nodedb-client-tests/tests/document_declared_key_round_trip.rs +++ b/nodedb-client-tests/tests/document_declared_key_round_trip.rs @@ -191,7 +191,7 @@ async fn vector_search_restricts_allowed_ids_on_a_declared_key() { &remote, "CREATE COLLECTION sku_vecs \ FIELDS (sku TEXT PRIMARY KEY, embedding VECTOR(2)) \ - WITH (engine='vector', m=8, ef_construction=50)", + WITH (engine='document_schemaless', m=8, ef_construction=50)", ) .await; for (sku, x) in [ diff --git a/nodedb-client-tests/tests/document_typeguard_round_trip.rs b/nodedb-client-tests/tests/document_typeguard_round_trip.rs new file mode 100644 index 000000000..3d772b87b --- /dev/null +++ b/nodedb-client-tests/tests/document_typeguard_round_trip.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! A native document write is held to the collection's typeguards and +//! generated columns, as a SQL write is. +//! +//! A native put reaches the Data Plane without the SQL planner. The typeguard +//! hook on the Data Plane judges its body all the same, and a body that +//! supplies a generated column is refused before dispatch. + +use nodedb_client::native::pool::PoolConfig; +use nodedb_client::{Document, NativeClient, NodeDb, Value}; +use nodedb_test_support::pgwire_harness::TestServer; + +fn native(server: &TestServer) -> NativeClient { + NativeClient::new(PoolConfig::new( + format!("127.0.0.1:{}", server.native_port), + nodedb_types::protocol::AuthMethod::Trust { + username: "nodedb".into(), + }, + )) +} + +fn document(id: &str, fields: &[(&str, &str)]) -> Document { + let mut doc = Document::new(id); + for (name, value) in fields { + doc.set(*name, Value::String((*value).into())); + } + doc +} + +#[tokio::test] +async fn native_put_enforces_typeguards() { + let server = TestServer::start().await; + server.exec("CREATE COLLECTION tg_native").await.unwrap(); + server + .exec( + "CREATE TYPEGUARD ON tg_native (\ + email STRING REQUIRED, \ + slug STRING VALUE LOWER(title)\ + )", + ) + .await + .unwrap(); + let native = native(&server); + + native + .document_put("tg_native", document("n1", &[("title", "Hi")])) + .await + .expect_err("a put without the required email must be refused"); + + native + .document_put( + "tg_native", + document("n2", &[("title", "Native Row"), ("email", "a@b.c")]), + ) + .await + .expect("a valid put lands"); + let read = native + .document_get("tg_native", "n2") + .await + .expect("document_get") + .expect("the written document exists"); + assert_eq!( + read.get("slug"), + Some(&Value::String("native row".into())), + "the guard VALUE must inject: {read:?}" + ); +} + +#[tokio::test] +async fn native_put_supplying_a_generated_column_is_refused() { + let server = TestServer::start().await; + server + .exec( + "CREATE COLLECTION gen_native (\ + id TEXT PRIMARY KEY, price TEXT, \ + label TEXT GENERATED ALWAYS AS (LOWER(price))) \ + WITH (engine='document_strict')", + ) + .await + .unwrap(); + let native = native(&server); + + native + .document_put( + "gen_native", + document("g1", &[("price", "TEN"), ("label", "mine")]), + ) + .await + .expect_err("a put that supplies a generated column must be refused"); +} diff --git a/nodedb-client-tests/tests/vector_insert_field_round_trip.rs b/nodedb-client-tests/tests/vector_insert_field_round_trip.rs index 5bea1ce49..8d912143a 100644 --- a/nodedb-client-tests/tests/vector_insert_field_round_trip.rs +++ b/nodedb-client-tests/tests/vector_insert_field_round_trip.rs @@ -28,9 +28,8 @@ async fn vector_insert_field_must_not_silently_delegate_to_unfielded() { // `primary='vector'` is the server contract that routes per-row // INSERT through `VectorOp::DirectUpsert`, which binds the vector // under the named-field HNSW key — exactly the path - // `vector_insert_field` must target. Plain `engine='vector'` - // without `primary='vector'` is a schemaless collection that - // happens to have a VECTOR column: the data lands in the document + // `vector_insert_field` must target. A `document_schemaless` + // collection with a VECTOR column keeps the data in the document // body, not in a per-field HNSW index, so a field-aware insert is // unverifiable in that mode. remote diff --git a/nodedb-client-tests/tests/vector_search_allowed_ids_round_trip.rs b/nodedb-client-tests/tests/vector_search_allowed_ids_round_trip.rs index 42ae1b2c8..519afc734 100644 --- a/nodedb-client-tests/tests/vector_search_allowed_ids_round_trip.rs +++ b/nodedb-client-tests/tests/vector_search_allowed_ids_round_trip.rs @@ -21,7 +21,7 @@ async fn seed(remote: &NodeDbRemote) { .execute_sql( "CREATE COLLECTION allowed_ids_vecs \ FIELDS (id TEXT, embedding VECTOR(2)) \ - WITH (engine='vector', m=8, ef_construction=50)", + WITH (engine='document_schemaless', m=8, ef_construction=50)", &[], ) .await diff --git a/nodedb-client-tests/tests/vector_search_filter_round_trip.rs b/nodedb-client-tests/tests/vector_search_filter_round_trip.rs index 4de37caf0..ba210bf36 100644 --- a/nodedb-client-tests/tests/vector_search_filter_round_trip.rs +++ b/nodedb-client-tests/tests/vector_search_filter_round_trip.rs @@ -26,15 +26,16 @@ async fn vector_search_with_metadata_filter_round_trips_through_pgwire() { .await .expect("pgwire connect to harness must succeed"); - // Provision a vector collection with an explicit `category` - // metadata column so the WHERE predicate has a real column to bind - // against. Vector engine requires the engine='vector' option plus - // explicit FIELDS; the planner does not auto-promote. + // Provision a document collection with a vector column and an + // explicit `category` metadata column, so the WHERE predicate has a + // real column to bind against. Vector search over a document + // collection uses engine='document_schemaless'; engine='vector' is a + // vector-primary collection. remote .execute_sql( "CREATE COLLECTION embeddings \ FIELDS (id TEXT, embedding VECTOR(3), category TEXT) \ - WITH (engine='vector', m=8, ef_construction=50)", + WITH (engine='document_schemaless', m=8, ef_construction=50)", &[], ) .await diff --git a/nodedb-client-tests/tests/vector_search_metadata_filter_window.rs b/nodedb-client-tests/tests/vector_search_metadata_filter_window.rs index 134921ece..7c6b139d4 100644 --- a/nodedb-client-tests/tests/vector_search_metadata_filter_window.rs +++ b/nodedb-client-tests/tests/vector_search_metadata_filter_window.rs @@ -24,7 +24,7 @@ async fn seed(remote: &NodeDbRemote) { .execute_sql( "CREATE COLLECTION meta_filter_vecs \ FIELDS (id TEXT, embedding VECTOR(2), category TEXT) \ - WITH (engine='vector', m=8, ef_construction=50)", + WITH (engine='document_schemaless', m=8, ef_construction=50)", &[], ) .await diff --git a/nodedb-cluster-tests/Cargo.toml b/nodedb-cluster-tests/Cargo.toml index bf5bc827f..2ddb736c0 100644 --- a/nodedb-cluster-tests/Cargo.toml +++ b/nodedb-cluster-tests/Cargo.toml @@ -42,11 +42,14 @@ futures = { workspace = true } hex = { workspace = true } loro = { workspace = true } nexar = { workspace = true } +prost = { workspace = true } quinn = { workspace = true } rdkafka = { workspace = true } redb = { workspace = true } +reqwest = { workspace = true } rustls = { workspace = true } serde_json = { workspace = true } +snap = { workspace = true } sonic-rs = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/nodedb-cluster-tests/tests/common_suite/cases/cluster_execute_request.rs b/nodedb-cluster-tests/tests/common_suite/cases/cluster_execute_request.rs index 42c04b8d1..539d0f2b4 100644 --- a/nodedb-cluster-tests/tests/common_suite/cases/cluster_execute_request.rs +++ b/nodedb-cluster-tests/tests/common_suite/cases/cluster_execute_request.rs @@ -60,6 +60,7 @@ fn make_kv_put_request( txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), } } @@ -172,6 +173,7 @@ async fn execute_request_read_carries_watermark_lsn() { txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }; let resp = send_execute_request(transport, node1.listen_addr, req).await; @@ -306,6 +308,7 @@ async fn execute_request_cross_node_dispatch() { txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }; let resp = send_execute_request(sender_transport, target_addr, req).await; diff --git a/nodedb-cluster-tests/tests/common_suite/cases/continuous_aggregate_serving.rs b/nodedb-cluster-tests/tests/common_suite/cases/continuous_aggregate_serving.rs new file mode 100644 index 000000000..6be62e0f3 --- /dev/null +++ b/nodedb-cluster-tests/tests/common_suite/cases/continuous_aggregate_serving.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! A continuous aggregate read as a relation answers the same rows on every +//! node, and after its root source's leader dies. +//! +//! The aggregate is `Manual`. `REFRESH CONTINUOUS AGGREGATE` is a replicated +//! cut on the source's data group: every replica flushes the source at the +//! cut's log position and folds the same rows. A read on any node fetches +//! the finalized rows from the replica that owns the source's vShard. So a +//! read through a node that does not lead the source, and a read after the +//! leader is killed, both return the rows of the last cut. + +use crate::common; +use common::cluster_harness::shared_steps::{kill_node, leader_index_of}; +use common::cluster_harness::{TestCluster, TestClusterNode, wait_for_async}; + +use std::time::Duration; + +const SRC: &str = "cagg_cluster_src"; +const VIEW: &str = "cagg_cluster_view"; + +const CONVERGE: Duration = Duration::from_secs(30); +const STEP: Duration = Duration::from_millis(100); + +/// `(host, s, c)` rows of the aggregate read through `node`, or `None` while +/// the read fails. +async fn view_rows(node: &TestClusterNode) -> Option> { + let messages = node + .client + .simple_query(&format!("SELECT host, s, c FROM {VIEW} ORDER BY host")) + .await + .ok()?; + Some( + messages + .iter() + .filter_map(|msg| match msg { + tokio_postgres::SimpleQueryMessage::Row(row) => Some(( + row.get(0).unwrap_or_default().to_string(), + row.get(1).unwrap_or_default().to_string(), + row.get(2).unwrap_or_default().to_string(), + )), + _ => None, + }) + .collect(), + ) +} + +/// Whether `rows` are the buckets of the two seeded hosts. +fn are_seeded(rows: &[(String, String, String)]) -> bool { + let num = |s: &str| s.parse::().ok(); + rows.len() == 2 + && rows[0].0 == "a" + && num(&rows[0].1) == Some(4.0) + && rows[0].2 == "2" + && rows[1].0 == "b" + && num(&rows[1].1) == Some(30.0) + && rows[1].2 == "2" +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn continuous_aggregate_reads_from_source_leader() { + let mut cluster = TestCluster::spawn_three() + .await + .expect("spawn 3-node cluster"); + cluster + .exec_ddl_on_any_leader(&format!( + "CREATE COLLECTION {SRC} (ts BIGINT TIME_KEY, host TEXT, value FLOAT) \ + WITH (engine='timeseries')" + )) + .await + .expect("create the source"); + cluster + .exec_ddl_on_any_leader(&format!( + "CREATE CONTINUOUS AGGREGATE {VIEW} ON {SRC} BUCKET '1m' \ + AGGREGATE SUM(value) AS s, COUNT(*) AS c GROUP BY host \ + WITH (refresh_policy = 'manual')" + )) + .await + .expect("create the aggregate"); + + let leader = leader_index_of(&cluster, SRC); + let reader = (leader + 1) % cluster.nodes.len(); + for (ts, host, value) in [ + (1_000, "a", 1.5), + (2_000, "a", 2.5), + (3_000, "b", 10.0), + (4_000, "b", 20.0), + ] { + cluster.nodes[reader] + .client + .simple_query(&format!( + "INSERT INTO {SRC} (ts, host, value) VALUES ({ts}, '{host}', {value})" + )) + .await + .unwrap_or_else(|e| panic!("insert row at {ts}: {e}")); + } + cluster.nodes[reader] + .client + .simple_query(&format!("REFRESH CONTINUOUS AGGREGATE {VIEW}")) + .await + .unwrap_or_else(|e| panic!("refresh: {e}")); + + let through_reader = &cluster.nodes[reader]; + wait_for_async( + "a node that does not lead the source reads the refreshed buckets", + CONVERGE, + STEP, + || async { + view_rows(through_reader) + .await + .is_some_and(|rows| are_seeded(&rows)) + }, + ) + .await; + + kill_node(&mut cluster, leader).await; + let survivor = &cluster.nodes[0]; + wait_for_async( + "after the source's leader dies, the new leader serves the same cut", + CONVERGE, + STEP, + || async { + view_rows(survivor) + .await + .is_some_and(|rows| are_seeded(&rows)) + }, + ) + .await; + + cluster.shutdown().await; +} diff --git a/nodedb-cluster-tests/tests/common_suite/cases/mod.rs b/nodedb-cluster-tests/tests/common_suite/cases/mod.rs index be7dd1fbf..bfb83a2c5 100644 --- a/nodedb-cluster-tests/tests/common_suite/cases/mod.rs +++ b/nodedb-cluster-tests/tests/common_suite/cases/mod.rs @@ -76,6 +76,7 @@ mod column_stats_cross_node; mod committed_publish_failover; mod committed_publish_metadata_lag; mod constraint_delivery; +mod continuous_aggregate_serving; mod crdt_compact_cross_node; mod cross_node_pk_lookup; mod cross_node_pk_write; @@ -128,6 +129,7 @@ mod owner_read_on_non_replica; mod pgwire_gateway_migration; mod planner_local_only; mod prepared_cache_invalidation; +mod promql_remote_storage; mod proposal_committed_twice_applies_once; mod redo_stream_held_replica; mod resp_gateway_migration; diff --git a/nodedb-cluster-tests/tests/common_suite/cases/promql_remote_storage.rs b/nodedb-cluster-tests/tests/common_suite/cases/promql_remote_storage.rs new file mode 100644 index 000000000..8c66b6f06 --- /dev/null +++ b/nodedb-cluster-tests/tests/common_suite/cases/promql_remote_storage.rs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! PromQL reads over remote-written data on a 3-node cluster. +//! +//! Remote write on one node registers each metric collection through the +//! replicated catalog and stores the samples on the metric's owning data +//! group. Remote read and `/query` on every node then return the same +//! series: discovery reads each node's replicated catalog, and every scan +//! is routed to the group that owns the metric. + +use crate::common; + +use std::sync::Arc; +use std::time::Duration; + +use nodedb::config::auth::AuthMode; +use nodedb::control::promql::remote_proto::{ + Label, LabelMatcher, MatchType, Query, ReadRequest, ReadResponse, Sample, TimeSeries, + WriteRequest, +}; +use prost::Message; + +use common::cluster_harness::{TestCluster, TestClusterNode, wait_for_async}; + +/// An HTTP listener serving `node`'s state in trust mode. +async fn start_http(node: &TestClusterNode) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind http listener"); + let addr = listener.local_addr().expect("http local addr"); + let shared = Arc::clone(&node.shared); + let (bus, _) = nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown)); + let handle = tokio::spawn(async move { + let _ = nodedb::control::server::http::server::run_with_listener( + listener, + shared, + AuthMode::Trust, + None, + bus, + ) + .await; + }); + (format!("http://{addr}/v1/obsv/api/v1"), handle) +} + +fn base_ms() -> i64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_millis() as i64; + (now / 1000 - 600) * 1000 +} + +fn series(metric: &str, job: &str, samples: &[(i64, f64)]) -> TimeSeries { + TimeSeries { + labels: vec![ + Label { + name: "__name__".into(), + value: metric.into(), + }, + Label { + name: "job".into(), + value: job.into(), + }, + ], + samples: samples + .iter() + .map(|&(timestamp, value)| Sample { value, timestamp }) + .collect(), + exemplars: vec![], + } +} + +async fn remote_write(base: &str, timeseries: Vec) -> (reqwest::StatusCode, String) { + let mut buf = Vec::new(); + WriteRequest { timeseries } + .encode(&mut buf) + .expect("encode write request"); + let body = snap::raw::Encoder::new() + .compress_vec(&buf) + .expect("snappy compress"); + let response = reqwest::Client::new() + .post(format!("{base}/write")) + .header("Content-Encoding", "snappy") + .body(body) + .send() + .await + .expect("POST remote write"); + let status = response.status(); + (status, response.text().await.expect("remote write body")) +} + +/// `(metric, job, values)` of every series a remote read returns, sorted. +async fn remote_read(base: &str, start_ms: i64, end_ms: i64) -> Option)>> { + let mut buf = Vec::new(); + ReadRequest { + queries: vec![Query { + start_timestamp_ms: start_ms, + end_timestamp_ms: end_ms, + matchers: vec![LabelMatcher { + match_type: MatchType::Re as i32, + name: "__name__".into(), + value: "promql_cluster_m1|promql_cluster_m2".into(), + }], + }], + } + .encode(&mut buf) + .expect("encode read request"); + let body = snap::raw::Encoder::new() + .compress_vec(&buf) + .expect("snappy compress"); + let response = reqwest::Client::new() + .post(format!("{base}/read")) + .header("Content-Encoding", "snappy") + .body(body) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let bytes = response.bytes().await.ok()?; + let raw = snap::raw::Decoder::new().decompress_vec(&bytes).ok()?; + let decoded = ReadResponse::decode(&raw[..]).ok()?; + let mut out: Vec<(String, Vec)> = decoded + .results + .first()? + .timeseries + .iter() + .map(|ts| { + let label = |name: &str| { + ts.labels + .iter() + .find(|l| l.name == name) + .map(|l| l.value.clone()) + .unwrap_or_default() + }; + ( + format!("{}:{}", label("__name__"), label("job")), + ts.samples.iter().map(|s| s.value).collect(), + ) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + Some(out) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn remote_write_on_one_node_reads_back_on_every_node() { + let cluster = TestCluster::spawn_three().await.expect("3-node cluster"); + let mut endpoints = Vec::new(); + for node in &cluster.nodes { + endpoints.push(start_http(node).await); + } + tokio::time::sleep(Duration::from_millis(50)).await; + + let t0 = base_ms(); + let (status, body) = remote_write( + &endpoints[0].0, + vec![ + series("promql_cluster_m1", "api", &[(t0, 1.0), (t0 + 15_000, 2.0)]), + series("promql_cluster_m2", "web", &[(t0, 5.0)]), + ], + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NO_CONTENT, + "remote write: {body}" + ); + + let expected = vec![ + ("promql_cluster_m1:api".to_owned(), vec![1.0, 2.0]), + ("promql_cluster_m2:web".to_owned(), vec![5.0]), + ]; + for (index, (base, _)) in endpoints.iter().enumerate() { + let base = base.clone(); + let want = expected.clone(); + wait_for_async( + &format!("node {index} reads both metrics back"), + Duration::from_secs(15), + Duration::from_millis(100), + || { + let base = base.clone(); + let want = want.clone(); + async move { remote_read(&base, t0, t0 + 15_000).await == Some(want) } + }, + ) + .await; + + let response = reqwest::Client::new() + .get(format!("{base}/query")) + .query(&[ + ("query", "sum(promql_cluster_m1)".to_owned()), + ("time", format!("{}", (t0 + 15_000) as f64 / 1000.0)), + ]) + .send() + .await + .expect("GET /query"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let text = response.text().await.expect("query body"); + let json: serde_json::Value = + sonic_rs::from_str(&text).unwrap_or_else(|e| panic!("query JSON {e}: {text}")); + let value = json + .pointer("/data/result/0/value/1") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("node {index}: no value in {json}")); + assert_eq!(value, "2", "node {index}: {json}"); + } + + for (_, handle) in endpoints { + handle.abort(); + } + cluster.shutdown().await; +} diff --git a/nodedb-cluster-tests/tests/common_suite/cases/shuffle_aggregate_cross_node.rs b/nodedb-cluster-tests/tests/common_suite/cases/shuffle_aggregate_cross_node.rs index df1cb124c..fa4166003 100644 --- a/nodedb-cluster-tests/tests/common_suite/cases/shuffle_aggregate_cross_node.rs +++ b/nodedb-cluster-tests/tests/common_suite/cases/shuffle_aggregate_cross_node.rs @@ -89,6 +89,7 @@ fn agg_specs() -> Vec { user_alias: Some(user_alias.to_string()), field: field.to_string(), expr: None, + param: None, }; vec![ spec("count", "*", "cnt"), @@ -105,6 +106,7 @@ fn producer_plan(rows: &[&Row]) -> Vec { let scan = PhysicalPlan::Query(QueryOp::ProviderScan { provider: None, rows: msgpack_array(rows), + sources: Vec::new(), filters: Vec::new(), projection: Vec::new(), computed_columns: Vec::new(), diff --git a/nodedb-cluster-tests/tests/common_suite/cases/shuffle_consume_cross_node.rs b/nodedb-cluster-tests/tests/common_suite/cases/shuffle_consume_cross_node.rs index 4085e4d72..63ba3058f 100644 --- a/nodedb-cluster-tests/tests/common_suite/cases/shuffle_consume_cross_node.rs +++ b/nodedb-cluster-tests/tests/common_suite/cases/shuffle_consume_cross_node.rs @@ -102,6 +102,7 @@ fn provider_scan_plan(rows: &[&Row]) -> Vec { let plan = PhysicalPlan::Query(QueryOp::ProviderScan { provider: None, rows: msgpack_array(rows), + sources: Vec::new(), filters: Vec::new(), projection: Vec::new(), computed_columns: Vec::new(), diff --git a/nodedb-cluster-tests/tests/common_suite/cases/shuffle_produce_cross_node.rs b/nodedb-cluster-tests/tests/common_suite/cases/shuffle_produce_cross_node.rs index f4351274d..317ebef42 100644 --- a/nodedb-cluster-tests/tests/common_suite/cases/shuffle_produce_cross_node.rs +++ b/nodedb-cluster-tests/tests/common_suite/cases/shuffle_produce_cross_node.rs @@ -106,6 +106,7 @@ fn provider_scan_plan(rows: &[Vec]) -> Vec { let plan = PhysicalPlan::Query(QueryOp::ProviderScan { provider: None, rows: msgpack_array(rows), + sources: Vec::new(), filters: Vec::new(), projection: Vec::new(), computed_columns: Vec::new(), diff --git a/nodedb-cluster-tests/tests/common_suite/cases/vector_index_dispatch_cross_node.rs b/nodedb-cluster-tests/tests/common_suite/cases/vector_index_dispatch_cross_node.rs index 50773c9b9..474b2d5c9 100644 --- a/nodedb-cluster-tests/tests/common_suite/cases/vector_index_dispatch_cross_node.rs +++ b/nodedb-cluster-tests/tests/common_suite/cases/vector_index_dispatch_cross_node.rs @@ -123,6 +123,7 @@ async fn execute_on_node(node: &TestClusterNode, plan: PhysicalPlan) -> ExecuteR txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }; match transport .send_rpc_to_addr(node.listen_addr, RaftRpc::ExecuteRequest(request)) diff --git a/nodedb-cluster/src/calvin/types/transaction.rs b/nodedb-cluster/src/calvin/types/transaction.rs index 4585a1d38..c81238a3e 100644 --- a/nodedb-cluster/src/calvin/types/transaction.rs +++ b/nodedb-cluster/src/calvin/types/transaction.rs @@ -146,6 +146,13 @@ pub struct TxClass { #[serde(default)] #[msgpack(default)] pub restore_id: u64, + /// The roles of the identity whose writes the transaction commits. Every + /// participant's redo record carries them, so every replica judges a + /// role-guarded state transition against the same set. Empty for a + /// transaction no user wrote. + #[serde(default)] + #[msgpack(default)] + pub user_roles: Vec, /// Cached participating-vshard set. Re-derived on decode; not serialized. #[serde(skip)] #[msgpack(ignore)] @@ -296,6 +303,7 @@ impl TxClass { incarnations: Vec::new(), multi_part: None, restore_id: 0, + user_roles: Vec::new(), participating_vshards: Vec::new(), } } @@ -413,6 +421,11 @@ impl TxClass { self.event_source = code; } + /// Set the roles of the identity whose writes the transaction commits. + pub fn set_user_roles(&mut self, user_roles: Vec) { + self.user_roles = user_roles; + } + /// Mark the transaction as the re-issue of RESTORE `restore_id`. pub fn set_restore_id(&mut self, restore_id: u64) { self.restore_id = restore_id; diff --git a/nodedb-cluster/src/rpc_codec/execute/codec.rs b/nodedb-cluster/src/rpc_codec/execute/codec.rs index 4ea76e09d..4066c2ac3 100644 --- a/nodedb-cluster/src/rpc_codec/execute/codec.rs +++ b/nodedb-cluster/src/rpc_codec/execute/codec.rs @@ -167,6 +167,7 @@ mod tests { txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }; let decoded = roundtrip_req(req.clone()); assert_eq!(decoded.plan_bytes, req.plan_bytes); @@ -193,6 +194,7 @@ mod tests { txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }; let decoded = roundtrip_req(req); assert!(decoded.descriptor_versions.is_empty()); @@ -210,6 +212,7 @@ mod tests { txn_id: Some(nodedb_types::id::TxnId::new(9)), vshard_id: Some(nodedb_types::id::VShardId::new(513)), read_groups: vec![7, 9], + user_roles: vec!["approver".into()], }; let decoded = roundtrip_req(req); assert_eq!( @@ -218,6 +221,7 @@ mod tests { ); assert_eq!(decoded.txn_id, Some(nodedb_types::id::TxnId::new(9))); assert_eq!(decoded.read_groups, vec![7, 9]); + assert_eq!(decoded.user_roles, vec!["approver".to_string()]); } #[test] @@ -434,6 +438,7 @@ mod tests { txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }; let rpc = RaftRpc::ExecuteStreamRequest(req.clone()); let encoded = diff --git a/nodedb-cluster/src/rpc_codec/execute/types.rs b/nodedb-cluster/src/rpc_codec/execute/types.rs index c52225b3f..efc0267d6 100644 --- a/nodedb-cluster/src/rpc_codec/execute/types.rs +++ b/nodedb-cluster/src/rpc_codec/execute/types.rs @@ -50,6 +50,10 @@ pub struct ExecuteRequest { /// index for each and applies through it before it reads. Empty for a /// write or a read that accepts this replica as it is. pub read_groups: Vec, + /// The roles of the identity authenticated on the originating node. A + /// write carries them to every replica, which judges a role-guarded + /// state transition against them. Empty for a read or a system write. + pub user_roles: Vec, } /// Response to an `ExecuteRequest`. diff --git a/nodedb-cluster/src/transport/client/transport.rs b/nodedb-cluster/src/transport/client/transport.rs index 0e4608cb0..092f0a976 100644 --- a/nodedb-cluster/src/transport/client/transport.rs +++ b/nodedb-cluster/src/transport/client/transport.rs @@ -757,6 +757,7 @@ pub(super) mod tests { txn_id: None, vshard_id: None, read_groups: Vec::new(), + user_roles: Vec::new(), }); let stream = client.send_rpc_stream(1, req).await.unwrap(); diff --git a/nodedb-crdt/src/state/preview.rs b/nodedb-crdt/src/state/preview.rs index 3add2be6a..df9fb4574 100644 --- a/nodedb-crdt/src/state/preview.rs +++ b/nodedb-crdt/src/state/preview.rs @@ -62,6 +62,11 @@ pub struct CrdtDeltaPreview { pub post_image: Option, /// Deterministic canonical MessagePack representation of [`post_image`](Self::post_image). pub post_image_msgpack: Vec, + /// The target row as the authoritative state holds it before the delta, + /// in the same canonical encoding as `post_image_msgpack`. Encodes `None` + /// when the row is absent. A constraint on how a row may change judges + /// this image against the post-image. + pub pre_image_msgpack: Vec, /// SHA-256 over the post-import sorted `(peer, counter)` frontier entries. pub resulting_frontier_digest: [u8; 32], /// Number of new operations contributed beyond the fork's prior oplog @@ -182,6 +187,22 @@ impl CrdtState { }); } + // `read_row` opens the collection's root map, and opening an absent + // root registers it in the authoritative state. The pre-image reads + // the row only when its collection already exists, so the preview + // stays non-mutating. + let collection_exists = match self.doc.get_value() { + LoroValue::Map(roots) => roots.contains_key(expected_collection), + _ => false, + }; + let raw_pre_image = if collection_exists { + self.read_row(expected_collection, expected_row) + } else { + None + }; + let pre_image_msgpack = + zerompk::to_msgpack_vec(&raw_pre_image.as_ref().map(CanonicalLoroValue::from)) + .map_err(|error| CrdtError::Loro(format!("preview pre-image encode: {error}")))?; let raw_post_image = fork_state.read_row(expected_collection, expected_row); let post_image = raw_post_image.as_ref().map(loro_to_value); let post_image_msgpack = @@ -198,6 +219,7 @@ impl CrdtState { write_set, post_image, post_image_msgpack, + pre_image_msgpack, resulting_frontier_digest: CrdtState::frontier_digest_from(&resulting_frontier), imported_ops, trimmed_ops: admission.trimmed_operations(), diff --git a/nodedb-physical/src/physical_plan/aggregate_functions.rs b/nodedb-physical/src/physical_plan/aggregate_functions.rs new file mode 100644 index 000000000..054d795f1 --- /dev/null +++ b/nodedb-physical/src/physical_plan/aggregate_functions.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The aggregate functions the generic aggregate accumulates. +//! +//! An [`AggregateSpec`](super::AggregateSpec) sent to the generic aggregate +//! names one of these functions, or the `grouping` pseudo-aggregate. The +//! planner refuses any other function before it builds the spec. + +/// Function names with a generic accumulator. +pub const GENERIC_AGGREGATE_FUNCTIONS: &[&str] = &[ + "count", + "sum", + "avg", + "sum_distinct", + "avg_distinct", + "min", + "max", + "count_distinct", + "stddev", + "stddev_pop", + "stddev_samp", + "variance", + "var_pop", + "var_samp", + "ts_stddev", + "approx_count_distinct", + "approx_percentile", + "approx_topk", + "array_agg", + "array_agg_distinct", + "percentile_cont", + "string_agg", + "group_concat", +]; + +/// Generic aggregates that take a constant parameter beside their input: +/// the `k` of `approx_topk`, the percentile of `approx_percentile` and +/// `percentile_cont`. Each spec carries it in +/// [`AggregateSpec::param`](super::AggregateSpec::param), and a spec +/// without it is refused. +pub const PARAMETERIZED_AGGREGATE_FUNCTIONS: &[&str] = + &["approx_topk", "approx_percentile", "percentile_cont"]; + +/// The constant parameter of a parameterized aggregate. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub enum AggregateParam { + /// The fraction of `approx_percentile` / `percentile_cont`, in `[0, 1]`. + Percentile { fraction: f64 }, + /// The `k` of `approx_topk`, at least 1. + TopK { k: u32 }, +} diff --git a/nodedb-physical/src/physical_plan/collection.rs b/nodedb-physical/src/physical_plan/collection.rs index 02a6c8598..fef29a5b2 100644 --- a/nodedb-physical/src/physical_plan/collection.rs +++ b/nodedb-physical/src/physical_plan/collection.rs @@ -82,6 +82,9 @@ impl PhysicalPlan { | PhysicalPlan::Timeseries(TimeseriesOp::Scan { collection, .. }) | PhysicalPlan::Timeseries(TimeseriesOp::Ingest { collection, .. }) | PhysicalPlan::Timeseries(TimeseriesOp::Truncate { collection, .. }) + | PhysicalPlan::Timeseries(TimeseriesOp::RefreshAggregate { collection, .. }) + | PhysicalPlan::Timeseries(TimeseriesOp::Partitions { collection, .. }) + | PhysicalPlan::Timeseries(TimeseriesOp::ReadAggregate { collection, .. }) | PhysicalPlan::Spatial(SpatialOp::Scan { collection, .. }) | PhysicalPlan::Document(DocumentOp::Register { collection, .. }) | PhysicalPlan::Document(DocumentOp::IndexLookup { collection, .. }) @@ -94,7 +97,10 @@ impl PhysicalPlan { PhysicalPlan::Timeseries(TimeseriesOp::ResolveIngest(inner)) => match &inner.ingest { TimeseriesOp::Scan { collection, .. } | TimeseriesOp::Ingest { collection, .. } - | TimeseriesOp::Truncate { collection, .. } => Some(collection.as_str()), + | TimeseriesOp::Truncate { collection, .. } + | TimeseriesOp::RefreshAggregate { collection, .. } + | TimeseriesOp::Partitions { collection, .. } + | TimeseriesOp::ReadAggregate { collection, .. } => Some(collection.as_str()), TimeseriesOp::ResolveIngest(_) => None, }, // Same shape on the graph side, and `EdgeDelete` itself reports diff --git a/nodedb-physical/src/physical_plan/document/mod.rs b/nodedb-physical/src/physical_plan/document/mod.rs index 39ee94ba1..bd1d0115c 100644 --- a/nodedb-physical/src/physical_plan/document/mod.rs +++ b/nodedb-physical/src/physical_plan/document/mod.rs @@ -8,6 +8,7 @@ pub mod merge_types; pub mod ollp_edge; pub mod op; pub mod resolved_mutation; +pub mod row_source; pub mod sum_target; pub mod timeseries_schema; pub mod types; @@ -21,6 +22,7 @@ pub use merge_types::{MergeActionOp, MergeClauseKind as MergeClauseKindOp, Merge pub use ollp_edge::OllpPredictedEdge; pub use op::DocumentOp; pub use resolved_mutation::{DocumentResolveOutcome, DocumentResolvedMutation}; +pub use row_source::{DocumentPageSource, QuerySource, RowSource}; pub use sum_target::{RedoSumTargets, ResolvedSumTarget, SumTargetKey, resolved_sum_surrogate}; pub use timeseries_schema::TimeseriesSchema; pub use types::{ diff --git a/nodedb-physical/src/physical_plan/document/op.rs b/nodedb-physical/src/physical_plan/document/op.rs index 6af3676cf..d5e5b2d1e 100644 --- a/nodedb-physical/src/physical_plan/document/op.rs +++ b/nodedb-physical/src/physical_plan/document/op.rs @@ -6,6 +6,7 @@ use nodedb_types::{ use super::merge_types::MergeClauseOp; use super::ollp_edge::OllpPredictedEdge; +use super::row_source::RowSource; use super::sum_target::ResolvedSumTarget; use super::timeseries_schema::TimeseriesSchema; use super::types::{EnforcementOptions, RegisteredIndex, ReturningSpec, StorageMode, UpdateValue}; @@ -200,10 +201,19 @@ pub enum DocumentOp { }, /// Batch insert documents in a single redb transaction. + /// + /// Every row is judged as a `PointInsert` judges it: a key already + /// stored, or one the page names twice, is refused with a `unique` + /// violation (23505) and the page writes nothing. With `if_absent` a + /// stored key skips its row instead, as `ON CONFLICT DO NOTHING` does. BatchInsert { collection: QualifiedCollection, /// (document_id, value_bytes) pairs. documents: Vec<(String, Vec)>, + /// `INSERT ... ON CONFLICT DO NOTHING`: a row whose key is already + /// stored is skipped, not refused. See `PointInsert::if_absent`. + #[serde(default)] + if_absent: bool, /// Per-row surrogates, parallel to `documents`: one bound surrogate /// per row. A batch with a `Surrogate::ZERO` entry is refused before /// any row is written. @@ -360,16 +370,13 @@ pub enum DocumentOp { field: String, }, - /// INSERT ... SELECT: copy documents from source to target. + /// INSERT ... SELECT: copy the rows of a source relation into the target. + /// The Control Plane orchestrates it: it reads `source`, gives each row + /// a fresh target surrogate, and writes the rows. It never reaches the + /// Data Plane as itself. InsertSelect { target_collection: QualifiedCollection, - source_collection: QualifiedCollection, - source_filters: Vec, - source_limit: usize, - /// zerompk-encoded `Vec`: one entry per target column, - /// its `alias` the target column name and its `expr` the source-row - /// expression. Empty means copy each source row unchanged. - column_map: Vec, + source: RowSource, }, /// Upsert: insert or merge. When `on_conflict_updates` is non-empty, @@ -401,12 +408,14 @@ pub enum DocumentOp { resolved_sum_targets: Vec, }, - /// Update target rows matched by a join with a source collection: scan - /// source, build a merged doc per matching target row (source fields - /// qualified `.`), evaluate `updates`, write back. + /// Update target rows matched by a join with a source relation: build a + /// merged doc per matching target row (source fields qualified + /// `.`), evaluate `updates`, write back. UpdateFromJoin { target_collection: QualifiedCollection, - source_collection: QualifiedCollection, + /// The source relation. The Control Plane reads it and ships its rows + /// in `source_rows`; the Data Plane never reads it. + source: RowSource, /// Qualifier used for source columns in assignment expressions. source_alias: String, /// Field in the target used for the equi-join. @@ -419,13 +428,12 @@ pub enum DocumentOp { target_filters: Vec, #[serde(default)] returning: Option, - /// Control-Plane-shipped source rows for cross-core `UPDATE ... FROM`. - /// When `Some`, the join-map is built from these pre-scanned - /// `(source_doc_id, raw_stored_bytes)` rows instead of local storage — - /// needed because source and target can live on different - /// Data-Plane cores. `None` = legacy local-read path. + /// The source rows, read by the Control Plane, each a standard + /// msgpack map. The Data Plane builds the join map from them only. + /// `None` is the unresolved shape every Control-Plane entry point + /// intercepts; the Data Plane refuses it. #[serde(default)] - source_rows: Option)>>, + source_rows: Option>>, /// See `PointDelete::rls_filters`; every returned row is a target row. #[serde(default)] rls_filters: Vec, @@ -511,7 +519,8 @@ pub enum DocumentOp { /// with no source match applying `NotMatchedBySource`. Merge { target_collection: QualifiedCollection, - source_collection: QualifiedCollection, + /// See `UpdateFromJoin::source`. + source: RowSource, /// Qualifier used for source columns in assignment expressions. source_alias: String, target_join_col: String, @@ -520,7 +529,8 @@ pub enum DocumentOp { #[serde(default)] returning: Option, /// Control-Plane-pre-assigned surrogates for NOT-MATCHED insert rows, - /// keyed by source join value. When `Some`, the handler re-verifies + /// keyed by source row id (the row's position in `source_rows`, as + /// text). When `Some`, the handler re-verifies /// the insert set against these keys (`OllpRetryRequired` on drift, /// no write) and applies every arm in one redb transaction. `None` /// is the unresolved shape every Control-Plane entry point @@ -536,7 +546,7 @@ pub enum DocumentOp { resolved_insert_identities: Vec<(String, u32)>, /// See `UpdateFromJoin::source_rows`. #[serde(default)] - source_rows: Option)>>, + source_rows: Option>>, /// See `PointDelete::rls_filters`; every returned row is a target row. #[serde(default)] rls_filters: Vec, diff --git a/nodedb-physical/src/physical_plan/document/row_source.rs b/nodedb-physical/src/physical_plan/document/row_source.rs new file mode 100644 index 000000000..682aa502f --- /dev/null +++ b/nodedb-physical/src/physical_plan/document/row_source.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The relation a Control-Plane-orchestrated `INSERT ... SELECT`, `MERGE`, or +//! `UPDATE ... FROM` reads its source rows from. + +use nodedb_types::QualifiedCollection; + +use crate::physical_plan::PhysicalPlan; + +/// Source relation of an orchestrated copy or join write. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub enum RowSource { + /// A plain document scan, read page by page through + /// `DocumentOp::MaterializeScan`. The B-tree key cursor is stable under + /// concurrent writes, so the read has no row-count bound. The Control + /// Plane applies the WHERE filter, the offset, the limit, and the column + /// map to each page. + DocumentPages(DocumentPageSource), + /// Any other SELECT, lowered by the normal plan converter and read once + /// through the Data Plane dispatch funnel. It has exactly SELECT + /// semantics: engine, filters, ordering, distinct, windows, joins, `AS + /// OF`, and read policies all come from the normal pipeline, under the + /// normal result-byte budget. + Query(QuerySource), +} + +/// A plain document scan source, read page by page. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct DocumentPageSource { + pub collection: QualifiedCollection, + /// Serialized `Vec`: the WHERE clause, ANDed with the + /// collection's read policy by the RLS pass. + pub filters: Vec, + /// `FOR SYSTEM_TIME AS OF` cut, in epoch milliseconds. `None` reads the + /// current version. + pub system_as_of_ms: Option, + /// Matching rows skipped before the first row is read. + pub offset: usize, + /// Matching rows read after the offset. `None` reads every matching row. + pub limit: Option, + /// zerompk `Vec`: one entry per target column, its alias + /// the target column and its expression the source-row expression. + /// Empty passes each source row through unchanged. + pub column_map: Vec, +} + +/// A source SELECT read once as a whole relation. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct QuerySource { + /// The lowered SELECT. + pub plan: Box, + /// zerompk `Vec` that renames each output column to the + /// target column it writes. Empty passes each output row through + /// unchanged. + pub column_map: Vec, +} + +impl RowSource { + /// A page source that reads every current row of `collection` unchanged. + pub fn whole_collection(collection: QualifiedCollection) -> Self { + RowSource::DocumentPages(DocumentPageSource { + collection, + filters: Vec::new(), + system_as_of_ms: None, + offset: 0, + limit: None, + column_map: Vec::new(), + }) + } + + /// The collection a page source scans. `None` for a query source, whose + /// collections are the ones its plan reads. + pub fn paged_collection(&self) -> Option<&QualifiedCollection> { + match self { + RowSource::DocumentPages(page) => Some(&page.collection), + RowSource::Query(_) => None, + } + } + + /// The plan a query source runs. `None` for a page source. + pub fn query_plan(&self) -> Option<&PhysicalPlan> { + match self { + RowSource::DocumentPages(_) => None, + RowSource::Query(query) => Some(&query.plan), + } + } + + /// A short label for logs and error messages: the paged collection, or + /// the primary collection of the query plan. + pub fn label(&self) -> &str { + match self { + RowSource::DocumentPages(page) => page.collection.as_str(), + RowSource::Query(query) => query.plan.collection().unwrap_or(""), + } + } +} diff --git a/nodedb-physical/src/physical_plan/document/timeseries_schema.rs b/nodedb-physical/src/physical_plan/document/timeseries_schema.rs index 939dfd842..2e5a43ac9 100644 --- a/nodedb-physical/src/physical_plan/document/timeseries_schema.rs +++ b/nodedb-physical/src/physical_plan/document/timeseries_schema.rs @@ -27,6 +27,10 @@ pub struct TimeseriesSchema { pub time_key: String, /// Declared `(column_name, type_str)` pairs in declaration order. pub columns: Vec<(String, String)>, + /// The declared partition interval, as `partition_by` spells it (`1h`, + /// `1d`, `1M`, `1y`, `AUTO`, `UNBOUNDED`). A flush cuts one partition per + /// interval its rows fall in. + pub partition_interval: String, } impl TimeseriesSchema { diff --git a/nodedb-physical/src/physical_plan/document/types.rs b/nodedb-physical/src/physical_plan/document/types.rs index d32109d2c..f1ead77ee 100644 --- a/nodedb-physical/src/physical_plan/document/types.rs +++ b/nodedb-physical/src/physical_plan/document/types.rs @@ -81,6 +81,11 @@ pub struct EnforcementOptions { /// On UPDATE: re-evaluate if any `depends_on` column changed. #[serde(default)] pub generated_columns: Vec, + /// The collection's typeguards. Schemaless collections only; empty + /// otherwise. Every write injects their DEFAULT / VALUE into the stored + /// body and validates REQUIRED, type and CHECK. + #[serde(default)] + pub type_guards: Vec, } impl EnforcementOptions { diff --git a/nodedb-physical/src/physical_plan/gathered_source.rs b/nodedb-physical/src/physical_plan/gathered_source.rs new file mode 100644 index 000000000..09a5b7486 --- /dev/null +++ b/nodedb-physical/src/physical_plan/gathered_source.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Where the rows of a coordinator-gathered +//! [`crate::physical_plan::QueryOp::ProviderScan`] were read from. + +/// One resource the plan behind a gathered `ProviderScan` read. +/// +/// The coordinator replaces a gathered child plan (a join input, a +/// post-processed body, an aggregate input, a set-operation branch) with a +/// `ProviderScan` of its rows. The task is authorized again after that +/// replacement. Each source keeps the requirement the child had, so that +/// check demands what the check of the child demanded. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub enum GatheredSource { + /// The child read this collection. It requires Read on it. + Collection(String), + /// The child read no collection at this point, such as constant rows. + /// It requires tenant-wide Read. + Tenant, +} diff --git a/nodedb-physical/src/physical_plan/meta.rs b/nodedb-physical/src/physical_plan/meta.rs index 75853a614..5532e3b87 100644 --- a/nodedb-physical/src/physical_plan/meta.rs +++ b/nodedb-physical/src/physical_plan/meta.rs @@ -59,10 +59,27 @@ pub enum MetaOp { /// Remove a continuous aggregate from this core's manager. UnregisterContinuousAggregate { name: String }, - /// List all continuous aggregates from this core's manager. - /// Returns JSON-serialized `Vec`. + /// List the request tenant's continuous aggregates from this core's + /// manager. Returns MessagePack-encoded `Vec`. ListContinuousAggregates, + /// Boot registered every continuous aggregate the catalog holds on this + /// core. The core drops every persisted image entry of an aggregate + /// that is not registered, and writes the images. + ContinuousAggregatesRegistered, + + /// Wait until the refresh cut `refresh_id` of the named continuous + /// aggregate is fully folded. `collection` is the aggregate's root + /// source collection: the wait runs on the core that owns its vShard, + /// where the cut applied. The core holds the request and answers it + /// from its tick, so the fold runs under the per-tick budget while the + /// core serves other requests. The request deadline bounds the wait. + AwaitContinuousAggregateRefresh { + collection: QualifiedCollection, + name: String, + refresh_id: u64, + }, + /// Convert a collection's storage mode. /// /// Scans all documents in the collection and re-encodes them for the @@ -208,9 +225,16 @@ pub enum MetaOp { /// Enforce retention on a timeseries collection: drop segments older than /// the cutoff. Called by the retention policy enforcement loop. + /// + /// `aggregates` lists every continuous aggregate the catalog roots at + /// `collection`. Retention folds an expiring partition into those + /// aggregates before it removes it, so the core refuses the pass while + /// one of them is not registered on it at that incarnation. The loop + /// retries on its next pass. EnforceTimeseriesRetention { collection: QualifiedCollection, max_age_ms: i64, + aggregates: Vec, }, /// Bitemporal audit-retention purge for the graph edge store. @@ -245,10 +269,17 @@ pub enum MetaOp { /// column version has been superseded by a later partition is removed. /// Non-bitemporal columnar collections are a no-op (collection is /// expected to be flagged bitemporal by the caller). + /// + /// `aggregates` lists every continuous aggregate the catalog roots at + /// `collection`, as in `EnforceTimeseriesRetention`. A timeseries + /// collection's purge removes partitions, so the core refuses it while + /// one of them is not registered on it at that incarnation. A plain + /// columnar collection roots no aggregate, and its list is empty. TemporalPurgeColumnar { tenant_id: u64, collection: QualifiedCollection, cutoff_system_ms: i64, + aggregates: Vec, }, /// Bitemporal audit-retention purge for CRDT (Loro-backed) collections. diff --git a/nodedb-physical/src/physical_plan/mod.rs b/nodedb-physical/src/physical_plan/mod.rs index f403546ae..4bf253391 100644 --- a/nodedb-physical/src/physical_plan/mod.rs +++ b/nodedb-physical/src/physical_plan/mod.rs @@ -6,6 +6,7 @@ //! each defined in its own module. This keeps each engine's operations //! isolated. +pub mod aggregate_functions; pub mod array; pub mod calvin_reply; pub mod cluster_array; @@ -15,6 +16,7 @@ pub mod columnar; pub mod crdt; pub mod document; pub mod exchange; +pub mod gathered_source; pub mod graph; pub mod kv; pub mod meta; @@ -32,11 +34,15 @@ pub mod sort_key; pub mod spatial; pub mod streaming; pub mod text; +pub mod tiering; pub mod timeseries; pub mod truncate_target; pub mod vector; pub mod wire; +pub use aggregate_functions::{ + AggregateParam, GENERIC_AGGREGATE_FUNCTIONS, PARAMETERIZED_AGGREGATE_FUNCTIONS, +}; pub use array::{ArrayBinaryOp, ArrayOp, ArrayReducer}; pub use calvin_reply::{ CalvinInstall, CalvinInstalledTimeseriesSpec, CalvinPostImagesSpec, CalvinReplyRow, @@ -47,13 +53,14 @@ pub use cluster_event::{ClusterEventOp, MAX_REMOTE_CDC_COMMITTED_OFFSETS}; pub use columnar::{ColumnarInsertIntent, ColumnarOp}; pub use crdt::{CrdtOp, CrdtWriteVerb}; pub use document::{ - BalancedDef, DeclaredColumn, DocumentOp, DocumentResolveOutcome, DocumentResolvedMutation, - EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding, OllpPredictedEdge, - PeriodLockConfig, RedoSumTargets, RegisteredIndex, RegisteredIndexState, ResolvedSumTarget, - ReturningColumns, ReturningItem, ReturningSpec, StorageMode, SumTargetKey, TimeseriesSchema, - UpdateValue, resolved_sum_surrogate, + BalancedDef, DeclaredColumn, DocumentOp, DocumentPageSource, DocumentResolveOutcome, + DocumentResolvedMutation, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding, + OllpPredictedEdge, PeriodLockConfig, QuerySource, RedoSumTargets, RegisteredIndex, + RegisteredIndexState, ResolvedSumTarget, ReturningColumns, ReturningItem, ReturningSpec, + RowSource, StorageMode, SumTargetKey, TimeseriesSchema, UpdateValue, resolved_sum_surrogate, }; pub use exchange::{ExchangeMode, ExchangeOp}; +pub use gathered_source::GatheredSource; pub use graph::{ AlgoEdge, AlgoStage, BatchEdge, BspSuperstepPlan, BspSuperstepResult, GraphOp, RagBindingRow, RagLegs, RagStage, RagTextHit, RagVectorHit, WccSuperstepPlan, WccSuperstepResult, @@ -74,7 +81,11 @@ pub use set_op::SetOpKind; pub use sort_key::SortKeySpec; pub use spatial::{SpatialOp, SpatialPredicate}; pub use text::{ScoreScanBound, ScoreScanOrder, TextOp, TextScoreSpec}; -pub use timeseries::{TimeseriesOp, TimeseriesResolve, UNBOUNDED_TIME_RANGE}; +pub use tiering::{TierRead, TierSegment, TierSource, TieringError, TieringPlan}; +pub use timeseries::{ + AggregateIncarnation, PartitionsAction, RefreshCut, TimeseriesOp, TimeseriesResolve, + UNBOUNDED_TIME_RANGE, +}; pub use vector::{ VectorDirectWriteIntent, VectorOp, VectorResolveOutcome, VectorResolvedMutation, VectorWriteTargets, diff --git a/nodedb-physical/src/physical_plan/query.rs b/nodedb-physical/src/physical_plan/query.rs index 67e023498..317c90be5 100644 --- a/nodedb-physical/src/physical_plan/query.rs +++ b/nodedb-physical/src/physical_plan/query.rs @@ -25,6 +25,11 @@ pub struct AggregateSpec { pub field: String, /// Optional expression to evaluate per-document before aggregating. pub expr: Option, + /// The constant parameter of a parameterized aggregate (see + /// [`super::PARAMETERIZED_AGGREGATE_FUNCTIONS`]); `None` for every other + /// function. + #[serde(default)] + pub param: Option, } #[derive( @@ -55,23 +60,30 @@ pub enum QueryOp { /// Coordinator-resolved data-movement wrapper. Defensive error if it reaches a core. Exchange(crate::physical_plan::ExchangeOp), - /// Pre-materialized rows. `provider: Some(name)` => filled per-request - /// (identity-scoped catalog table); `None` => `rows` is final (constant - /// SELECT or gathered broadcast child). `rows` is a canonical msgpack array - /// of map values (the `encode_binary_rows` shape). + /// Pre-materialized rows. `provider: Some(name)` => a named relation (an + /// identity-scoped catalog table or a continuous aggregate) whose rows + /// the coordinator fills per request; `None` => `rows` is final + /// (constant SELECT or a gathered child). `rows` is a canonical msgpack + /// array of map values (the `encode_binary_rows` shape). /// /// The executor applies predicate, offset, sort, distinct, projection, and /// limit in that order over the rows before emitting the response. Empty /// `filters`/`projection` and `None` limit are no-ops (all rows emitted /// unchanged through those stages). ProviderScan { - /// Catalog provider name for deferred materialization by the coordinator. - /// `Some(name)` is replaced with `None` after `materialize_providers` - /// fills `rows`. Data-Plane cores must never see `Some`. + /// Named relation the coordinator materializes. The name stays after + /// the coordinator fills `rows`, so the authorization of the filled + /// plan demands Read on it. The Data Plane reads only `rows`. provider: Option, /// Msgpack array of plain map rows. Filled by the coordinator from the - /// catalog producer or from a gathered broadcast child. + /// catalog producer or from a gathered child. rows: Vec, + /// What a gathered child read to produce `rows`. The authorization + /// of the plan that embeds these rows demands each source's + /// requirement. Empty for a named provider and for constant rows, + /// which `provider` and the tenant-wide fallback cover. + #[serde(default)] + sources: Vec, /// Serialized `Vec` (MessagePack). Empty = no predicate. #[serde(default)] filters: Vec, @@ -252,10 +264,6 @@ pub enum QueryOp { on: Vec<(String, String)>, join_type: String, limit: usize, - /// Post-join GROUP BY columns (empty = no aggregation). - post_group_by: Vec, - /// Post-join aggregates: (op, field) pairs (empty = no aggregation). - post_aggregates: Vec<(String, String)>, /// Post-join projection: column names to keep (empty = all). projection: Vec, /// MessagePack-encoded computed projection expressions. When present, @@ -379,6 +387,13 @@ pub enum QueryOp { left_rls_filters: Vec, /// Row-level-security filters for rows scanned from `right_collection`. right_rls_filters: Vec, + /// Resolved rows for the left side, set by the coordinator inside a + /// transaction so the side reads its staged writes. `None` means the + /// side is scanned locally by `left_collection`. When set, the child + /// carries the side's read policy and `left_rls_filters` is empty. + left_input: Option>, + /// Resolved rows for the right side. Same semantics as `left_input`. + right_input: Option>, }, /// Sort-merge join: both sides pre-sorted by join key. @@ -396,6 +411,11 @@ pub enum QueryOp { left_rls_filters: Vec, /// Row-level-security filters for rows scanned from `right_collection`. right_rls_filters: Vec, + /// Resolved rows for the left side. Same semantics as + /// `QueryOp::NestedLoopJoin::left_input`. + left_input: Option>, + /// Resolved rows for the right side. Same semantics as `left_input`. + right_input: Option>, }, /// Multi-facet aggregation: compute facet counts for multiple fields diff --git a/nodedb-physical/src/physical_plan/tiering.rs b/nodedb-physical/src/physical_plan/tiering.rs new file mode 100644 index 000000000..6b17afe66 --- /dev/null +++ b/nodedb-physical/src/physical_plan/tiering.rs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! AUTO_TIER routing of one timeseries scan across retention tiers. +//! +//! A tiered retention policy keeps raw rows for `raw_retain_ms`, then +//! downsampled buckets in one continuous aggregate per tier. A scan carries +//! the policy's tier layout; the core that owns the collection splits the +//! query range into segments and reads each segment from its tier. + +/// The tier layout of an AUTO_TIER retention policy. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct TieringPlan { + /// How long raw rows are kept, in milliseconds. 0 = forever. + pub raw_retain_ms: u64, + /// Downsample tiers, finest first. + pub tiers: Vec, +} + +/// One downsample tier of a [`TieringPlan`]. +#[derive( + Debug, + Clone, + PartialEq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct TierRead { + /// The policy's tier number (1 = first downsample tier). + pub tier_index: u32, + /// The continuous aggregate that holds this tier's buckets. + pub aggregate: String, + /// Bucket width in milliseconds. + pub resolution_ms: i64, + /// How long this tier keeps buckets, in milliseconds. 0 = forever. + pub retain_ms: u64, +} + +/// Which source serves one segment of a tiered scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TierSource { + /// Raw rows of the collection. + Raw, + /// Buckets of `TieringPlan::tiers[index]`. + Tier(usize), +} + +/// One time segment of a tiered scan: `[start, end)` read from `source`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TierSegment { + pub source: TierSource, + pub start: i64, + pub end: i64, +} + +/// Why a tier layout cannot split a range. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TieringError { + #[error( + "tier {tier_index} resolution {resolution_ms}ms is not a positive multiple of the \ + finer tier's {finer_ms}ms; a coarser bucket would straddle the finer tier's edge" + )] + UnalignedResolution { + tier_index: u32, + resolution_ms: i64, + finer_ms: i64, + }, +} + +impl TieringPlan { + /// Split `query` (an inclusive `(min, max)` millisecond range) into + /// segments, newest first. + /// + /// The edge between two sources is the retention boundary rounded up to + /// the coarser tier's resolution, so every bucket a tier serves lies + /// wholly before the edge and no row is read from two sources. + pub fn segments( + &self, + query: (i64, i64), + now_ms: i64, + ) -> Result, TieringError> { + let (query_start, query_end) = query; + let mut segments = Vec::new(); + // `upper` is the exclusive end of the source being placed. + let mut upper = query_end.saturating_add(1); + let mut source = TierSource::Raw; + let mut boundary = now_ms; + let mut retain = self.raw_retain_ms; + let mut finer_ms = 1i64; + for (index, tier) in self.tiers.iter().enumerate() { + if retain == 0 { + break; + } + if tier.resolution_ms <= 0 || tier.resolution_ms % finer_ms != 0 { + return Err(TieringError::UnalignedResolution { + tier_index: tier.tier_index, + resolution_ms: tier.resolution_ms, + finer_ms, + }); + } + boundary = boundary.saturating_sub(retain as i64); + let edge = align_up(boundary, tier.resolution_ms); + push_segment(&mut segments, source, edge.max(query_start), upper); + upper = upper.min(edge); + source = TierSource::Tier(index); + retain = tier.retain_ms; + finer_ms = tier.resolution_ms; + } + push_segment(&mut segments, source, query_start, upper); + Ok(segments) + } +} + +/// Append `[start, end)` when it is non-empty. +fn push_segment(out: &mut Vec, source: TierSource, start: i64, end: i64) { + if start < end { + out.push(TierSegment { source, start, end }); + } +} + +/// `ms` rounded up to a multiple of `step` (`step > 0`). +fn align_up(ms: i64, step: i64) -> i64 { + let floor = ms.div_euclid(step).saturating_mul(step); + if floor == ms { + floor + } else { + floor.saturating_add(step) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: i64 = 86_400_000; + + fn plan() -> TieringPlan { + TieringPlan { + raw_retain_ms: 7 * DAY as u64, + tiers: vec![ + TierRead { + tier_index: 1, + aggregate: "_policy_p_tier1".into(), + resolution_ms: 60_000, + retain_ms: 90 * DAY as u64, + }, + TierRead { + tier_index: 2, + aggregate: "_policy_p_tier2".into(), + resolution_ms: 3_600_000, + retain_ms: 0, + }, + ], + } + } + + #[test] + fn recent_range_reads_raw_only() { + let now = 100_000 * DAY; + let segments = plan().segments((now - 3_600_000, now), now).expect("split"); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].source, TierSource::Raw); + assert_eq!(segments[0].end, now + 1); + } + + #[test] + fn range_spanning_tiers_splits_on_aligned_edges() { + let now = 100_000 * DAY + 12_345; + let segments = plan().segments((now - 200 * DAY, now), now).expect("split"); + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].source, TierSource::Raw); + assert_eq!(segments[1].source, TierSource::Tier(0)); + assert_eq!(segments[2].source, TierSource::Tier(1)); + // Edges are aligned to the coarser source's resolution and contiguous. + assert_eq!(segments[0].start, segments[1].end); + assert_eq!(segments[1].start, segments[2].end); + assert_eq!(segments[0].start % 60_000, 0); + assert_eq!(segments[1].start % 3_600_000, 0); + } + + #[test] + fn forever_raw_retention_never_reads_a_tier() { + let mut plan = plan(); + plan.raw_retain_ms = 0; + let now = 100_000 * DAY; + let segments = plan.segments((0, now), now).expect("split"); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].source, TierSource::Raw); + } + + #[test] + fn unaligned_resolution_is_refused() { + let mut plan = plan(); + plan.tiers[1].resolution_ms = 90_000; + let now = 100_000 * DAY; + assert!(matches!( + plan.segments((0, now), now), + Err(TieringError::UnalignedResolution { tier_index: 2, .. }) + )); + } +} diff --git a/nodedb-physical/src/physical_plan/timeseries.rs b/nodedb-physical/src/physical_plan/timeseries.rs index c7cdbedba..fceb106a9 100644 --- a/nodedb-physical/src/physical_plan/timeseries.rs +++ b/nodedb-physical/src/physical_plan/timeseries.rs @@ -62,6 +62,15 @@ pub enum TimeseriesOp { /// `[_ts_valid_from, _ts_valid_until)` contains it are returned. #[serde(default)] valid_at_ms: Option, + /// HAVING predicates (MessagePack `Vec`) over the + /// finalized group rows, applied after gap-fill and before sort and + /// limit. Empty on a raw scan. + #[serde(default)] + having: Vec, + /// AUTO_TIER layout. `Some` routes each segment of the range to the + /// raw rows or to a downsample tier's buckets. + #[serde(default)] + tiering: Option, }, /// Write a batch of samples to the columnar memtable. @@ -116,6 +125,105 @@ pub enum TimeseriesOp { #[serde(default)] restart_identity: bool, }, + + /// A refresh cut of one continuous aggregate rooted at `collection`. + /// + /// The cut replicates on the collection's data group and applies on + /// every replica in log order. At apply the replica flushes the + /// collection's memtable, so every row of every record before the cut + /// sits in a partition and no row after it does. The aggregate then + /// folds exactly the partitions present at that moment, on the + /// maintenance tick, so every replica folds the same rows. + RefreshAggregate { + collection: QualifiedCollection, + cut: RefreshCut, + }, + + /// Partition maintenance on the core that owns `collection`: + /// `SHOW PARTITIONS FOR` lists its partitions, and `REWRITE PARTITIONS + /// FOR` rewrites each sealed one. Neither changes a row, so neither is + /// logged or replicated. + Partitions { + collection: QualifiedCollection, + action: PartitionsAction, + }, + + /// The finalized rows of continuous aggregate `aggregate`, read on the + /// core that owns its root source `collection`: one map per bucket and + /// group, holding `bucket`, the GROUP BY columns and the output columns. + /// The read completes the aggregate's backfill and pending refresh cuts + /// first, so it never answers from partial buckets. It changes no row, + /// so it is neither logged nor replicated. + ReadAggregate { + collection: QualifiedCollection, + aggregate: String, + }, +} + +/// What a [`TimeseriesOp::Partitions`] does. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +#[msgpack(c_enum)] +pub enum PartitionsAction { + /// List every queryable partition with its state, time range, row count, + /// size and interval. + List, + /// Rewrite every sealed or merged partition with the collection's current + /// schema and codecs. A column the schema no longer holds is dropped. + Rewrite, +} + +/// One continuous aggregate by name and incarnation. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct AggregateIncarnation { + pub name: String, + pub incarnation: nodedb_types::Hlc, +} + +/// The cut a [`TimeseriesOp::RefreshAggregate`] makes in one continuous +/// aggregate. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + zerompk::ToMessagePack, + zerompk::FromMessagePack, +)] +pub struct RefreshCut { + /// Aggregate name. + pub aggregate: String, + /// Incarnation of the aggregate the cut was proposed for. A replica + /// whose aggregate of that name has another incarnation never applies + /// the cut to it. + pub incarnation: nodedb_types::Hlc, + /// Names the cut on every replica. A second apply of the same id is a + /// no-op, and a waiter asks for completion by it. For a periodic cut it + /// is the period slot: `now_ms / interval_ms`. + pub refresh_id: u64, + /// The periodic catch-up proposed for slot `refresh_id`. A replica + /// applies only slots above the last one it applied. + pub periodic: bool, } /// What a [`TimeseriesOp::ResolveIngest`] resolves. diff --git a/nodedb-physical/src/physical_plan/wire.rs b/nodedb-physical/src/physical_plan/wire.rs index e3c37969a..21385209d 100644 --- a/nodedb-physical/src/physical_plan/wire.rs +++ b/nodedb-physical/src/physical_plan/wire.rs @@ -117,8 +117,6 @@ mod tests { on: vec![("id".into(), "id".into())], join_type: "left".into(), limit: 10, - post_group_by: Vec::new(), - post_aggregates: Vec::new(), projection: vec![JoinProjection { source: "l.id".into(), output: "id".into(), diff --git a/nodedb-query/src/expr_parse/mod.rs b/nodedb-query/src/expr_parse/mod.rs index 138e946e0..a06584eab 100644 --- a/nodedb-query/src/expr_parse/mod.rs +++ b/nodedb-query/src/expr_parse/mod.rs @@ -2,7 +2,9 @@ pub mod error; pub mod parser; +mod predicate; mod tokenizer; +mod validate; pub use error::ExprParseError; pub use parser::parse_generated_expr; diff --git a/nodedb-query/src/expr_parse/parser.rs b/nodedb-query/src/expr_parse/parser.rs index a312765d5..0521dbbfb 100644 --- a/nodedb-query/src/expr_parse/parser.rs +++ b/nodedb-query/src/expr_parse/parser.rs @@ -10,6 +10,8 @@ //! - Binary operators: `+`, `-`, `*`, `/`, `%` //! - Comparison: `=`, `!=`, `<>`, `<`, `>`, `<=`, `>=` //! - Logical: `AND`, `OR`, `NOT` +//! - Predicates: `IS [NOT] NULL`, `[NOT] LIKE` / `ILIKE` (with `ESCAPE`), +//! `[NOT] IN (...)`, `[NOT] BETWEEN ... AND ...` //! - Parenthesized sub-expressions: `(a + b) * c` //! - Function calls: `ROUND(price * 1.08, 2)`, `CONCAT(a, ' ', b)` //! - COALESCE: `COALESCE(a, b, '')` @@ -19,7 +21,9 @@ //! Determinism validation: rejects `NOW()`, `RANDOM()`, `NEXTVAL()`, `UUID()`. use super::error::ExprParseError; +use super::predicate::parse_postfix_predicate; use super::tokenizer::{Token, TokenKind, tokenize}; +use super::validate::{collect_columns, validate_deterministic}; use crate::expr::{BinaryOp, SqlExpr}; use nodedb_types::Value; @@ -86,10 +90,10 @@ fn parse_and( pos: &mut usize, depth: &mut usize, ) -> Result { - let mut left = parse_comparison(tokens, pos, depth)?; + let mut left = parse_not(tokens, pos, depth)?; while peek_keyword(tokens, *pos, "AND") { *pos += 1; - let right = parse_comparison(tokens, pos, depth)?; + let right = parse_not(tokens, pos, depth)?; left = SqlExpr::BinaryOp { left: Box::new(left), op: BinaryOp::And, @@ -99,12 +103,27 @@ fn parse_and( Ok(left) } +/// `NOT` binds looser than a comparison: `NOT a = b` is `NOT (a = b)`. +fn parse_not( + tokens: &[Token], + pos: &mut usize, + depth: &mut usize, +) -> Result { + if peek_keyword(tokens, *pos, "NOT") { + *pos += 1; + let inner = parse_not(tokens, pos, depth)?; + return Ok(SqlExpr::Negate(Box::new(inner))); + } + parse_comparison(tokens, pos, depth) +} + fn parse_comparison( tokens: &[Token], pos: &mut usize, depth: &mut usize, ) -> Result { - let left = parse_additive(tokens, pos, depth)?; + let operand = parse_additive(tokens, pos, depth)?; + let left = parse_postfix_predicate(operand, tokens, pos, depth)?; if *pos < tokens.len() && tokens[*pos].kind == TokenKind::Op { let op = match tokens[*pos].text.as_str() { "=" => BinaryOp::Eq, @@ -126,7 +145,7 @@ fn parse_comparison( Ok(left) } -fn parse_additive( +pub(super) fn parse_additive( tokens: &[Token], pos: &mut usize, depth: &mut usize, @@ -315,7 +334,7 @@ fn parse_case( }) } -fn parse_arg_list( +pub(super) fn parse_arg_list( tokens: &[Token], pos: &mut usize, depth: &mut usize, @@ -340,13 +359,17 @@ fn parse_arg_list( // ── Helpers ─────────────────────────────────────────────────────────── -fn peek_keyword(tokens: &[Token], pos: usize, keyword: &str) -> bool { +pub(super) fn peek_keyword(tokens: &[Token], pos: usize, keyword: &str) -> bool { pos < tokens.len() && tokens[pos].kind == TokenKind::Ident && tokens[pos].text.eq_ignore_ascii_case(keyword) } -fn expect_keyword(tokens: &[Token], pos: &mut usize, keyword: &str) -> Result<(), ExprParseError> { +pub(super) fn expect_keyword( + tokens: &[Token], + pos: &mut usize, + keyword: &str, +) -> Result<(), ExprParseError> { if peek_keyword(tokens, *pos, keyword) { *pos += 1; Ok(()) @@ -381,122 +404,6 @@ fn expect_token( } } -// ── Validation ──────────────────────────────────────────────────────── - -const NON_DETERMINISTIC: &[&str] = &[ - "now", - "current_timestamp", - "random", - "nextval", - "uuid", - "uuid_v4", - "uuid_v7", - "gen_random_uuid", - "ulid", - "cuid2", - "nanoid", -]; - -fn validate_deterministic(expr: &SqlExpr) -> Result<(), ExprParseError> { - match expr { - SqlExpr::Function { name, args } => { - if NON_DETERMINISTIC.contains(&name.as_str()) { - return Err(ExprParseError::InvalidLiteral { - detail: format!( - "non-deterministic function '{name}()' not allowed in GENERATED ALWAYS AS" - ), - }); - } - for arg in args { - validate_deterministic(arg)?; - } - Ok(()) - } - SqlExpr::BinaryOp { left, right, .. } => { - validate_deterministic(left)?; - validate_deterministic(right) - } - SqlExpr::Negate(inner) => validate_deterministic(inner), - SqlExpr::Coalesce(args) => { - for arg in args { - validate_deterministic(arg)?; - } - Ok(()) - } - SqlExpr::Case { - operand, - when_thens, - else_expr, - } => { - if let Some(op) = operand { - validate_deterministic(op)?; - } - for (cond, then) in when_thens { - validate_deterministic(cond)?; - validate_deterministic(then)?; - } - if let Some(e) = else_expr { - validate_deterministic(e)?; - } - Ok(()) - } - SqlExpr::Cast { expr, .. } => validate_deterministic(expr), - SqlExpr::NullIf(a, b) => { - validate_deterministic(a)?; - validate_deterministic(b) - } - SqlExpr::IsNull { expr, .. } => validate_deterministic(expr), - SqlExpr::Column(_) - | SqlExpr::Literal(_) - | SqlExpr::OldColumn(_) - | SqlExpr::ExcludedColumn(_) => Ok(()), - } -} - -pub(crate) fn collect_columns(expr: &SqlExpr, deps: &mut Vec) { - match expr { - SqlExpr::Column(name) => deps.push(name.clone()), - SqlExpr::BinaryOp { left, right, .. } => { - collect_columns(left, deps); - collect_columns(right, deps); - } - SqlExpr::Negate(inner) => collect_columns(inner, deps), - SqlExpr::Function { args, .. } => { - for arg in args { - collect_columns(arg, deps); - } - } - SqlExpr::Coalesce(args) => { - for arg in args { - collect_columns(arg, deps); - } - } - SqlExpr::Case { - operand, - when_thens, - else_expr, - } => { - if let Some(op) = operand { - collect_columns(op, deps); - } - for (cond, then) in when_thens { - collect_columns(cond, deps); - collect_columns(then, deps); - } - if let Some(e) = else_expr { - collect_columns(e, deps); - } - } - SqlExpr::Cast { expr, .. } => collect_columns(expr, deps), - SqlExpr::NullIf(a, b) => { - collect_columns(a, deps); - collect_columns(b, deps); - } - SqlExpr::IsNull { expr, .. } => collect_columns(expr, deps), - SqlExpr::Literal(_) | SqlExpr::OldColumn(_) | SqlExpr::ExcludedColumn(_) => {} - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/nodedb-query/src/expr_parse/predicate.rs b/nodedb-query/src/expr_parse/predicate.rs new file mode 100644 index 000000000..562cf8f89 --- /dev/null +++ b/nodedb-query/src/expr_parse/predicate.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Postfix predicates on an operand: `IS [NOT] NULL`, `[NOT] LIKE`, +//! `[NOT] ILIKE`, `[NOT] IN (...)` and `[NOT] BETWEEN ... AND ...`. +//! +//! Each lowers onto a form the evaluator already has, the same way the SQL +//! planner lowers it. LIKE and ILIKE become the `like` and `ilike` functions. +//! IN becomes an OR of equalities. BETWEEN becomes two comparisons. + +use super::error::ExprParseError; +use super::parser::{expect_keyword, parse_additive, parse_arg_list, peek_keyword}; +use super::tokenizer::Token; +use crate::expr::{BinaryOp, SqlExpr}; +use nodedb_types::Value; + +/// Parse the predicate that follows `operand`, if one does. Returns +/// `operand` unchanged when the next token starts no predicate. +pub(super) fn parse_postfix_predicate( + operand: SqlExpr, + tokens: &[Token], + pos: &mut usize, + depth: &mut usize, +) -> Result { + if peek_keyword(tokens, *pos, "IS") { + *pos += 1; + let negated = peek_keyword(tokens, *pos, "NOT"); + if negated { + *pos += 1; + } + expect_keyword(tokens, pos, "NULL")?; + return Ok(SqlExpr::IsNull { + expr: Box::new(operand), + negated, + }); + } + + // `NOT` after an operand only ever opens a negated predicate. + let negated = peek_keyword(tokens, *pos, "NOT") + && ["LIKE", "ILIKE", "IN", "BETWEEN"] + .iter() + .any(|keyword| peek_keyword(tokens, *pos + 1, keyword)); + let start = if negated { *pos + 1 } else { *pos }; + + let predicate = if peek_keyword(tokens, start, "LIKE") || peek_keyword(tokens, start, "ILIKE") { + let name = if peek_keyword(tokens, start, "ILIKE") { + "ilike" + } else { + "like" + }; + *pos = start + 1; + let pattern = parse_additive(tokens, pos, depth)?; + let mut args = vec![operand, pattern]; + if peek_keyword(tokens, *pos, "ESCAPE") { + *pos += 1; + args.push(parse_additive(tokens, pos, depth)?); + } + SqlExpr::Function { + name: name.to_string(), + args, + } + } else if peek_keyword(tokens, start, "IN") { + *pos = start + 1; + let list = parse_arg_list(tokens, pos, depth)?; + // `IN` is an OR of equalities, so a negated list is built from + // inequalities joined by AND rather than wrapped below. + return Ok(in_list(operand, list, negated)); + } else if peek_keyword(tokens, start, "BETWEEN") { + *pos = start + 1; + let low = parse_additive(tokens, pos, depth)?; + expect_keyword(tokens, pos, "AND")?; + let high = parse_additive(tokens, pos, depth)?; + // `NOT BETWEEN` is the outside of the range, built directly. + return Ok(between(operand, low, high, negated)); + } else { + return Ok(operand); + }; + + Ok(if negated { + SqlExpr::Negate(Box::new(predicate)) + } else { + predicate + }) +} + +fn binary(left: SqlExpr, op: BinaryOp, right: SqlExpr) -> SqlExpr { + SqlExpr::BinaryOp { + left: Box::new(left), + op, + right: Box::new(right), + } +} + +/// `target IN (list)` as `target = a OR target = b ...`. `NOT IN` is +/// `target <> a AND target <> b ...`. An empty list is false, or true when +/// negated. +fn in_list(target: SqlExpr, list: Vec, negated: bool) -> SqlExpr { + let (compare, combine) = if negated { + (BinaryOp::NotEq, BinaryOp::And) + } else { + (BinaryOp::Eq, BinaryOp::Or) + }; + list.into_iter() + .map(|item| binary(target.clone(), compare, item)) + .reduce(|acc, next| binary(acc, combine, next)) + .unwrap_or(SqlExpr::Literal(Value::Bool(negated))) +} + +/// `target BETWEEN low AND high` as `target >= low AND target <= high`. +/// `NOT BETWEEN` is `target < low OR target > high`. +fn between(target: SqlExpr, low: SqlExpr, high: SqlExpr, negated: bool) -> SqlExpr { + if negated { + binary( + binary(target.clone(), BinaryOp::Lt, low), + BinaryOp::Or, + binary(target, BinaryOp::Gt, high), + ) + } else { + binary( + binary(target.clone(), BinaryOp::GtEq, low), + BinaryOp::And, + binary(target, BinaryOp::LtEq, high), + ) + } +} + +#[cfg(test)] +mod tests { + use nodedb_types::Value; + + use super::super::parser::parse_generated_expr; + + fn eval(text: &str, doc: serde_json::Value) -> Value { + let (expr, _) = parse_generated_expr(text).expect("parses"); + expr.eval(&Value::from(doc)).expect("evaluates") + } + + #[test] + fn like_and_not_like() { + let doc = serde_json::json!({"email": "a@b.c"}); + assert_eq!(eval("email LIKE '%@%'", doc.clone()), Value::Bool(true)); + assert_eq!(eval("email NOT LIKE '%@%'", doc), Value::Bool(false)); + let plain = serde_json::json!({"email": "nobody"}); + assert_eq!(eval("email LIKE '%@%'", plain), Value::Bool(false)); + } + + #[test] + fn ilike_ignores_case() { + let doc = serde_json::json!({"name": "Alice"}); + assert_eq!(eval("name ILIKE 'ali%'", doc.clone()), Value::Bool(true)); + assert_eq!(eval("name LIKE 'ali%'", doc), Value::Bool(false)); + } + + #[test] + fn like_with_escape() { + let doc = serde_json::json!({"code": "50%"}); + assert_eq!(eval("code LIKE '50!%' ESCAPE '!'", doc), Value::Bool(true)); + let other = serde_json::json!({"code": "500"}); + assert_eq!( + eval("code LIKE '50!%' ESCAPE '!'", other), + Value::Bool(false) + ); + } + + #[test] + fn like_on_null_is_null() { + let doc = serde_json::json!({"email": null}); + assert_eq!(eval("email LIKE '%@%'", doc), Value::Null); + } + + #[test] + fn in_and_not_in() { + let doc = serde_json::json!({"role": "admin"}); + assert_eq!( + eval("role IN ('admin', 'user')", doc.clone()), + Value::Bool(true) + ); + assert_eq!( + eval("role NOT IN ('admin', 'user')", doc.clone()), + Value::Bool(false) + ); + assert_eq!(eval("role IN ('guest')", doc), Value::Bool(false)); + } + + #[test] + fn between_and_not_between() { + let doc = serde_json::json!({"age": 30}); + assert_eq!( + eval("age BETWEEN 18 AND 65", doc.clone()), + Value::Bool(true) + ); + assert_eq!( + eval("age NOT BETWEEN 18 AND 65", doc.clone()), + Value::Bool(false) + ); + assert_eq!(eval("age BETWEEN 40 AND 65", doc), Value::Bool(false)); + } + + #[test] + fn between_binds_inside_and() { + let doc = serde_json::json!({"age": 30, "active": true}); + assert_eq!( + eval("age BETWEEN 18 AND 65 AND active = true", doc), + Value::Bool(true) + ); + } + + #[test] + fn is_null_and_is_not_null() { + let doc = serde_json::json!({"a": null, "b": 1}); + assert_eq!(eval("a IS NULL", doc.clone()), Value::Bool(true)); + assert_eq!(eval("b IS NULL", doc.clone()), Value::Bool(false)); + assert_eq!(eval("b IS NOT NULL", doc), Value::Bool(true)); + } + + #[test] + fn not_applies_to_the_whole_comparison() { + let doc = serde_json::json!({"x": 1}); + assert_eq!(eval("NOT x = 2", doc.clone()), Value::Bool(true)); + assert_eq!(eval("NOT x = 1 OR x = 1", doc), Value::Bool(true)); + } + + #[test] + fn columns_inside_predicates_are_dependencies() { + let (_, deps) = + parse_generated_expr("a LIKE b AND c IN (d, 1) AND e BETWEEN f AND g").expect("parses"); + assert_eq!(deps, vec!["a", "b", "c", "d", "e", "f", "g"]); + } +} diff --git a/nodedb-query/src/expr_parse/validate.rs b/nodedb-query/src/expr_parse/validate.rs new file mode 100644 index 000000000..8f022597c --- /dev/null +++ b/nodedb-query/src/expr_parse/validate.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Checks on a parsed expression: determinism, and the columns it reads. + +use super::error::ExprParseError; +use crate::expr::SqlExpr; + +const NON_DETERMINISTIC: &[&str] = &[ + "now", + "current_timestamp", + "random", + "nextval", + "uuid", + "uuid_v4", + "uuid_v7", + "gen_random_uuid", + "ulid", + "cuid2", + "nanoid", +]; + +pub(super) fn validate_deterministic(expr: &SqlExpr) -> Result<(), ExprParseError> { + match expr { + SqlExpr::Function { name, args } => { + if NON_DETERMINISTIC.contains(&name.as_str()) { + return Err(ExprParseError::InvalidLiteral { + detail: format!( + "non-deterministic function '{name}()' not allowed in GENERATED ALWAYS AS" + ), + }); + } + for arg in args { + validate_deterministic(arg)?; + } + Ok(()) + } + SqlExpr::BinaryOp { left, right, .. } => { + validate_deterministic(left)?; + validate_deterministic(right) + } + SqlExpr::Negate(inner) => validate_deterministic(inner), + SqlExpr::Coalesce(args) => { + for arg in args { + validate_deterministic(arg)?; + } + Ok(()) + } + SqlExpr::Case { + operand, + when_thens, + else_expr, + } => { + if let Some(op) = operand { + validate_deterministic(op)?; + } + for (cond, then) in when_thens { + validate_deterministic(cond)?; + validate_deterministic(then)?; + } + if let Some(e) = else_expr { + validate_deterministic(e)?; + } + Ok(()) + } + SqlExpr::Cast { expr, .. } => validate_deterministic(expr), + SqlExpr::NullIf(a, b) => { + validate_deterministic(a)?; + validate_deterministic(b) + } + SqlExpr::IsNull { expr, .. } => validate_deterministic(expr), + SqlExpr::Column(_) + | SqlExpr::Literal(_) + | SqlExpr::OldColumn(_) + | SqlExpr::ExcludedColumn(_) => Ok(()), + } +} + +pub(super) fn collect_columns(expr: &SqlExpr, deps: &mut Vec) { + match expr { + SqlExpr::Column(name) => deps.push(name.clone()), + SqlExpr::BinaryOp { left, right, .. } => { + collect_columns(left, deps); + collect_columns(right, deps); + } + SqlExpr::Negate(inner) => collect_columns(inner, deps), + SqlExpr::Function { args, .. } => { + for arg in args { + collect_columns(arg, deps); + } + } + SqlExpr::Coalesce(args) => { + for arg in args { + collect_columns(arg, deps); + } + } + SqlExpr::Case { + operand, + when_thens, + else_expr, + } => { + if let Some(op) = operand { + collect_columns(op, deps); + } + for (cond, then) in when_thens { + collect_columns(cond, deps); + collect_columns(then, deps); + } + if let Some(e) = else_expr { + collect_columns(e, deps); + } + } + SqlExpr::Cast { expr, .. } => collect_columns(expr, deps), + SqlExpr::NullIf(a, b) => { + collect_columns(a, deps); + collect_columns(b, deps); + } + SqlExpr::IsNull { expr, .. } => collect_columns(expr, deps), + SqlExpr::Literal(_) | SqlExpr::OldColumn(_) | SqlExpr::ExcludedColumn(_) => {} + } +} diff --git a/nodedb-query/src/msgpack_scan/filter.rs b/nodedb-query/src/msgpack_scan/filter.rs index 9ec23d83a..27d3c0380 100644 --- a/nodedb-query/src/msgpack_scan/filter.rs +++ b/nodedb-query/src/msgpack_scan/filter.rs @@ -167,7 +167,13 @@ impl ScanFilter { | FilterOp::NeColumn => {} } - let (start, end) = match idx.get(&self.field) { + // Same lookup order as `matches_binary`: the exact key, then a + // qualified key carrying the bare name. Absent, and so NULL, only + // when neither exists. + let (start, end) = match idx + .get(&self.field) + .or_else(|| find_field_by_suffix(doc, &format!(".{}", self.field))) + { Some(r) => r, None => return Ok(self.op == FilterOp::IsNull), }; diff --git a/nodedb-query/src/scan_filter/types.rs b/nodedb-query/src/scan_filter/types.rs index 60432e81a..1d94abbf7 100644 --- a/nodedb-query/src/scan_filter/types.rs +++ b/nodedb-query/src/scan_filter/types.rs @@ -148,7 +148,13 @@ impl ScanFilter { | FilterOp::NeColumn => {} } - let field_val = match doc.get(&self.field) { + // A bare name also reaches a qualified field (`amount` reads + // `orders.amount`), as the binary matcher's suffix lookup does. The + // field reads as absent, and so NULL, only when neither exists. + let field_val = match doc + .get(&self.field) + .or_else(|| field_by_suffix(doc, &self.field)) + { Some(v) => v, None => return Ok(self.op == FilterOp::IsNull), }; @@ -258,7 +264,10 @@ impl ScanFilter { nodedb_types::Value::String(s) => s.as_str(), _ => return Ok(false), }; - let other_val = match doc.get(other_col) { + let other_val = match doc + .get(other_col) + .or_else(|| field_by_suffix(doc, other_col)) + { Some(v) => v, None => return Ok(false), }; @@ -315,6 +324,29 @@ pub(crate) fn column_compare( } } +/// The field a bare name reaches in a row keyed `qualifier.column`: the +/// value under a key ending in `.name`. When several qualifiers carry the +/// name, the smallest key wins, so the choice does not depend on map order. +/// `None` for a qualified name, and when no key carries it. +fn field_by_suffix<'a>( + doc: &'a nodedb_types::Value, + name: &str, +) -> Option<&'a nodedb_types::Value> { + if name.is_empty() || name.contains('.') { + return None; + } + let nodedb_types::Value::Object(map) = doc else { + return None; + }; + map.iter() + .filter(|(key, _)| { + key.strip_suffix(name) + .is_some_and(|qualifier| qualifier.ends_with('.') && qualifier.len() > 1) + }) + .min_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, value)| value) +} + #[cfg(test)] mod tests { use super::*; diff --git a/nodedb-sql/src/aggregate_walk.rs b/nodedb-sql/src/aggregate_walk.rs index ba0cf198e..b517425c8 100644 --- a/nodedb-sql/src/aggregate_walk.rs +++ b/nodedb-sql/src/aggregate_walk.rs @@ -33,7 +33,7 @@ use crate::parser::normalize::normalize_ident; use crate::resolver::ColumnScope; use crate::resolver::columns::TableScope; use crate::resolver::expr::convert_expr; -use crate::types::{AggregateExpr, SqlExpr}; +use crate::types::{AggregateExpr, SqlExpr, SqlValue}; /// Return `true` if any aggregate function call appears anywhere inside /// `expr` — at any nesting depth, inside any expression position. @@ -147,12 +147,36 @@ impl Visitor for AggregateExtractor<'_> { return ControlFlow::Break(()); } }; + let filter = match aggregate_filter(f, self.scope) { + Ok(filter) => filter, + Err(e) => { + self.error = Some(e); + return ControlFlow::Break(()); + } + }; + let mut arg_text = computed_arg_text(f, &args); + let (args, filter_text) = match filter { + Some((predicate, text)) => { + // The folded argument is a CASE; the key keeps naming + // the argument as written. + if arg_text.is_none() { + arg_text = Some(match args.first() { + Some(SqlExpr::Column { name, .. }) => name.clone(), + _ => "*".to_string(), + }); + } + (filtered_args(args, predicate), Some(text)) + } + None => (args, None), + }; self.out.push(AggregateExpr { function: function_name(f), args, alias: self.alias.into(), distinct, grouping_col_index: None, + filter_text, + arg_text, }); self.inside_aggregate += 1; } @@ -239,6 +263,67 @@ fn function_args_and_distinct( Ok((parsed, distinct)) } +/// Convert an aggregate call's `FILTER (WHERE ...)` clause against `scope`: +/// the predicate and its lowercase source text. +/// +/// An aggregate inside the predicate is rejected by the extractor's nested +/// aggregate check, because the visitor descends into the clause while the +/// enclosing aggregate is open. +fn aggregate_filter(f: &ast::Function, scope: &TableScope) -> Result> { + let Some(predicate) = f.filter.as_deref() else { + return Ok(None); + }; + Ok(Some(( + convert_expr(predicate, &ColumnScope::Relations(scope))?, + format!("{predicate}").to_lowercase(), + ))) +} + +/// Fold a `FILTER` predicate into the aggregate's arguments. +/// +/// Each argument becomes `CASE WHEN THEN END`, NULL for +/// a row the predicate does not admit. Every aggregate skips a NULL input, +/// so the result equals PostgreSQL's FILTER semantics. `COUNT(*)` counts +/// rows, so its wildcard becomes the constant 1. +fn filtered_args(args: Vec, predicate: SqlExpr) -> Vec { + let args = if args.is_empty() { + vec![SqlExpr::Wildcard] + } else { + args + }; + args.into_iter() + .map(|arg| { + let value = match arg { + SqlExpr::Wildcard => SqlExpr::Literal(SqlValue::Int(1)), + other => other, + }; + SqlExpr::Case { + operand: None, + when_then: vec![(predicate.clone(), value)], + else_expr: None, + } + }) + .collect() +} + +/// Lowercase source text of the first argument when it is computed. A +/// column or wildcard argument is named by the column itself. +fn computed_arg_text(f: &ast::Function, args: &[SqlExpr]) -> Option { + match args.first() { + None | Some(SqlExpr::Column { .. }) | Some(SqlExpr::Wildcard) => return None, + Some(_) => {} + } + let ast::FunctionArguments::List(list) = &f.args else { + return None; + }; + list.args.iter().find_map(|a| match a { + ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e)) => { + Some(format!("{e}").to_lowercase()) + } + _ => None, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/nodedb-sql/src/catalog.rs b/nodedb-sql/src/catalog.rs index 4638193ea..f5de86a1a 100644 --- a/nodedb-sql/src/catalog.rs +++ b/nodedb-sql/src/catalog.rs @@ -112,6 +112,11 @@ pub enum SqlCatalogError { /// collection is hard-deleted by the GC sweeper. retention_expires_at_ns: u64, }, + + /// The relation's catalog record exists but could not be read: a + /// stored definition that does not decode, or a catalog read error. + #[error("relation '{relation}' could not be read: {detail}")] + Unreadable { relation: String, detail: String }, } /// A relation that resolves to planner metadata without a stored collection. diff --git a/nodedb-sql/src/ddl_ast/collection_type/build.rs b/nodedb-sql/src/ddl_ast/collection_type/build.rs index 1bab39ff2..c70820d23 100644 --- a/nodedb-sql/src/ddl_ast/collection_type/build.rs +++ b/nodedb-sql/src/ddl_ast/collection_type/build.rs @@ -60,7 +60,14 @@ pub fn build_collection_type( } Some("timeseries") => { reject_reserved_columns(columns)?; + // The collection type's interval is the one source a flush reads. + // Undeclared, it is one hour. A declared one must parse. let partition_by = opt_val("partition_by").unwrap_or_else(|| "1h".to_string()); + nodedb_types::timeseries::PartitionInterval::parse(&partition_by).map_err(|e| { + SqlError::DataException { + detail: format!("invalid partition_by '{partition_by}': {e}"), + } + })?; let time_key = resolve_time_key(columns)?; Ok(( nodedb_types::CollectionType::timeseries(time_key, partition_by), diff --git a/nodedb-sql/src/ddl_ast/collection_type/kv.rs b/nodedb-sql/src/ddl_ast/collection_type/kv.rs index 7fb59a8a1..284926847 100644 --- a/nodedb-sql/src/ddl_ast/collection_type/kv.rs +++ b/nodedb-sql/src/ddl_ast/collection_type/kv.rs @@ -67,10 +67,11 @@ pub(crate) fn build_kv_collection_type( } // Validate: PK type must be hashable. - let pk = col_defs - .iter() - .find(|c| c.primary_key) - .expect("invariant: pk_count validated to be exactly 1 in the checks above"); + let Some(pk) = col_defs.iter().find(|c| c.primary_key) else { + return Err(SqlError::Parse { + detail: "KV collections require a PRIMARY KEY column (the hash key)".to_string(), + }); + }; if !nodedb_types::is_valid_kv_key_type(&pk.column_type) { return Err(SqlError::Parse { detail: format!( @@ -86,7 +87,7 @@ pub(crate) fn build_kv_collection_type( })?; let ttl = build_kv_ttl(options, &schema)?; - let capacity_hint = build_kv_capacity(options); + let capacity_hint = build_kv_capacity(options)?; let config = nodedb_types::KvConfig { schema, @@ -150,11 +151,62 @@ fn build_kv_ttl( })) } -/// Parse optional `capacity` from the options list. -fn build_kv_capacity(options: &[(String, String)]) -> u32 { - options +/// Parse optional `capacity` from the options list. Absent is `0`, which +/// sizes the table by the engine default. A value that is not a positive +/// integer within `u32` is refused by value, never replaced by the default. +fn build_kv_capacity(options: &[(String, String)]) -> Result { + let Some((_, raw)) = options .iter() .find(|(k, _)| k.eq_ignore_ascii_case("capacity")) - .and_then(|(_, v)| v.trim().parse::().ok()) - .unwrap_or(0) + else { + return Ok(0); + }; + let text = raw.trim().trim_matches('\''); + match text.parse::() { + Ok(capacity) if capacity > 0 => Ok(capacity), + _ => Err(SqlError::Parse { + detail: format!( + "KV capacity '{raw}' is not valid; capacity must be a positive integer up to {}", + u32::MAX + ), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(value: &str) -> Vec<(String, String)> { + vec![("capacity".to_string(), value.to_string())] + } + + #[test] + fn an_absent_capacity_is_the_default() { + assert_eq!(build_kv_capacity(&[]).expect("absent capacity"), 0); + } + + #[test] + fn a_positive_capacity_is_kept() { + assert_eq!( + build_kv_capacity(&options("10000")).expect("capacity"), + 10_000 + ); + assert_eq!( + build_kv_capacity(&options("'64'")).expect("quoted capacity"), + 64 + ); + } + + #[test] + fn a_bad_capacity_is_refused_by_value() { + for bad in ["lots", "-5", "0", "1.5", "99999999999"] { + match build_kv_capacity(&options(bad)) { + Err(SqlError::Parse { detail }) => { + assert!(detail.contains(&format!("'{bad}'")), "{detail}"); + } + other => panic!("capacity '{bad}' must be refused, got {other:?}"), + } + } + } } diff --git a/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs b/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs new file mode 100644 index 000000000..40ad8c061 --- /dev/null +++ b/nodedb-sql/src/ddl_ast/graph_parse/cursor.rs @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Left-to-right clause reader for the graph DSL. +//! +//! A statement parser reads its clauses in source order. A clause keyword is +//! matched only at a clause position, and its value is read from the tokens +//! right after it. A token at a clause position that names no clause of the +//! statement is a parse error, so a misspelled keyword never falls back to +//! the clause default. A clause given twice is a parse error. + +use super::super::graph_types::{GraphDirection, GraphProperties}; +use super::tokenizer::Tok; +use crate::error::SqlError; + +pub(super) struct Cursor<'t, 'a> { + toks: &'t [Tok<'a>], + pos: usize, + statement: &'static str, +} + +impl<'t, 'a> Cursor<'t, 'a> { + /// A cursor over `toks` after its `command_words` (2 for `GRAPH TRAVERSE`). + pub(super) fn new(toks: &'t [Tok<'a>], statement: &'static str, command_words: usize) -> Self { + Self { + toks, + pos: command_words.min(toks.len()), + statement, + } + } + + /// The statement name error messages use, such as `GRAPH TRAVERSE`. + pub(super) fn statement(&self) -> &'static str { + self.statement + } + + fn peek(&self) -> Option<&'t Tok<'a>> { + let toks: &'t [Tok<'a>] = self.toks; + toks.get(self.pos) + } + + /// The next clause keyword, upper-cased. `None` at the end of the + /// statement. A string, identifier, object or list at a clause position + /// is refused. + pub(super) fn next_clause(&mut self) -> Result, SqlError> { + match self.peek() { + None => Ok(None), + Some(Tok::Word(word)) => { + self.pos += 1; + Ok(Some(word.to_ascii_uppercase())) + } + Some(other) => Err(self.unexpected(other)), + } + } + + /// The error for the keyword `next_clause` returned last, when the + /// statement has no such clause. + pub(super) fn unknown_clause(&self) -> SqlError { + match self + .pos + .checked_sub(1) + .and_then(|index| self.toks.get(index)) + { + Some(tok) => self.unexpected(tok), + None => parse_error(format!("unexpected end of {}", self.statement)), + } + } + + /// Consume the next token when it is the bare word `keyword`. + pub(super) fn eat_keyword(&mut self, keyword: &str) -> bool { + let hit = + matches!(self.peek(), Some(Tok::Word(word)) if word.eq_ignore_ascii_case(keyword)); + if hit { + self.pos += 1; + } + hit + } + + /// A node, collection or label: a quoted string, a quoted identifier, or + /// a bare word. + pub(super) fn value(&mut self, clause: &str) -> Result { + let value = match self.peek() { + Some(Tok::Quoted(text) | Tok::Ident(text)) => text.clone().into_owned(), + Some(Tok::Word(word)) => (*word).to_string(), + other => { + return Err(parse_error(format!( + "{clause} needs a value — found {}", + describe_opt(other) + ))); + } + }; + self.pos += 1; + Ok(value) + } + + /// A bare word. + pub(super) fn word(&mut self, clause: &str) -> Result<&'a str, SqlError> { + match self.peek() { + Some(Tok::Word(word)) => { + self.pos += 1; + Ok(*word) + } + other => Err(parse_error(format!( + "{clause} needs a word — found {}", + describe_opt(other) + ))), + } + } + + pub(super) fn usize(&mut self, clause: &str) -> Result { + let text = self.number_text(clause, "a non-negative integer")?; + text.parse().map_err(|_| { + parse_error(format!( + "{clause} must be a non-negative integer — found '{text}'" + )) + }) + } + + pub(super) fn f64(&mut self, clause: &str) -> Result { + let text = self.number_text(clause, "a number")?; + text.parse() + .map_err(|_| parse_error(format!("{clause} must be a number — found '{text}'"))) + } + + /// Consume the next word when it parses as a number. + pub(super) fn number_word(&mut self) -> Option { + let Some(Tok::Word(word)) = self.peek() else { + return None; + }; + let number = word.parse().ok()?; + self.pos += 1; + Some(number) + } + + fn number_text(&mut self, clause: &str, expected: &str) -> Result<&'a str, SqlError> { + match self.peek() { + Some(Tok::Word(word)) => { + self.pos += 1; + Ok(*word) + } + other => Err(parse_error(format!( + "{clause} must be {expected} — found {}", + describe_opt(other) + ))), + } + } + + /// One or more quoted strings: `'a', 'b'` or `('a', 'b')`. + pub(super) fn quoted_list(&mut self, clause: &str) -> Result, SqlError> { + let mut out = Vec::new(); + while let Some(Tok::Quoted(text)) = self.peek() { + out.push(text.clone().into_owned()); + self.pos += 1; + } + if out.is_empty() { + return Err(parse_error(format!( + "{} {clause} needs one or more quoted labels, as {clause} 'a', 'b'", + self.statement + ))); + } + Ok(out) + } + + /// A `{...}` object literal, braces included. + pub(super) fn object(&mut self, clause: &str) -> Result { + match self.peek() { + Some(Tok::Object(text)) => { + self.pos += 1; + Ok((*text).to_string()) + } + other => Err(parse_error(format!( + "{clause} needs an object literal {{...}} — found {}", + describe_opt(other) + ))), + } + } + + /// The content of a `[...]` list. + pub(super) fn list(&mut self, clause: &str) -> Result<&'a str, SqlError> { + match self.peek() { + Some(Tok::List(text)) => { + self.pos += 1; + Ok(*text) + } + other => Err(parse_error(format!( + "{clause} needs ARRAY[...] — found {}", + describe_opt(other) + ))), + } + } + + /// A `PROPERTIES` value: an object literal or a quoted JSON document. + pub(super) fn properties(&mut self) -> Result { + let properties = match self.peek() { + Some(Tok::Object(text)) => GraphProperties::Object((*text).to_string()), + Some(Tok::Quoted(text)) => GraphProperties::Quoted(text.clone().into_owned()), + other => { + return Err(parse_error(format!( + "PROPERTIES needs an object literal {{...}} or a quoted JSON document — found {}", + describe_opt(other) + ))); + } + }; + self.pos += 1; + Ok(properties) + } + + /// A `DIRECTION` value. A word outside `in`, `out`, `both` is refused by + /// name. + pub(super) fn direction(&mut self) -> Result { + let word = match self.peek() { + Some(Tok::Word(word)) => { + self.pos += 1; + *word + } + other => { + return Err(parse_error(format!( + "DIRECTION must be one of in, out, both — found {}", + describe_opt(other) + ))); + } + }; + match word.to_ascii_uppercase().as_str() { + "IN" => Ok(GraphDirection::In), + "OUT" => Ok(GraphDirection::Out), + "BOTH" => Ok(GraphDirection::Both), + _ => Err(parse_error(format!( + "DIRECTION must be one of in, out, both — found '{word}'" + ))), + } + } + + fn unexpected(&self, tok: &Tok<'_>) -> SqlError { + parse_error(format!( + "unexpected {} in {}", + describe(tok), + self.statement + )) + } +} + +/// Store a clause value. A clause the statement already has is refused. +pub(super) fn set_once( + slot: &mut Option, + value: T, + clause: &str, + statement: &str, +) -> Result<(), SqlError> { + if slot.is_some() { + return Err(parse_error(format!( + "{clause} given more than once in {statement}" + ))); + } + *slot = Some(value); + Ok(()) +} + +/// A required clause was absent. +pub(super) fn missing_clause(statement: &str, clause: &str) -> SqlError { + parse_error(format!("{statement} requires {clause}")) +} + +fn describe(tok: &Tok<'_>) -> String { + match tok { + Tok::Word(word) => format!("token '{word}'"), + Tok::Quoted(text) => format!("string '{text}'"), + Tok::Ident(text) => format!("identifier \"{text}\""), + Tok::Object(_) => "an object literal".to_owned(), + Tok::List(_) => "a [...] list".to_owned(), + } +} + +fn describe_opt(tok: Option<&Tok<'_>>) -> String { + tok.map_or_else(|| "end of statement".to_owned(), describe) +} + +fn parse_error(detail: String) -> SqlError { + SqlError::Parse { detail } +} diff --git a/nodedb-sql/src/ddl_ast/graph_parse/entry.rs b/nodedb-sql/src/ddl_ast/graph_parse/entry.rs index f49018ae3..151946d4e 100644 --- a/nodedb-sql/src/ddl_ast/graph_parse/entry.rs +++ b/nodedb-sql/src/ddl_ast/graph_parse/entry.rs @@ -28,12 +28,17 @@ pub fn try_parse(sql: &str) -> Option> { return None; } - Some(parse_graph(trimmed, &upper)) + Some(parse_graph(trimmed)) } -/// Parse a statement known to start with `GRAPH `. A trailing `EDGE WHERE` -/// predicate splits off first, so the clause text before it tokenizes alone. -fn parse_graph(trimmed: &str, upper: &str) -> Result { +/// Parse a statement known to start with `GRAPH `. Comments are blanked +/// first. A trailing `EDGE WHERE` predicate splits off next, so the clause +/// text before it tokenizes alone. +fn parse_graph(sql: &str) -> Result { + let blanked = tokenizer::blank_comments(sql); + let trimmed = blanked.trim(); + let upper = trimmed.to_ascii_uppercase(); + let upper = upper.as_str(); let (head, predicate_text) = edge_predicate::split_edge_where(trimmed)?; let predicate = match predicate_text { Some(text) => edge_predicate::parse_edge_predicate(text)?, @@ -70,7 +75,7 @@ fn parse_graph(trimmed: &str, upper: &str) -> Result } else if upper.starts_with("GRAPH ALGO ") { variants::parse_algo(&toks) } else if upper.starts_with("GRAPH RAG FUSION ") { - variants::parse_rag_fusion(&toks, trimmed) + variants::parse_rag_fusion(&toks) } else { // Starts with `GRAPH ` but names no known command. Still graph DSL, // so report it here rather than letting the SQL parser guess. @@ -532,4 +537,142 @@ mod tests { "an omitted DIRECTION must keep defaulting" ); } + + // ── Unconsumed and misplaced tokens ─────────────────────────────────── + + fn parse_error(sql: &str) -> String { + try_parse(sql) + .expect("input is graph DSL") + .expect_err("statement must be refused") + .to_string() + } + + #[test] + fn misspelled_clause_keyword_is_named() { + let error = parse_error("GRAPH TRAVERSE FROM 1 DEPTS 3 IN g"); + assert!( + error.contains("unexpected token 'DEPTS' in GRAPH TRAVERSE"), + "{error}" + ); + let error = parse_error("GRAPH NEIGHBORS OF 1 LABELS 'follows' IN g"); + assert!( + error.contains("unexpected token 'LABELS' in GRAPH NEIGHBORS"), + "{error}" + ); + } + + #[test] + fn stray_tokens_are_refused() { + for (sql, token) in [ + ("GRAPH TRAVERSE FROM 1 SIDEWAYS IN g", "SIDEWAYS"), + ("GRAPH TRAVERSE FROM 1 BANANA PHONE IN g", "BANANA"), + ("GRAPH INSERT EDGE IN 'e' FROM 'a' TO 'b' TYP 'x'", "TYP"), + ( + "GRAPH DELETE EDGE IN 'e' FROM 'a' TO 'b' TYPE 'x' EXTRA", + "EXTRA", + ), + ("GRAPH PATH IN 'g' FROM 'a' TO 'b' MAXDEPTH 3", "MAXDEPTH"), + ("GRAPH LABEL 'a' AS 'P' EXTRA", "EXTRA"), + ("GRAPH ALGO PAGERANK ON g ITERATION 5", "ITERATION"), + ( + "GRAPH RAG FUSION ON e QUERY ARRAY[0.1] VECTOR_TOPK 5", + "VECTOR_TOPK", + ), + ("GRAPH TRAVERSE IN 'g' FROM 'a' ]", "]"), + ] { + let error = parse_error(sql); + assert!( + error.contains(&format!("unexpected token '{token}'")), + "{sql}: {error}" + ); + } + } + + #[test] + fn stray_string_at_clause_position_is_refused() { + let error = parse_error("GRAPH TRAVERSE IN 'g' FROM 'a' 'b'"); + assert!(error.contains("unexpected string 'b'"), "{error}"); + } + + #[test] + fn repeated_clause_is_refused() { + let error = parse_error("GRAPH TRAVERSE IN 'g' FROM 'a' DEPTH 1 DEPTH 3"); + assert!( + error.contains("DEPTH given more than once in GRAPH TRAVERSE"), + "{error}" + ); + } + + #[test] + fn direction_value_in_does_not_shadow_in_clause() { + match parsed("GRAPH TRAVERSE FROM 'b' DIRECTION in IN 'g'") { + NodedbStatement::Graph(GraphStmt::GraphTraverse { + collection, + start, + direction, + .. + }) => { + assert_eq!(collection, "g"); + assert_eq!(start, "b"); + assert_eq!(direction, GraphDirection::In); + } + other => panic!("expected GraphTraverse, got {other:?}"), + } + } + + #[test] + fn non_numeric_clause_values_are_refused() { + let error = parse_error("GRAPH ALGO PAGERANK ON g DAMPING abc"); + assert!(error.contains("DAMPING must be a number"), "{error}"); + let error = parse_error("GRAPH TRAVERSE IN 'g' FROM 'a' DEPTH '3'"); + assert!( + error.contains("DEPTH must be a non-negative integer"), + "{error}" + ); + let error = parse_error("GRAPH RAG FUSION ON e QUERY ARRAY[0.1, x]"); + assert!(error.contains("'x'"), "{error}"); + } + + #[test] + fn comments_are_not_tokens() { + match parsed("GRAPH TRAVERSE IN 'g' /* note */ FROM 'a' -- trailing note") { + NodedbStatement::Graph(GraphStmt::GraphTraverse { + collection, start, .. + }) => { + assert_eq!(collection, "g"); + assert_eq!(start, "a"); + } + other => panic!("expected GraphTraverse, got {other:?}"), + } + } + + #[test] + fn quoted_identifier_is_a_value() { + match parsed(r#"GRAPH NEIGHBORS IN "My Graph" OF 'a'"#) { + NodedbStatement::Graph(GraphStmt::GraphNeighbors { collection, .. }) => { + assert_eq!(collection, "My Graph"); + } + other => panic!("expected GraphNeighbors, got {other:?}"), + } + } + + #[test] + fn rag_fusion_bm25_field_is_the_on_after_bm25() { + let params_of = |sql: &str| match parsed(sql) { + NodedbStatement::Graph(GraphStmt::GraphRagFusion { collection, params }) => { + assert_eq!(collection, "entities"); + params + } + other => panic!("expected GraphRagFusion, got {other:?}"), + }; + let params = params_of( + "GRAPH RAG FUSION ON entities QUERY ARRAY[0.1] BM25 'q' ON 'body' \ + RRF_K (1.0, 2.0, 3.0)", + ); + assert_eq!(params.bm25_query.as_deref(), Some("q")); + assert_eq!(params.bm25_field.as_deref(), Some("body")); + assert_eq!(params.rrf_k_triple, Some((1.0, 2.0, 3.0))); + let params = params_of("GRAPH RAG FUSION ON entities QUERY ARRAY[0.1]"); + assert_eq!(params.bm25_field, None); + } } diff --git a/nodedb-sql/src/ddl_ast/graph_parse/fusion_params.rs b/nodedb-sql/src/ddl_ast/graph_parse/fusion_params.rs index f2f237675..1bbf55fea 100644 --- a/nodedb-sql/src/ddl_ast/graph_parse/fusion_params.rs +++ b/nodedb-sql/src/ddl_ast/graph_parse/fusion_params.rs @@ -2,23 +2,20 @@ //! Shared parameter extraction for graph-vector fusion SQL surfaces. //! -//! Two syntaxes reach the same `GraphOp::RagFusion` executor today: +//! Two syntaxes reach the same `GraphOp::RagFusion` executor: //! //! - `GRAPH RAG FUSION ON QUERY ARRAY[...] ...` (DSL form) //! - `SEARCH USING FUSION(ARRAY[...] ...)` (wrapped form) //! //! They use different keyword aliases for the same parameters //! (`EXPANSION_DEPTH` vs `DEPTH`, `EDGE_LABEL` vs `LABEL`, `FINAL_TOP_K` -//! vs `TOP`). Both must extract the same typed bag so future fusion -//! variants (hybrid text+vector, multi-vector, etc.) can share this -//! code and cannot silently drop parameters the way substring-find -//! parsing did. +//! vs `TOP`). Both read into the same typed bag through one left-to-right +//! clause reader, so an unknown or misspelled parameter is refused by name. use super::super::statement::GraphDirection; -use super::helpers::{ - array_floats_after, float_pair_after, float_triple_after, quoted_after, usize_after, word_after, -}; -use super::tokenizer::{Tok, tokenize}; +use super::cursor::{Cursor, set_once}; +use super::tokenizer::{Tok, blank_comments, tokenize}; +use crate::error::SqlError; /// Keyword aliases for the shared fusion parameters. /// @@ -108,65 +105,103 @@ pub struct FusionParams { } impl FusionParams { - pub(super) fn extract(toks: &[Tok<'_>], sql: &str, kw: &FusionKeywords) -> Self { - let direction = match word_after(toks, kw.direction) - .as_deref() - .map(str::to_ascii_uppercase) - .as_deref() - { - Some("IN") => Some(GraphDirection::In), - Some("BOTH") => Some(GraphDirection::Both), - Some("OUT") => Some(GraphDirection::Out), - _ => None, - }; - - // Try to parse a three-value RRF_K triple first; fall back to the - // two-value pair. This way `RRF_K (60.0, 35.0, 50.0)` populates - // `rrf_k_triple` and leaves `rrf_k` as None, while the legacy - // `RRF_K (60.0, 35.0)` continues to populate only `rrf_k`. - let rrf_k_triple = float_triple_after(toks, kw.rrf_k); - let rrf_k = if rrf_k_triple.is_some() { - None - } else { - float_pair_after(toks, kw.rrf_k) - }; - - // BM25 text leg — only parsed when the keyword is non-empty. - let (bm25_query, bm25_field) = if !kw.bm25_query.is_empty() { - ( - quoted_after(toks, kw.bm25_query), - quoted_after(toks, kw.bm25_field), - ) - } else { - (None, None) - }; - - Self { - query_vector: array_floats_after(sql, kw.query_anchor), - vector_top_k: usize_after(toks, kw.vector_top_k), - expansion_depth: usize_after(toks, kw.expansion_depth), - edge_label: quoted_after(toks, kw.edge_label), - final_top_k: usize_after(toks, kw.final_top_k), - rrf_k, - rrf_k_triple, - vector_field: quoted_after(toks, kw.vector_field), - direction, - max_visited: usize_after(toks, kw.max_visited), - bm25_query, - bm25_field, + /// Read the fusion clauses left to right until the end of the statement. + /// + /// A keyword outside `kw` is refused by name, a clause given twice is + /// refused, and a value that does not read as its type is refused. + pub(super) fn parse(c: &mut Cursor<'_, '_>, kw: &FusionKeywords) -> Result { + let stmt = c.statement(); + let mut p = Self::default(); + while let Some(clause) = c.next_clause()? { + let k = clause.as_str(); + if k == kw.query_anchor { + if kw.query_anchor != "ARRAY" && !c.eat_keyword("ARRAY") { + return Err(SqlError::Parse { + detail: format!("{k} needs ARRAY[...] in {stmt}"), + }); + } + let vector = parse_vector(c.list(k)?, k)?; + set_once(&mut p.query_vector, vector, k, stmt)?; + } else if k == kw.vector_top_k { + set_once(&mut p.vector_top_k, c.usize(k)?, k, stmt)?; + } else if k == kw.expansion_depth { + set_once(&mut p.expansion_depth, c.usize(k)?, k, stmt)?; + } else if k == kw.edge_label { + set_once(&mut p.edge_label, c.value(k)?, k, stmt)?; + } else if k == kw.final_top_k { + set_once(&mut p.final_top_k, c.usize(k)?, k, stmt)?; + } else if k == kw.rrf_k { + if p.rrf_k.is_some() || p.rrf_k_triple.is_some() { + return Err(SqlError::Parse { + detail: format!("{k} given more than once in {stmt}"), + }); + } + match (c.number_word(), c.number_word(), c.number_word()) { + (Some(vector_k), Some(graph_k), None) => p.rrf_k = Some((vector_k, graph_k)), + (Some(vector_k), Some(text_k), Some(graph_k)) => { + p.rrf_k_triple = Some((vector_k, text_k, graph_k)); + } + _ => { + return Err(SqlError::Parse { + detail: format!("{k} needs two or three numbers, as {k} (60.0, 35.0)"), + }); + } + } + } else if k == kw.vector_field { + set_once(&mut p.vector_field, c.value(k)?, k, stmt)?; + } else if k == kw.direction { + set_once(&mut p.direction, c.direction()?, k, stmt)?; + } else if k == kw.max_visited { + set_once(&mut p.max_visited, c.usize(k)?, k, stmt)?; + } else if !kw.bm25_query.is_empty() && k == kw.bm25_query { + set_once(&mut p.bm25_query, c.value(k)?, k, stmt)?; + // The field clause belongs to the BM25 clause only when it + // follows it directly. + if c.eat_keyword(kw.bm25_field) { + set_once( + &mut p.bm25_field, + c.value(kw.bm25_field)?, + kw.bm25_field, + stmt, + )?; + } + } else { + return Err(c.unknown_clause()); + } } + Ok(p) + } +} + +/// Read the comma-separated numbers of an `ARRAY[...]` literal. +fn parse_vector(inner: &str, clause: &str) -> Result, SqlError> { + if inner.trim().is_empty() { + return Err(SqlError::Parse { + detail: format!("{clause} ARRAY[...] needs one or more numbers"), + }); } + inner + .split(',') + .map(|part| { + let text = part.trim(); + text.parse::().map_err(|_| SqlError::Parse { + detail: format!("{clause} ARRAY element must be a number — found '{text}'"), + }) + }) + .collect() } /// Parse `SEARCH USING FUSION(...)` into its collection name -/// and a typed [`FusionParams`]. Returns `None` when the SQL does not -/// match the expected shape. +/// and a typed [`FusionParams`]. /// -/// Body extraction uses the same quote- and bracket-aware tokenizer as -/// the `GRAPH RAG FUSION` path, so a keyword-shaped string literal (e.g. -/// a label value `'TOP'`) cannot shadow a real parameter keyword. -pub fn parse_search_using_fusion(sql: &str) -> Option<(String, FusionParams)> { - let toks = tokenize(sql); +/// `None` means the SQL does not start with `SEARCH USING +/// FUSION`. `Some(Err)` means it does and a clause is malformed. The body +/// goes through the same quote- and bracket-aware tokenizer and the same +/// clause reader as `GRAPH RAG FUSION`, so a keyword-shaped string literal +/// (e.g. a label value `'TOP'`) cannot shadow a real parameter keyword. +pub fn parse_search_using_fusion(sql: &str) -> Option> { + let sql = blank_comments(sql); + let toks = tokenize(&sql); let collection = match toks.as_slice() { [Tok::Word(s), Tok::Word(c), Tok::Word(u), Tok::Word(f), ..] if s.eq_ignore_ascii_case("SEARCH") @@ -177,10 +212,11 @@ pub fn parse_search_using_fusion(sql: &str) -> Option<(String, FusionParams)> { } _ => return None, }; - Some(( - collection, - FusionParams::extract(&toks, sql, &SEARCH_FUSION_KEYWORDS), - )) + let mut cursor = Cursor::new(&toks, "SEARCH USING FUSION", 4); + Some( + FusionParams::parse(&mut cursor, &SEARCH_FUSION_KEYWORDS) + .map(|params| (collection, params)), + ) } #[cfg(test)] @@ -193,7 +229,8 @@ mod tests { "SEARCH mycol USING FUSION(ARRAY[0.1, 0.2] VECTOR_TOP_K 5 DEPTH 2 \ LABEL 'related' TOP 10 RRF_K (60.0, 35.0))", ) - .unwrap(); + .expect("SEARCH USING FUSION form") + .expect("well-formed fusion clauses"); assert_eq!(col, "mycol"); assert_eq!(p.query_vector.as_deref().map(<[f32]>::len), Some(2)); assert_eq!(p.vector_top_k, Some(5)); @@ -211,7 +248,8 @@ mod tests { VECTOR_TOP_K 50 BM25 'transformer attention' ON 'body' \ DEPTH 2 LABEL 'related_to' TOP 10 RRF_K (60.0, 35.0, 50.0))", ) - .unwrap(); + .expect("SEARCH USING FUSION form") + .expect("well-formed fusion clauses"); assert_eq!(col, "entities"); assert_eq!(p.rrf_k, None); assert_eq!(p.rrf_k_triple, Some((60.0, 35.0, 50.0))); @@ -229,7 +267,8 @@ mod tests { // quoted strings whole, so `TOP 10` is the real parameter. let (_, p) = parse_search_using_fusion("SEARCH c USING FUSION(ARRAY[0.5] LABEL 'TOP_SECRET' TOP 7)") - .unwrap(); + .expect("SEARCH USING FUSION form") + .expect("well-formed fusion clauses"); assert_eq!(p.edge_label.as_deref(), Some("TOP_SECRET")); assert_eq!(p.final_top_k, Some(7)); } @@ -239,4 +278,38 @@ mod tests { assert!(parse_search_using_fusion("INSERT INTO x VALUES (1)").is_none()); assert!(parse_search_using_fusion("SEARCH x USING VECTOR(ARRAY[1.0])").is_none()); } + + fn search_error(sql: &str) -> String { + parse_search_using_fusion(sql) + .expect("SEARCH USING FUSION form") + .expect_err("malformed fusion clauses must be refused") + .to_string() + } + + #[test] + fn search_fusion_refuses_misspelled_parameter() { + let error = search_error("SEARCH c USING FUSION(ARRAY[0.5] TOPP 7)"); + assert!( + error.contains("unexpected token 'TOPP' in SEARCH USING FUSION"), + "{error}" + ); + } + + #[test] + fn search_fusion_refuses_non_numeric_vector_element() { + let error = search_error("SEARCH c USING FUSION(ARRAY[0.1, x] TOP 3)"); + assert!(error.contains("'x'"), "{error}"); + } + + #[test] + fn search_fusion_refuses_unknown_direction() { + let error = search_error("SEARCH c USING FUSION(ARRAY[0.1] DIRECTION sideways)"); + assert!(error.contains("sideways"), "{error}"); + } + + #[test] + fn search_fusion_refuses_repeated_parameter() { + let error = search_error("SEARCH c USING FUSION(ARRAY[0.1] TOP 3 TOP 4)"); + assert!(error.contains("TOP given more than once"), "{error}"); + } } diff --git a/nodedb-sql/src/ddl_ast/graph_parse/helpers.rs b/nodedb-sql/src/ddl_ast/graph_parse/helpers.rs deleted file mode 100644 index 14afef8af..000000000 --- a/nodedb-sql/src/ddl_ast/graph_parse/helpers.rs +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -use super::{ - super::statement::{GraphDirection, GraphProperties}, - tokenizer::Tok, -}; -use crate::error::SqlError; - -pub(super) fn find_keyword(toks: &[Tok<'_>], keyword: &str) -> Option { - toks.iter() - .position(|t| matches!(t, Tok::Word(w) if w.eq_ignore_ascii_case(keyword))) -} - -pub(super) fn quoted_after(toks: &[Tok<'_>], keyword: &str) -> Option { - let pos = find_keyword(toks, keyword)?; - match toks.get(pos + 1)? { - Tok::Quoted(s) => Some(s.clone().into_owned()), - Tok::Word(w) => Some((*w).to_string()), - Tok::Object(_) => None, - } -} - -/// Collect the run of quoted tokens at the head of `toks`. -/// -/// The run ends at the first unquoted token. The tokenizer drops `,`, `(` -/// and `)`, so `'a', 'b'` and `('a', 'b')` give the same run. -fn quoted_run(toks: &[Tok<'_>]) -> Vec { - toks.iter() - .map_while(|t| match t { - Tok::Quoted(s) => Some(s.clone().into_owned()), - _ => None, - }) - .collect() -} - -pub(super) fn quoted_list_after(toks: &[Tok<'_>], keyword: &str) -> Vec { - find_keyword(toks, keyword) - .map(|pos| quoted_run(&toks[pos + 1..])) - .unwrap_or_default() -} - -/// Read an optional `LABEL '