discord9 e48e17774a feat(query): run a PromQL statement through the fused native node (#9868)
Wires the native PromQL route into the production planner so a real
PromQL statement reaches `FusedSeriesExec` and runs. Scope is a
standalone, single-region point of contact: `rate(x[5m])` and
`abs(rate(x[5m]))` execute through the node, and every shape the
compiler does not accept keeps the plan the existing planner builds.

Depends on the node `FusedSeries` / `FusedSeriesExec`, which landed in
`narthex` as #9820.

## The route

A statement is offered to `native_compile::compile` at its root inside
`PromPlanner`, before the expression is split into the nodes a range
function and the math functions over it would have shared. The route is
offered only when **both** hold: the engine-wide
`query.experimental_native_promql` option (retained in
`QueryEngineState`) and a per-request query-context extension of the
same name. Either alone keeps the existing plan, so a client cannot turn
the route on against the server's configuration.

A refused shape is the route declining the query, not an approximation:
planning continues exactly as it would with the flag off.

## Getting the node to the region

**Placement.** `check_extension_plan` answered `Unsupported` for the
node, which the analyzer treats as a boundary. It now answers
commutative when the input is exactly the table scan (the region builds
the node from the scan's own entrypoint and cannot look through a filter
or projection), the node binds a metric name, and no node of its tree
folds across series.

**Encodability.** `should_expand` asks whether a plan encodes to
Substrait before it asks whether a node commutes. That is right for a
region reached over the wire and wrong for standalone, whose region
server is in the same process. The engine now knows whether its regions
are reached in process, and the exception is scoped to a plan whose
**root** is `FusedSeries`; every other plan keeps the ordinary
whole-plan fast path and the per-node check. Both transports are covered
by the analyzer tests.

**Scan hint.** A region only prepares its series scan for a per-series
request. The existing `ScanHintRule` now attaches `PerSeries` to the
scan that is the immediate input of a fused node; the duplicate helper
that used to do it from the planner is deleted.

**Time bounds.** The native scan is the selector's own scan, so it gets
the same bounds the legacy selector path computes:
`build_time_index_filter` with the selector range, `offset` and `@`
anchor, pushed into `TableScan.filters` so the plan stays a bare
`TableScan`. Without this the scan read the table's whole retention.

## Cross-region correctness

A region evaluates the whole tree over the series its own scan returns,
so a series split across storage regions loses window contributions
instead of failing — `rate(cpu[2m])` on a time-partitioned table returns
nothing where the complete series answers `1.0`.
`single_partition_scan()` only covers scan partitions inside one region.

The route therefore declines unless **every partition column of the
table is one of the selector's own tag columns**: no partition columns
is a single region, and a tag-determined partition key cannot split one
series. Anything else, including internal columns, falls back to the
existing plan. No exchange and no region-count probing.

## Verification

Real standalone, `experimental_native_promql = true`, physical plan from
`datafusion::physical_planner=debug`:

```
FusedSeriesExec: rate(cpu[5m])
  NativeTree
    root Window rate [300000ms] ...
  SeriesScanExec ...
```

- `rate(cpu[5m])` and `abs(rate(cpu[5m]))` return the same series,
timestamps and values as the existing path on a dense series, a sparse
one and a two-point series.

**Multi-partition, with a flush in the middle.** `[query] parallelism =
4`, 8 series with a `shard` tag written in two rounds with `ADMIN
FLUSH_TABLE` between them, so each series' samples span a memtable and
an SST. The physical plan reports `SeriesScanExec: ... partitions=4` and
`FusedSeriesExec`, and for both `rate(cpu[5m])` and `abs(rate(cpu[5m]))`
all 40 emitted rows match the legacy plan position by position
(timestamps, values, `host`/`shard` labels). A series split across
partitions would have made `rate` lose the earlier sample, so the
row-for-row match is the check that each series stayed in one scan
partition.

**Scan parallelism is regression-tested in the workspace, not just at
the seam.** Moving the range-assignment rule into `table::table::scan`
initially broke two callers that no single-crate test covered
(`src/cmd/src/datanode/scanbench.rs` and `parallelize_scan`'s own test
module); `cargo check --workspace --all-targets` is what caught them,
and it now passes along with `query --lib` 897 passed.
- `/v1/prometheus/api/v1/query` and `/query_range` responses are
identical between the two routes.
- `/v1/promql` now emits the same column order as the existing path
(time index first).

Sqlness, standalone and distributed: `distinct`, `latest_per_series`,
`neumann` pass.

Focused unit tests, 13 passed / 0 failed (`native_fused_*` and the
request-opt-in parser after the admission fix; the analyzer and
scan-hint checks below are separate invocations): `native_fused_*`
(including the time-partitioned fallback and the tag-partitioned
still-native case), the request-opt-in parser, both analyzer
encodability checks under **both** transports, the ordinary-plan
fast-path assertion, and the three `ScanHintRule` cases. Clippy `-p
query --all-targets -D warnings` and fmt clean.

## Admission: what the route refuses

The region's raw-series preflight refuses an append-mode table and any
time index that is not millisecond, and the physical layer turns that
refusal into a planning error rather than a fallback. The logical route
therefore declines those tables itself, so such a query keeps the
existing plan instead of failing after the logical plan is already
fixed. Both cases have fallback tests asserting the native and flag-off
plans are identical.

## What the route refuses, and why that is a fallback rather than an
error

Every refusal below happens while the logical plan is still this
function's to choose, so the query keeps the plan the existing planner
builds. That matters because once a `FusedSeries` exists the logical
plan is fixed, and the physical layer can only report an error - it
cannot undo the choice.

- **A shape the compiler refuses**, a table it cannot bind, a remaining
label matcher, `__field__`, a subquery (the selector walk does not enter
one), or more than one selector.
- **A tree that reads the whole vector at a point** (`Aggregate`,
vector-vector `Binary`, and `Vector`: `sort`, `absent`, `topk`,
`histogram_quantile`, ...). One executor runs per scan partition, and a
partition holds only part of a metric's series.
- **A table whose own settings the region cannot frame**: a
non-millisecond time index, append mode, a partition column that is not
a tag, a field that is not numeric (a decimal is not: the region asks
`ConcreteDataType::is_numeric`, which excludes them), or an engine
without a raw-series scanner. Mito's series scanner is the one that
implements the raw path; a file-engine table is read by a
`SinglePartitionScanner`, which cannot frame a series. A metric-engine
logical table is refused too: it reads through a mito scanner, but the
planner only reaches this node with a bare scan when it could not
resolve the physical table, and that table's `TableInfo` carries no
`append_mode` - so the region's own eligibility could not be checked
here. That is a false negative that costs the query its native route,
not an error.
- **A plan that cannot reach a region**: regions reached over the wire
(a distributed frontend) and a PromQL statement embedded in a hybrid
CTE, where the node would sit under a SQL root and the in-process
encodability exception - which is scoped to a plan whose root is the
node - would not apply.

Two shapes fall back for a simpler reason, and are recorded as limits
rather than fixed here because supporting them would widen this POC to
scalar broadcast: a negation or a binary operation mentioning a selector
(`-m`, `rate(m[5m]) * 100`) is not found by the selector walk, and a
statement whose bounds precede the Unix epoch cannot be represented as a
`SystemTime`. Both answer correctly on the existing path.

- **`TQL EXPLAIN` / `TQL ANALYZE` still print the existing plan.** Those
statements wrap the expression in an explain extension before this route
sees it, and the selector walk does not enter a wrapper, so the route
declines. The query itself answers the same way; what differs is that
the displayed plan does not show the node the same statement executes
through `TQL EVAL`. Closing that needs the wrapper recognised and
re-applied around a native plan, which is deliberately not part of this
POC.

## Known limits, stated plainly

- **The value column is named `val`, not the expression-derived name**
the legacy projection carries
(`prom_rate(ts_range,val,ts,Int64(300000))`). Prometheus API output is
unaffected because it reads column roles, not names; the
greptimedb-shaped `/v1/promql` response shows the difference.
- **Scan parallelism is preserved.** The fused leaf needs a *prepared*
scan, because `ParallelizeScan` runs after physical planning and cannot
see a `SeriesScanExec`. Preparation now uses the same range-assignment
rule the parallelizer uses —
`table::table::scan::assign_partition_ranges`, moved there and shared by
both callers — with the session's `target_partitions`, and it leaves an
already-prepared scan untouched. The inherited single-partition
restriction in `resolve_named_series_binding` is gone, so a
multi-partition scan resolves its binding; the series producer still
keeps one series inside one scan partition, so each partition feeds
`FusedSeriesExec` with whole series.
- **Each series must stay inside one region.** The guard declines the
route unless every partition column is one of the selector's own tag
columns, so a table partitioned by time, or by anything that is not a
series label, falls back to the existing plan instead of answering
wrongly. A tag-partitioned table is accepted and does use the native
route, because a tag-determined partition key cannot split one series.
The guard is a sufficient condition, not a proof for every partitioning
scheme: it has not been exercised against a concurrent repartition
lifecycle.
- **Anything with a label matcher falls back**, because a matcher that
survives preprocessing selects fewer rows than the scan below the node
reads.
- **Distributed is unsupported**: in a frontend the metric scan is
rewritten to `MergeScanLogicalPlan`, the node is not placed there, and
the flag is off by default.
- **No aggregates, scalars or subquery execution.**
2026-10-25 03:50:37 +08:00
2023-08-10 08:08:37 +00:00
2023-06-25 11:05:46 +08:00
2023-11-09 10:38:12 +00:00

GreptimeDB Logo

Metrics, logs, and traces.
One engine, on your infrastructure.

A columnar database for metrics, logs, and traces on object storage. Apache-2.0 licensed core.

User Guide  ·  API Docs  ·  Roadmap 2026  ·  Slack

Stable Canary Nightly Docker Pulls License

stable for production  ·  canary includes pre-releases  ·  nightly is a weekly snapshot of main

Introduction

GreptimeDB is an open-source observability database. Metrics, logs, and traces run on one columnar engine over object storage and share one table model: tags, timestamp, and fields. When signals carry common identifiers such as service, host, or trace ID, you can correlate them in SQL without moving data between databases.

Ingest through OpenTelemetry, Prometheus Remote Write, Loki Push, or Elasticsearch Bulk. Use SQL across observability data and PromQL for metrics. Migrate ingestion one signal at a time without rebuilding your collectors.

One Query Across Signals

OpenTelemetry ingestion writes spans to opentelemetry_traces and log records to opentelemetry_logs. Both tables carry trace_id, so correlating them is a join:

-- The slowest failed spans in the last hour,
-- with the log lines emitted inside those same traces.
SELECT
    t.service_name,
    t.span_name,
    t.duration_nano / 1000000 AS duration_ms,
    l.timestamp AS log_time,
    l.severity_text,
    l.body
FROM opentelemetry_traces t
JOIN opentelemetry_logs l ON l.trace_id = t.trace_id
WHERE t.timestamp > now() - INTERVAL '1' HOUR
  AND t.span_status_code = 'STATUS_CODE_ERROR'
ORDER BY t.duration_nano DESC
LIMIT 20;

Metrics join the same way, on any tag the tables share, such as service, host, or pod.

Why You Might Use It

  • You run Prometheus plus Loki or Elasticsearch and want one backend instead of three
  • You have outgrown Prometheus on cardinality or retention and don't want the Thanos/Mimir operational surface
  • You are hitting Loki's query performance limits as log volume grows
  • You need long retention on object storage without a separate analytics stack
  • You want to query telemetry with SQL, not only a domain query language
  • You are storing GenAI or agent telemetry (OTel GenAI conventions) alongside infrastructure signals

Learn more in Why GreptimeDB.

What's Supported

Ingest OpenTelemetry (OTLP), Prometheus Remote Write, Loki Push, Elasticsearch Bulk, InfluxDB line protocol, gRPC
Query SQL, PromQL, Jaeger-compatible trace queries, MySQL and PostgreSQL wire protocols
Storage S3, GCS, Azure Blob and S3-compatible endpoints as primary storage, with memory and local-disk caches
Built in Retention policies, downsampling, continuous aggregation, explicit table partitioning, and inverted / skipping / fulltext indexes

Compute and storage are disaggregated: object storage holds the data, while memory and local-disk caches keep recent and frequently queried data close to compute.

GreptimeDB Overview

Benchmarks

Compatibility and Migration

Compatibility is per protocol, and query-side coverage is narrower than ingestion.

Compatible Not compatible
Prometheus Remote Write ingestion; PromQL queries Gaps are listed in PromQL compatibility
Loki Push ingestion; dual-write through Grafana Alloy makes the cutover gradual LogQL and the rest of the Loki query API
Elasticsearch _bulk ingestion in the open-source core; QueryDSL partially, in Enterprise Most other Elasticsearch APIs

Limitations and Edition Boundary

Cluster deployment, object storage, the Flow engine, and every ingestion protocol listed above are in the Apache-2.0 build. Repartitioning, region migration, and index creation are manual operations there.

Read replicas, workload isolation, and automated repartitioning are GreptimeDB Enterprise features, along with enterprise security and governance. The Enterprise overview has the current list, and pricing has the edition comparison.

Architecture

GreptimeDB can run in two modes:

  • Standalone — single binary for development and small deployments.
  • Distributed — four components, each independently scalable:
    • Frontend — protocol entry (OTel, Prometheus, MySQL/PostgreSQL, gRPC, ingestion APIs for Elasticsearch/InfluxDB/Loki) and the distributed query engine. Stateless, scales horizontally.
    • Datanode — region engine with WAL, memtable, SST, cache, compaction, and indexes. Persists data to object storage. Elastic.
    • Metasrv — metadata, routing, repartitioning, and security. Backed by a pluggable KV layer (etcd or RDS).
    • Flownode (optional) — continuous flow computation (streaming and materialized views).

For deeper coverage, see the architecture doc or DeepWiki.

GreptimeDB System Overview

Try GreptimeDB

For AI agents — paste this prompt into your agent:

Read https://docs.greptime.com/SKILL.md and follow the instructions
to deploy, configure, ingest, and query GreptimeDB.
docker run -p 127.0.0.1:4000-4003:4000-4003 \
  -v "$(pwd)/greptimedb_data:/greptimedb_data" \
  --name greptime --rm \
  greptime/greptimedb:latest standalone start \
  --http-addr 0.0.0.0:4000 \
  --grpc-bind-addr 0.0.0.0:4001 \
  --mysql-addr 0.0.0.0:4002 \
  --postgres-addr 0.0.0.0:4003

Dashboard: http://localhost:4000/dashboard

Read more in the full Install Guide.

Troubleshooting:

  • Cannot connect to the database? Ensure that ports 4000, 4001, 4002, and 4003 are not blocked by a firewall or used by other services.
  • Failed to start? Check the container logs with docker logs greptime for further details.

Getting Started

Build From Source

Prerequisites:

  • Rust toolchain — stable, pinned by rust-toolchain.toml
  • Protobuf compiler (>= 3.15)
  • C/C++ building essentials: gcc / g++ / autoconf and the glibc dev package (libc6-dev on Ubuntu, glibc-devel on Fedora)
  • Python toolchain (optional, only for some test scripts)

Build and run:

make                          # build greptime binary
cargo run -- standalone start # start in standalone mode

Common dev commands:

make fmt            # format Rust code
make clippy         # lint (fails on warnings)
make test           # unit + integration tests (uses cargo-nextest)
make sqlness-test   # SQL regression tests

See the Contribution Guidelines for the full developer workflow.

Tools & Extensions

Project Status

GreptimeDB is generally available, with stable APIs and regular releases. It runs in production at scale — OceanBase Cloud operates 80+ GreptimeDB clusters managing 300 TB of logs, cutting log storage cost by 60%+ after migrating from Grafana Loki. See more in case studies.

Release lines and support windows are in the version reference. For where the project is going, read the v1.0 highlights and the 2026 roadmap.

Community

We invite you to engage and contribute!

If GreptimeDB is useful to you, please star the repo.

Known Users

License

GreptimeDB is an open-core project. Its core is licensed under the Apache License 2.0.

A small set of peripheral, enterprise-only features are gated behind the enterprise Cargo feature (not built by default) and are governed by the separate GreptimeDB Enterprise License. Source files under that license carry an explicit Enterprise License header.

Commercial Support

Scaling observability on your infrastructure? GreptimeDB Enterprise adds the operational, security, and support layer for production deployments. Contact us for details.

Contributing

Integration CI Codecov

Acknowledgement

Special thanks to all contributors! See AUTHOR.md.


All trademarks, logos, and brand names referenced in this README and in the Overview diagram are the property of their respective owners. Their use is for identification purposes only and does not imply endorsement or affiliation.

S
Description
Open-source, cloud-native, unified observability database for metrics, logs and traces, supporting SQL/PromQL/Streaming.
Readme Apache-2.0
1.9 GiB
Languages
Rust 97.4%
Python 1.9%
Shell 0.4%
JavaScript 0.2%