* feat(promql): run a bound tree over raw series in one fused node
`FusedSeriesExec` evaluates a compiled `NativeTree` directly over the raw
series its child produces, instead of materialising a row batch through the
per-operator chain. It binds the time column, the fields and the tags once,
checks the input against that binding when the plan is built, and converts the
chunks the tree settles back into the statement's output columns.
The node's partition count is the child's, one tree per partition. The child
must be a raw-series scan that assigns every series whole to one partition:
`PerSeries` distribution is what makes a per-partition evaluation equivalent to
evaluating the whole series. A zero-partition input is refused; anything else
is the child's business.
A label set can recur across partitions once `rate` and the map kernels drop
`__name__`, so the occupancy map is plan state rather than per-stream. Two
series reaching one label set is an error only when their occupied grid points
overlap, which is Prometheus' range-query rule; disjoint points are legal and
are merged into one occupancy record. The reservation for that map is attached
to the first execution's pool and released when the plan is dropped, because a
later partition still has to collide with what an earlier one emitted.
`occupied_runs` reads a chunk's absolute positions rather than assuming one
contiguous run: a sparse evaluated chunk may hold several, and treating the
first as the whole range would count the holes as rows.
A poll does a bounded amount of work and wakes itself when the budget runs out,
so a long run of chunks that settle nothing cannot monopolise the executor
thread.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): charge for the label sets the plan keeps, not for one stream
The occupancy map is the plan's: entries are inserted by whichever partition
sees a label set first and no path removes them. The reservation paying for
them was the stream's, so the bytes went back when that stream ended while the
entries stayed. Measured on a plan with three partitions producing three label
sets: each grew 42 bytes, each stream's end returned them, and the pool then
read 0 while every entry was still there and still colliding with later
partitions. The plan's own drop produced no shrink at all.
So a label set's bytes were charged for part of the time and invisible for the
rest, and the invisibility grew with the number of label sets a query touches.
The charge now belongs to the map it pays for: `LabelSets` holds the entries
and the reservation together, `attach` registers it once on the first
execution's pool, and it is released when the plan is dropped.
`attach` is idempotent because every partition calls it. Re-registering would
move a charge the other partitions keep growing, and would give it back when
one stream ends while the entries stay - which is the shape of the bug.
A regression test states the invariant: after the first partition's stream
ends, the pool is still charged for its label set; a second partition with the
same set on disjoint points adds no further charge; dropping the plan returns
it to zero.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): take a raw series frame, not a row batch, and keep its series whole
Review found five things; this is all of them.
The node read ordinary Arrow rows and converted them itself, so a real
`SeriesScanExec` could never feed it: a frame's columns are `read_key`,
`is_last`, and the `time` and `fields` structs, and none of them is the time
column, a tag or a field the binding names, so construction refused it. It now
takes a raw series frame the way `NativeInstantManipulateExec` does - the frame
is validated when the plan is built and unpacked per partition when it runs -
and the row-to-series mapping is gone, because turning rows into series is a
different node's job. The series schema comes from the tree's own input node,
so there is one statement of it rather than two that could disagree.
A raw frame is a per-series stream, and this node holds one window per series
and closes it on the chunk that marks the end. Declaring that as
`KeyPartitioned(read_key)` keeps the optimizer from splitting a series across
partitions, which it would otherwise be free to do; `read_key` is the series'
primary key as a binary column, so hashing it groups exactly the rows of one
series. A round-robin split would have let each partition rate half a series
and answer half the truth; with the key declared, a series that somehow did
arrive in two pieces is refused by the frame validator instead.
Two label-set rules were wrong. Occupancy was tracked per position for any
field, so two series that each hold a different field at the same point looked
like a collision, while the rest of the stack merges exactly that as
complementary fields - it is now tracked per field, and only one field holding
two values at one point is refused. And the key encoded every tag including
empty ones, while `grid_series::label_set` drops them, so `{host="h", zone=""}`
and `{host="h"}` were two keys here and one everywhere else; the key is now
that same normalized label set.
Finally, a label set already in the map grew its run arrays on every merge
without telling the pool. The charge now follows the capacity the merge adds,
so a long-lived plan that keeps meeting one label set pays for what it holds.
The test that asserted the opposite was wrong and is corrected.
Signed-off-by: discord9 <discord9@163.com>
* fix(table): declare the distribution a raw series frame already has
`SeriesScan` guarantees every series arrives whole in one partition - its own
docs say so, and both modes enforce it in different ways: the two-phase
collector places a series by its tsid, and the legacy divider only emits a
batch once it holds all of a series' rows. But the leaf built for a raw frame
declared `UnknownPartitioning`, so nothing on the plan said it.
That mattered to a consumer holding one window per series: with the
distribution unspecified an optimizer is free to round-robin the partition,
and two halves of one series would each look final to their own stream. So the
leaf now declares `Hash(read_key)`. `read_key` is the primary key as a binary
column, so hashing it groups exactly the rows of one series and splits nothing;
`FusedSeriesExec` asks for the same key and is satisfied without a shuffle.
The three tests that pinned `UnknownPartitioning` now pin the key instead.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): keep the capacity a label set already paid for
`merge_runs` built a fresh vector sized to `stored + new` and replaced the old
one. When adjacent runs joined - or when a claim added nothing to a field,
which is what two complementary label sets do to each other - the entry came
back smaller than it was. The charge in `claim` is the growth over the
capacity, so the next merge into that field looked like growth again and was
charged for it, every time, while the entry itself never held more than a
couple of runs. A long query meeting one label set repeatedly would have paid
for the same few bytes over and over and could exhaust a pool it never used.
The merge now keeps whichever capacity is larger, and the charge follows that:
a merge that adds nothing to a field costs nothing.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): run the fused node over one series stream
`FusedSeriesExec` held one window per series and closed it on the chunk carrying
`is_last`, so it can answer a series only if nothing splits that run and nothing
puts part of it in another tree. Those are two different promises, and only the
first is a property of a stream: the frame decoder refuses a chunk interleaved
with another series', but nothing it can see says that half a series is in a
partition it never reads.
The node used to ask for `KeyPartitioned(read_key)` instead, expecting the
optimizer to keep whole series per partition. That does not hold: DataFusion
considers two hash layouts with the same count co-partitioned, and above
`subset_repartition_threshold` children it adds a hash exchange even when the
requirement is met (`enforce_distribution.rs`, the
`!partitioning_satisfied || target > child` branch). A hash exchange does not
preserve order, so series chunks interleave and the second half of a series is
answered as a series of its own.
So the count is now fixed at one, in `try_new` and in the reported properties,
and `benefits_from_input_partitioning` is false so no optimizer splits it for
parallelism. This is the phase-1 contract: reading a series spread over
partitions needs an exchange above this node, which does not exist yet.
Merging #9786 also moved `unpack_raw_series` to two arguments, with the frame
declaring its own label layout and ordering; the calls follow it, and the plan's
binding is checked against that declaration with
`ensure_frame_matches_label_source`.
Signed-off-by: discord9 <discord9@163.com>
* Revert "fix(table): declare the distribution a raw series frame already has"
The declaration was false. `Partitioning::Hash(read_key, n)` claims DataFusion
placed rows by its own hash of `read_key` mod `n`, and DataFusion treats any two
equal-count `Hash` layouts as co-partitioned, so a future co-partitioned consumer
would pair partitions that hold unrelated series. `SeriesScan` places a series by
tsid range (two-phase) or round-robin (legacy), neither of which is that hash.
It also did not buy what it was added for. `FusedSeriesExec` now takes one input
partition and reports `benefits_from_input_partitioning = false`, so nothing
repartitions between the scan and it, and the ordering guarantee the declaration
was meant to carry is the frame's own.
This reverts commit c9d160f6a1.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): keep the input's partitions and forbid an exchange
The node asked for `KeyPartitioned(read_key)` so the optimizer would keep whole
series per partition. It does not: that is a hash requirement, and
`hash_necessary = is_hash && multi_partitions` makes a hash exchange necessary as
soon as the child has more than one partition - even where the child already
satisfies the requirement. `with_preserve_order` then leaves `preserve_order`
false, because the child declares no ordering, so the exchange reorders and the
frame decoder closes a series it has not finished.
Asking for nothing removes both ways an exchange can appear between the scan and
this node: `is_hash` is false for `UnspecifiedDistribution`, so no hash exchange
is necessary, and `benefits_from_input_partitioning` is false so no round-robin
is added to raise parallelism. The child's layout then arrives as it left the
scan, which is what this node needs: `SeriesScan` keeps a series in one
partition, so one window per series is the right amount of state and the
partitions run in parallel.
So the node runs as many partitions as its input has, and only a zero-partition
input is refused. The comments around the label-set map say the same thing
again: the map is shared across partitions because two series that a range
function and its maps reduce to one label set can land in different ones, and
the runtime's per-node `Emitted` table never sees both.
Signed-off-by: discord9 <discord9@163.com>
* test(promql): prove no exchange is inserted above the fused node
The multi-partition contract rests on the optimizer leaving the input's layout
alone, and the previous `KeyPartitioned` requirement failed exactly there: the
rule inserted a hash `RepartitionExec` over `read_key` even though the child
satisfied the requirement, because a hash requirement is necessary for any child
with more than one partition. Reasoning about the flags is what missed it, so the
claim is now checked against the rule.
The new test runs `EnsureRequirements` over the node with a two-partition child
and `target_partitions = 8`, and fails if any `RepartitionExec` sits between the
input and the node. Ablating the requirement back to `KeyPartitioned(read_key)`
makes it fail, naming the hash exchange the rule inserted at 8 partitions, so the
test has the case it was written for; with `UnspecifiedDistribution` and
`benefits_from_input_partitioning = false` it passes.
The node also refuses, when it has more than one partition, a tree where a window
or a map rewrites the series identity and a window above reads a whole series.
The runtime does merge fragments that a rewrite made indistinguishable, but that
merge belongs to one execution and this node runs one per partition, so each
would merge its own half and the consumer would answer an empty result. The
compiler cannot build that shape today - an outer window needs a subquery and
`compile` refuses those - so the check costs no executable query.
Signed-off-by: discord9 <discord9@163.com>
* test(promql): assert the optimizer leaves the partition count alone
No exchange is not the same as no change. The ablation showed the old
`KeyPartitioned` requirement not only inserted a hash exchange but raised the node
from two partitions to eight, which is what `target_partitions` is set above the
child for. A rule that changed the count some other way would split a series just
as effectively, and the previous assertions only looked for a `RepartitionExec`.
The test now also pins the node's partition count and its input's, both to the
child's own two.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): record no label set for a chunk holding no value
A selection over a series that staleness or the range left empty still closes
that series and hands over a closing chunk with no rows. Every chunk reached
`LabelSets::claim`, which wrote an entry for it and charged the plan's
reservation, and the plan holds that charge for its whole life: a query whose
answer is empty grew the table and its reservation until a finite pool ran out,
without producing a row.
A chunk that occupies no position has no collision to remember and no entry to
write, so it is skipped before the key is built. A later chunk under the same
label set claims the positions it really holds, which a test now pins, together
with the table staying empty over many empty series.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): refuse a nested window over more than one partition
`reject_a_split_logical_series` took a window to be a possible rewriter only
when the function drops the metric name. That is not enough. `last_over_time` and
`first_over_time` keep `__name__`, but a tag holding an empty value and an absent
tag are one canonical label set and two raw keys, so their output can still hold
two series under one label set.
The runtime already knows this: its `holds_shared` pass computes
`drops_metric_name || !input_holds_shared` for a window, so a window that did not
merge its input leaves fragments for the window above it. That merge belongs to
one execution, and this node runs one execution per partition.
A bare tree says nothing about whether an input was merged, so every window is
now taken to be a possible rewriter, and the existing shape check narrows it to
the case that costs correctness: a window with a window above it. The refusal
still applies only over more than one partition; one partition returns before the
walk and keeps working.
The compiler requires a range function to apply directly to a selector, so no
nested-window tree is executable today and this costs no executable query. The
regression test pins the hole: with the old predicate restored it fails on the
two-partition tree, and the same tree over one partition is still accepted.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): share the run bookkeeping so a sparse claim stays linear
Every output chunk went through `LabelSets::claim`, which scanned the stored runs
from index 0 and re-merged all of them into a fresh vector. A legal sparse stream
- one grid point per chunk with a hole after it - therefore cost the square of the
chunk count, under a mutex every partition waits on. Measured on the two
functions alone: 4,000 chunks took about 58 ms, 16,000 about 962 ms.
The native runtime had already solved this for its own `Emitted` table, so the
two implementations are now one. `Run`, `absorb`, `append_runs`, `merge_runs` and
the test-only rebuild counter move to `native::runs`, and both layers call them:
`LabelSets` stores `Vec<Vec<Run>>`, looks a position up with `partition_point`
instead of scanning, and appends through `absorb`, which only rebuilds when a
chunk reaches back into what is stored. Keeping one copy is the point - the Fused
layer had fallen behind the runtime's version, and two copies can drift again.
The charge follows the vectors' capacity across the absorb, so the append path's
real growth is what gets charged and nothing already paid for is charged twice.
Two tests pin it: 64 forward chunks rebuild nothing, and a chunk that reaches
back still merges (and still refuses a genuine collision).
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): pull the fused node's answers one at a time after its input ends
`close()` called `finish()`, which ends the input and then drains what the tree
settled into one `Vec` before anything is handed out. A tree that answers many
chunks after its input ends therefore held all of them while the consumer waited
for the first, and charged the pool for them at once - the opposite of what the
runtime's `end_of_input` / `next_chunk` pair is for.
The stream now ends the input once and pulls one chunk per poll, through the
execution's own protocol: `end_of_input()` when the input ends, then `next_chunk()`
per answer, and `None` - which is also when the execution closes and returns its
reservations - is what ends the stream. The input is dropped as soon as it ends so
a poll goes straight to pulling.
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): preserve fused claims on memory refusal
Signed-off-by: discord9 <discord9@163.com>
* test(promql): verify retained fused claim allocations
Signed-off-by: discord9 <discord9@163.com>
* test(promql): keep the pool blocker immutable
Signed-off-by: discord9 <discord9@163.com>
* test(promql): cover a claim that shrinks its run vector
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): refuse an instant split under a window
Signed-off-by: discord9 <discord9@163.com>
* fix(promql): read grid timestamps through their fallible accessor
Signed-off-by: discord9 <discord9@163.com>
---------
Signed-off-by: discord9 <discord9@163.com>
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 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.
Benchmarks
- Agent RCA Bench: LLM agents doing root cause analysis over GreptimeDB versus Prometheus + Loki + Tempo. 40% fewer wrong diagnoses, 48% fewer input tokens, 45% lower cost (write-up)
- GreptimeDB tops JSONBench's billion-record cold run test
- TSBS Benchmark
- More benchmark reports
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.
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, and4003are not blocked by a firewall or used by other services. - Failed to start? Check the container logs with
docker logs greptimefor 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++/autoconfand the glibc dev package (libc6-devon Ubuntu,glibc-develon 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
- Kubernetes: GreptimeDB Operator
- Helm Charts: Greptime Helm Charts
- Dashboard: Web UI
- gRPC Ingester: Go, Java, C++, Erlang, Rust, .NET, TypeScript
- Grafana Data Source: GreptimeDB Grafana data source plugin
- Grafana Dashboard: Official Dashboard for monitoring
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.
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
- Read our Contribution Guidelines.
- Explore Internal Concepts and DeepWiki.
- Pick up a good first issue and join the #contributors Slack channel.
Acknowledgement
Special thanks to all contributors! See AUTHOR.md.
- Uses Apache Arrow™ (memory model)
- Apache Parquet™ (file storage)
- Apache DataFusion™ (query engine)
- Apache OpenDAL™ (data access abstraction)
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.
