greptimedb

GitHub

The open-source Observability 2.0 database. One engine for metrics, logs, and traces — replacing Prometheus, Loki & ES.

RAW Doc

Benchmarks/Tsbs/README

How to run TSBS Benchmark

This document contains the steps to run TSBS Benchmark. Our results are listed in other files in the same directory.

Prerequires

You need the following tools to run TSBS Benchmark:
- Go
- git
- make
- rust (optional, if you want to build the DB from source)

Build TSBS suite

Clone our fork of TSBS:

shell
git clone https://github.com/GreptimeTeam/tsbs.git

Then build it:

shell
cd tsbs
make

You can check the bin/ directory for compiled binaries. We will only use some of them.

shell
ls ./bin/

Binaries we will use later:
- tsbs_generate_data
- tsbs_generate_queries
- tsbs_load_greptime
- tsbs_run_queries_influx

Generate test data and queries

The data is generated by tsbs_generate_data

shell
mkdir bench-data
./bin/tsbs_generate_data --use-case="cpu-only" --seed=123 --scale=4000 \
--timestamp-start="2023-06-11T00:00:00Z" \
--timestamp-end="2023-06-14T00:00:00Z" \
--log-interval="10s" --format="influx" \
> ./bench-data/influx-data.lp

Here we generates 4000 time-series in 3 days with 10s interval. We'll use influx line protocol to write so the target format is influx.

Queries are generated by tsbs_generate_queries. You can change the parameters but need to make sure it matches with tsbs_generate_data.

text
/ Detailed source-code truncated for AI context efficiency. /

Start GreptimeDB

Reference to our document for how to install and start a GreptimeDB. Or you can also check this document for how to build a GreptimeDB from source.

Write Data

After the DB is started, we can use tsbs_load_greptime to test the write performance.

shell
./bin/tsbs_load_greptime \
--urls=http://localhost:4000 \
--file=./bench-data/influx-data.lp \
--batch-size=3000 \
--gzip=false \
--workers=6

Parameters here are only provided as an example. You can choose whatever you like or adjust them to match your target scenario.

Notice that if you want to rerun tsbs_load_greptime, please destroy and restart the DB and clear its previous data first. Existing duplicated data will impact the write and query performance.

Query Data

After the data is imported, you can then run queries. The following script runs all queries. You can also choose a subset of queries to run.

text
/ Detailed source-code truncated for AI context efficiency. /

Rerun queries need not to re-import data. Just execute the corresponding command again is fine.

---

Benchmarks/Tsbs/V0.3.2

TSBS benchmark - v0.3.2

Environment

| | |
| --- | --- |
| CPU | AMD Ryzen 7 7735HS (8 core 3.2GHz) |
| Memory | 32GB |
| Disk | SOLIDIGM SSDPFKNU010TZ |
| OS | Ubuntu 22.04.2 LTS |


Write performance

| Write buffer size | Ingest rate(rows/s) |
| --- | --- |
| 512M | 139583.04 |
| 32M | 279250.52 |


Query performance

| Query type | v0.3.2 write buffer 32M (ms) | v0.3.2 write buffer 512M (ms) | v0.3.1 write buffer 32M (ms) |
| --- | --- | --- | --- |
| cpu-max-all-1 | 921.12 | 241.23 | 553.63 |
| cpu-max-all-8 | 2657.66 | 502.78 | 3308.41 |
| double-groupby-1 | 28238.85 | 27367.42 | 52148.22 |
| double-groupby-5 | 33094.65 | 32421.89 | 56762.37 |
| double-groupby-all | 38565.89 | 38635.52 | 59596.80 |
| groupby-orderby-limit | 23321.60 | 22423.55 | 53983.23 |
| high-cpu-1 | 1167.04 | 254.15 | 832.41 |
| high-cpu-all | 32814.08 | 29906.94 | 62853.12 |
| lastpoint | 192045.05 | 153575.42 | NA |
| single-groupby-1-1-1 | 63.97 | 87.35 | 92.66 |
| single-groupby-1-1-12 | 666.24 | 326.98 | 781.50 |
| single-groupby-1-8-1 | 225.29 | 137.97 |281.95 |
| single-groupby-5-1-1 | 70.40 | 81.64 | 86.15 |
| single-groupby-5-1-12 | 722.75 | 356.01 | 805.18 |
| single-groupby-5-8-1 | 285.60 | 115.88 | 326.29 |

---

Benchmarks/Tsbs/V0.4.0

TSBS benchmark - v0.4.0

Environment

Local


| | |
| ------ | ---------------------------------- |
| CPU | AMD Ryzen 7 7735HS (8 core 3.2GHz) |
| Memory | 32GB |
| Disk | SOLIDIGM SSDPFKNU010TZ |
| OS | Ubuntu 22.04.2 LTS |

Aliyun amd64

| | |
| ------- | -------------- |
| Machine | ecs.g7.4xlarge |
| CPU | 16 core |
| Memory | 64GB |
| Disk | 100G |
| OS | Ubuntu 22.04 |

Aliyun arm64

| | |
| ------- | ----------------- |
| Machine | ecs.g8y.4xlarge |
| CPU | 16 core |
| Memory | 64GB |
| Disk | 100G |
| OS | Ubuntu 22.04 ARM |


Write performance

| Environment | Ingest rate(rows/s) |
| ------------------ | --------------------- |
| Local | 365280.60 |
| Aliyun g7.4xlarge | 341368.72 |
| Aliyun g8y.4xlarge | 320907.29 |


Query performance

| Query type | Local (ms) | Aliyun g7.4xlarge (ms) | Aliyun g8y.4xlarge (ms) |
| --------------------- | ---------- | ---------------------- | ----------------------- |
| cpu-max-all-1 | 50.70 | 31.46 | 47.61 |
| cpu-max-all-8 | 262.16 | 129.26 | 152.43 |
| double-groupby-1 | 2512.71 | 1408.19 | 1586.10 |
| double-groupby-5 | 3896.15 | 2304.29 | 2585.29 |
| double-groupby-all | 5404.67 | 3337.61 | 3773.91 |
| groupby-orderby-limit | 3786.98 | 2065.72 | 2312.57 |
| high-cpu-1 | 71.96 | 37.29 | 54.01 |
| high-cpu-all | 9468.75 | 7595.69 | 8467.46 |
| lastpoint | 13379.43 | 11253.76 | 12949.40 |
| single-groupby-1-1-1 | 20.72 | 12.16 | 13.35 |
| single-groupby-1-1-12 | 28.53 | 15.67 | 21.62 |
| single-groupby-1-8-1 | 72.23 | 37.90 | 43.52 |
| single-groupby-5-1-1 | 26.75 | 15.59 | 17.48 |
| single-groupby-5-1-12 | 45.41 | 22.90 | 31.96 |
| single-groupby-5-8-1 | 107.96 | 59.76 | 69.58 |

---

Benchmarks/Tsbs/V0.7.0

TSBS benchmark - v0.7.0

Environment

Local


| | |
| ------ | ---------------------------------- |
| CPU | AMD Ryzen 7 7735HS (8 core 3.2GHz) |
| Memory | 32GB |
| Disk | SOLIDIGM SSDPFKNU010TZ |
| OS | Ubuntu 22.04.2 LTS |

Amazon EC2

| | |
| ------- | -------------- |
| Machine | c5d.2xlarge |
| CPU | 8 core |
| Memory | 16GB |
| Disk | 50GB (GP3) |
| OS | Ubuntu 22.04.1 |


Write performance

| Environment | Ingest rate (rows/s) |
| --------------- | -------------------- |
| Local | 369581.464 |
| EC2 c5d.2xlarge | 298716.664 |


Query performance

| Query type | Local (ms) | EC2 c5d.2xlarge (ms) |
| --------------------- | ---------- | -------------------- |
| cpu-max-all-1 | 30.56 | 54.74 |
| cpu-max-all-8 | 52.69 | 70.50 |
| double-groupby-1 | 664.30 | 1366.63 |
| double-groupby-5 | 1391.26 | 2141.71 |
| double-groupby-all | 2828.94 | 3389.59 |
| groupby-orderby-limit | 718.92 | 1213.90 |
| high-cpu-1 | 29.21 | 52.98 |
| high-cpu-all | 5514.12 | 7194.91 |
| lastpoint | 7571.40 | 9423.41 |
| single-groupby-1-1-1 | 19.09 | 7.77 |
| single-groupby-1-1-12 | 27.28 | 51.64 |
| single-groupby-1-8-1 | 31.85 | 11.64 |
| single-groupby-5-1-1 | 16.14 | 9.67 |
| single-groupby-5-1-12 | 27.21 | 53.62 |
| single-groupby-5-8-1 | 39.62 | 14.96 |

---

Benchmarks/Tsbs/V0.8.0

TSBS benchmark - v0.8.0

Environment

Local

| | |
| ------ | ---------------------------------- |
| CPU | AMD Ryzen 7 7735HS (8 core 3.2GHz) |
| Memory | 32GB |
| Disk | SOLIDIGM SSDPFKNU010TZ |
| OS | Ubuntu 22.04.2 LTS |

Amazon EC2

| | |
| ------- | -------------- |
| Machine | c5d.2xlarge |
| CPU | 8 core |
| Memory | 16GB |
| Disk | 50GB (GP3) |
| OS | Ubuntu 22.04.1 |

Write performance

| Environment | Ingest rate (rows/s) |
| --------------- | -------------------- |
| Local | 315369.66 |
| EC2 c5d.2xlarge | 222148.56 |

Query performance

| Query type | Local (ms) | EC2 c5d.2xlarge (ms) |
| --------------------- | ---------- | -------------------- |
| cpu-max-all-1 | 24.63 | 15.29 |
| cpu-max-all-8 | 51.69 | 33.53 |
| double-groupby-1 | 673.51 | 1295.38 |
| double-groupby-5 | 1244.93 | 1993.91 |
| double-groupby-all | 2215.44 | 3056.77 |
| groupby-orderby-limit | 754.50 | 1546.49 |
| high-cpu-1 | 19.62 | 11.58 |
| high-cpu-all | 5402.31 | 8011.43 |
| lastpoint | 6756.12 | 9312.67 |
| single-groupby-1-1-1 | 15.70 | 7.67 |
| single-groupby-1-1-12 | 16.72 | 9.29 |
| single-groupby-1-8-1 | 26.72 | 17.97 |
| single-groupby-5-1-1 | 18.17 | 10.09 |
| single-groupby-5-1-12 | 20.04 | 12.37 |
| single-groupby-5-8-1 | 35.63 | 23.13 |

single-groupby-1-1-1 query throughput

| Environment | Client concurrency | mean time (ms) | qps (queries/sec) |
| --------------- | ------------------ | -------------- | ----------------- |
| Local | 50 | 42.87 | 1165.73 |
| Local | 100 | 89.29 | 1119.38 |
| EC2 c5d.2xlarge | 50 | 69.25 | 721.73 |
| EC2 c5d.2xlarge | 100 | 140.93 | 709.35 |

---

Benchmarks/Tsbs/V0.9.1

TSBS benchmark - v0.9.1

Environment

Local

| | |
| ------ | ---------------------------------- |
| CPU | AMD Ryzen 7 7735HS (8 core 3.2GHz) |
| Memory | 32GB |
| Disk | SOLIDIGM SSDPFKNU010TZ |
| OS | Ubuntu 22.04.2 LTS |

Amazon EC2

| | |
| ------- | ----------------------- |
| Machine | c5d.2xlarge |
| CPU | 8 core |
| Memory | 16GB |
| Disk | 100GB (GP3) |
| OS | Ubuntu Server 24.04 LTS |

Write performance

| Environment | Ingest rate (rows/s) |
| --------------- | -------------------- |
| Local | 387697.68 |
| EC2 c5d.2xlarge | 234620.19 |

Query performance

| Query type | Local (ms) | EC2 c5d.2xlarge (ms) |
| --------------------- | ---------- | -------------------- |
| cpu-max-all-1 | 21.14 | 14.75 |
| cpu-max-all-8 | 36.79 | 30.69 |
| double-groupby-1 | 529.02 | 987.85 |
| double-groupby-5 | 1064.53 | 1455.95 |
| double-groupby-all | 1625.33 | 2143.96 |
| groupby-orderby-limit | 529.19 | 1353.49 |
| high-cpu-1 | 12.09 | 8.24 |
| high-cpu-all | 3619.47 | 5312.82 |
| lastpoint | 224.91 | 576.06 |
| single-groupby-1-1-1 | 10.82 | 6.01 |
| single-groupby-1-1-12 | 11.16 | 7.42 |
| single-groupby-1-8-1 | 13.50 | 10.20 |
| single-groupby-5-1-1 | 11.99 | 6.70 |
| single-groupby-5-1-12 | 13.17 | 8.72 |
| single-groupby-5-8-1 | 16.01 | 12.07 |

single-groupby-1-1-1 query throughput

| Environment | Client concurrency | mean time (ms) | qps (queries/sec) |
| --------------- | ------------------ | -------------- | ----------------- |
| Local | 50 | 33.04 | 1511.74 |
| Local | 100 | 67.70 | 1476.14 |
| EC2 c5d.2xlarge | 50 | 61.93 | 806.97 |
| EC2 c5d.2xlarge | 100 | 126.31 | 791.40 |

---

Benchmarks/Tsbs/V0.12.0

TSBS benchmark - v0.12.0

Environment

Amazon EC2

| | |
|---------|-------------------------|
| Machine | c5d.2xlarge |
| CPU | 8 core |
| Memory | 16GB |
| Disk | 100GB (GP3) |
| OS | Ubuntu Server 24.04 LTS |

Write performance

| Environment | Ingest rate (rows/s) |
|-----------------|----------------------|
| EC2 c5d.2xlarge | 326839.28 |

Query performance

| Query type | EC2 c5d.2xlarge (ms) |
|-----------------------|----------------------|
| cpu-max-all-1 | 12.46 |
| cpu-max-all-8 | 24.20 |
| double-groupby-1 | 673.08 |
| double-groupby-5 | 963.99 |
| double-groupby-all | 1330.05 |
| groupby-orderby-limit | 952.46 |
| high-cpu-1 | 5.08 |
| high-cpu-all | 4638.57 |
| lastpoint | 591.02 |
| single-groupby-1-1-1 | 4.06 |
| single-groupby-1-1-12 | 4.73 |
| single-groupby-1-8-1 | 8.23 |
| single-groupby-5-1-1 | 4.61 |
| single-groupby-5-1-12 | 5.61 |
| single-groupby-5-8-1 | 9.74 |

---

Benchmarks/Log/Query

Query URL and payload for Elastic Search


Count


URL: http://127.0.0.1:9200/_count

Query by timerange


URL: http://127.0.0.1:9200/_search

You can use the following payload to get the full timerange first.

JSON
{"size":0,"aggs":{"max_timestamp":{"max":{"field":"timestamp"}},"min_timestamp":{"min":{"field":"timestamp"}}}}

And then use this payload to query by timerange.

JSON
{
"from": 0,
"size": 1000,
"query": {
"range": {
"timestamp": {
"gte": "2024-08-16T04:30:44.000Z",
"lte": "2024-08-16T04:51:52.000Z"
}
}
}
}

Query by condition


URL: http://127.0.0.1:9200/_search

Structured payload


JSON
{
"from": 0,
"size": 10000,
"query": {
"bool": {
"must": [
{
"term": {
"user.keyword": "CrucifiX"
}
},
{
"term": {
"method.keyword": "OPTION"
}
},
{
"term": {
"path.keyword": "/user/booperbot124"
}
},
{
"term": {
"http_version.keyword": "HTTP/1.1"
}
},
{
"term": {
"status": "401"
}
}
]
}
}
}

Unstructured payload


JSON
{
"from": 0,
"size": 10000,
"query": {
"bool": {
"must": [
{
"match_phrase": {
"message": "CrucifiX"
}
},
{
"match_phrase": {
"message": "OPTION"
}
},
{
"match_phrase": {
"message": "/user/booperbot124"
}
},
{
"match_phrase": {
"message": "HTTP/1.1"
}
},
{
"match_phrase": {
"message": "401"
}
}
]
}
}
}

Query by condition and timerange


URL: http://127.0.0.1:9200/_search

Structured payload


JSON
{
"size": 10000,
"query": {
"bool": {
"must": [
{
"term": {
"user.keyword": "CrucifiX"
}
},
{
"term": {
"method.keyword": "OPTION"
}
},
{
"term": {
"path.keyword": "/user/booperbot124"
}
},
{
"term": {
"http_version.keyword": "HTTP/1.1"
}
},
{
"term": {
"status": "401"
}
},
{
"range": {
"timestamp": {
"gte": "2024-08-19T07:03:37.383Z",
"lte": "2024-08-19T07:24:58.883Z"
}
}
}
]
}
}
}

Unstructured payload


JSON
{
"size": 10000,
"query": {
"bool": {
"must": [
{
"match_phrase": {
"message": "CrucifiX"
}
},
{
"match_phrase": {
"message": "OPTION"
}
},
{
"match_phrase": {
"message": "/user/booperbot124"
}
},
{
"match_phrase": {
"message": "HTTP/1.1"
}
},
{
"match_phrase": {
"message": "401"
}
},
{
"range": {
"timestamp": {
"gte": "2024-08-19T05:16:17.099Z",
"lte": "2024-08-19T05:46:02.722Z"
}
}
}
]
}
}
}

---

Benchmarks/Log/README

Log benchmark configuration


This repo holds the configuration we used to benchmark GreptimeDB, Clickhouse and Elastic Search.

Here are the versions of databases we used in the benchmark

| name | version |
| :------------ | :--------- |
| GreptimeDB | v0.9.2 |
| Clickhouse | 24.9.1.219 |
| Elasticsearch | 8.15.0 |

Structured model vs Unstructured model


We divide test into two parts, using structured model and unstructured model accordingly. You can also see the difference in create table clause.

__Structured model__

The log data is pre-processed into columns by vector. For example an insert request looks like following

SQL
INSERT INTO test_table (bytes, http_version, ip, method, path, status, user, timestamp) VALUES ()

The goal is to test string/text support for each database. In real scenarios it means the datasource(or log data producers) have separate fields defined, or have already processed the raw input.

__Unstructured model__

The log data is inserted as a long string, and then we build fulltext index upon these strings. For example an insert request looks like following

SQL
INSERT INTO test_table (message, timestamp) VALUES ()

The goal is to test fuzzy search performance for each database. In real scenarios it means the log is produced by some kind of middleware and inserted directly into the database.

Creating tables


See here for GreptimeDB and Clickhouse's create table clause.
The mapping of Elastic search is created automatically.

Vector Configuration


We use vector to generate random log data and send inserts to databases.
Please refer to structured config and unstructured config for detailed configuration.

SQLs and payloads


Please refer to SQL query for GreptimeDB and Clickhouse, and query payload for Elastic search.

Steps to reproduce


0. Decide whether to run structured model test or unstructured mode test.
1. Build vector binary(see vector's config file for specific branch) and databases binaries accordingly.
2. Create table in GreptimeDB and Clickhouse in advance.
3. Run vector to insert data.
4. When data insertion is finished, run queries against each database. Note: you'll need to update timerange value after data insertion.

Addition


- You can tune GreptimeDB's configuration to get better performance.
- You can setup GreptimeDB to use S3 as storage, see here.

---

How To/Memory Profile Scripts/Scripts/README

Memory Analysis Process


This section will guide you through the process of analyzing memory usage for greptimedb.

1. Get the jeprof tool script, see the next section("Getting the jeprof tool") for details.

2. After starting greptimedb(with env var MALLOC_CONF=prof:true), execute the dump.sh script with the PID of the greptimedb process as an argument. This continuously monitors memory usage and captures profiles when exceeding thresholds (e.g. +20MB within 10 minutes). Outputs greptime-{timestamp}.gprof files.

3. With 2-3 gprof files, run gen_flamegraph.sh in the same environment to generate flame graphs showing memory allocation call stacks.

4. NOTE: The gen_flamegraph.sh script requires jeprof and optionally flamegraph.pl to be in the current directory. If needed to gen flamegraph now, run the get_flamegraph_tool.sh script, which downloads the flame graph generation tool flamegraph.pl to the current directory.
The usage of gen_flamegraph.sh is:

Usage: ./gen_flamegraph.sh <binary_path> <gprof_directory>
where <binary_path> is the path to the greptimedb binary, <gprof_directory> is the directory containing the gprof files(the directory dump.sh is dumping profiles to).
Example call: ./gen_flamegraph.sh ./greptime .

Generating the flame graph might take a few minutes. The generated flame graphs are located in the <gprof_directory>/flamegraphs directory. Or if no flamegraph.pl is found, it will only contain .collapse files which is also fine.
5. You can send the generated flame graphs(the entire folder of <gprof_directory>/flamegraphs) to developers for further analysis.


Getting the jeprof tool


there are three ways to get jeprof, list in here from simple to complex, using any one of those methods is ok, as long as it's the same environment as the greptimedb will be running on:
1. If you are compiling greptimedb from source, then jeprof is already produced during compilation. After running cargo build, execute find_compiled_jeprof.sh. This will copy jeprof to the current directory.
2. Or, if you have the Rust toolchain installed locally, simply follow these commands:
bash
cargo new get_jeprof
cd get_jeprof

Then add this line to Cargo.toml:
toml
[dependencies]
tikv-jemalloc-ctl = { version = "0.6", features = ["use_std", "stats"] }

then run:
bash
cargo build

after that the jeprof tool is produced. Now run find_compiled_jeprof.sh in current directory, it will copy the jeprof tool to the current directory.

3. compile jemalloc from source
you can first clone this repo, and checkout to this commit:

bash
git clone https://github.com/tikv/jemalloc.git
cd jemalloc
git checkout e13ca993e8ccb9ba9847cc330696e02839f328f7

then run:
bash
./configure
make

and jeprof is in .bin/ directory. Copy it to the current directory.

---

How To/How To Change Log Level On The Fly

Change Log Level on the Fly

HTTP API

example:

bash
curl --data "trace,flow=debug" 127.0.0.1:4000/debug/log_level

And database will reply with something like:
bash
Log Level changed from Some("info") to "trace,flow=debug"%

The data is a string in the format of global_level,module1=level1,module2=level2,... that follows the same rule of RUST_LOG.

The module is the module name of the log, and the level is the log level. The log level can be one of the following: trace, debug, info, warn, error, off(case insensitive).

Enable/Disable Trace on the Fly

HTTP API

example:

bash
curl --data "true" 127.0.0.1:4000/debug/enable_trace

And database will reply with something like:
text
trace enabled%

Possible values are "true" or "false".

---

How To/How To Implement Sql Statement

This document introduces how to implement SQL statements in GreptimeDB.

The execution entry point for SQL statements locates at Frontend Instance. You can see it has
implemented SqlQueryHandler:

rust
impl SqlQueryHandler for Instance {
async fn do_query(
&self,
query: &str,
query_ctx: QueryContextRef,
) -> Vec<servers::error::Result<Output>> {
// ...
}
}

Normally, when a SQL query arrives at GreptimeDB, the do_query method will be called. After some parsing work, the SQL
will be fed into StatementExecutor:

rust
// in Frontend Instance:
self.statement_executor.execute_sql(stmt, query_ctx).await

That's where we handle our SQL statements. You can just create a new match arm for your statement there, then the
statement is implemented for both GreptimeDB Standalone and Cluster. You can see how DESCRIBE TABLE is implemented as
an example.

Now, what if the statements should be handled differently for GreptimeDB Standalone and Cluster? You can see there's
a SqlStatementExecutor field in StatementExecutor. Each GreptimeDB Standalone and Cluster has its own implementation
of SqlStatementExecutor. If you are going to implement the statements differently in the two modes (
like CREATE TABLE), you have to implement them in their own SqlStatementExecutors.

Summarize as the diagram below:

text
/ Detailed source-code truncated for AI context efficiency. /

Note that some SQL statements can be executed in our QueryEngine, in the form of LogicalPlan. You can follow the
invocation path down to the QueryEngine implementation from StatementExecutor::plan_exec. For now, there's only
one DatafusionQueryEngine for both GreptimeDB Standalone and Cluster. That lone query engine works for both modes is
because GreptimeDB read/write data through Table trait, and each mode has its own Table implementation.

We don't have any bias towards whether statements should be handled in query engine or StatementExecutor. You can
implement one kind of statement in both places. For example, Insert with selection is handled in query engine, because
we can easily do the query part there. However, Insert without selection is not, for the cost of parsing statement
to LogicalPlan is not neglectable. So generally if the SQL query is simple enough, you can handle it
in StatementExecutor; otherwise if it is complex or has some part of selection, it should be parsed to LogicalPlan
and handled in query engine.

---

How To/How To Profile Cpu

Profiling CPU

HTTP API


Sample at 99 Hertz, for 5 seconds, output report in protobuf format.
bash
curl -X POST -s '0:4000/debug/prof/cpu' > /tmp/pprof.out

Then you can use pprof command with the protobuf file.

bash
go tool pprof -top /tmp/pprof.out

Sample at 99 Hertz, for 60 seconds, output report in flamegraph format.

bash
curl -X POST -s '0:4000/debug/prof/cpu?seconds=60&output=flamegraph' > /tmp/pprof.svg

Sample at 49 Hertz, for 10 seconds, output report in text format.

bash
curl -X POST -s '0:4000/debug/prof/cpu?seconds=10&frequency=49&output=text' > /tmp/pprof.txt

---

How To/How To Profile Memory

Profile memory usage of GreptimeDB

This crate provides an easy approach to dump memory profiling info. A set of ready to use scripts is provided in docs/how-to/memory-profile-scripts.

Prerequisites


jemalloc


jeprof is already compiled in the target directory of GreptimeDB. You can find the binary and use it.
text

find jeprof binary


find . -name 'jeprof'

add executable permission


chmod +x <path_to_jeprof>

The path is usually under ./target/${PROFILE}/build/tikv-jemalloc-sys-${HASH}/out/build/bin/jeprof.
The default version of jemalloc installed from the package manager may not have the --collapsed option.
You may need to check the whether the jeprof version is >= 5.3.0 if you want to install it from the package manager.
bash

for macOS


brew install jemalloc

for Ubuntu


sudo apt install libjemalloc-dev

flamegraph

bash
curl https://raw.githubusercontent.com/brendangregg/FlameGraph/master/flamegraph.pl > ./flamegraph.pl

Profiling

Enable memory profiling for greptimedb binary

Start GreptimeDB instance with environment variables:

bash

for Linux


MALLOC_CONF=prof:true ./target/debug/greptime standalone start

for macOS


_RJEM_MALLOC_CONF=prof:true ./target/debug/greptime standalone start

Memory profiling for greptimedb docker image

We have memory profiling enabled and activated by default in our official docker
image.

This behavior is controlled by configuration enable_heap_profiling:

toml
[memory]

Whether to enable heap profiling activation during startup.


Default is true.


enable_heap_profiling = true

To disable memory profiling, set enable_heap_profiling to false.

Memory profiling control

You can control heap profiling activation using the new HTTP APIs:

bash

Check current profiling status


curl -X GET localhost:4000/debug/prof/mem/status

Activate heap profiling (if not already active)


curl -X POST localhost:4000/debug/prof/mem/activate

Deactivate heap profiling


curl -X POST localhost:4000/debug/prof/mem/deactivate

Activate gdump feature that dumps memory profiling data every time virtual memory usage exceeds previous maximum value.


curl -X POST localhost:4000/debug/prof/mem/gdump -d 'activate=true'

Deactivate gdump.


curl -X POST localhost:4000/debug/prof/mem/gdump -d 'activate=false'

Retrieve current gdump status.


curl -X GET localhost:4000/debug/prof/mem/gdump

Dump memory profiling data

Dump memory profiling data through HTTP API:

bash
curl -X POST localhost:4000/debug/prof/mem > greptime.hprof

or output flamegraph directly


curl -X POST "localhost:4000/debug/prof/mem?output=flamegraph" > greptime.svg

or output pprof format


curl -X POST "localhost:4000/debug/prof/mem?output=proto" > greptime.pprof

You can periodically dump profiling data and compare them to find the delta memory usage.

Symbolicate external dump files

If you have jemalloc heap dump files generated externally (e.g., via MALLOC_CONF="prof:true,prof_prefix:jeprof.out,lg_prof_interval:26" or prof_gdump), you can upload them to a running GreptimeDB instance for symbolication and flamegraph generation:

bash

Upload a jemalloc heap dump file and get a symbolicated flamegraph


curl -X POST --data-binary @/path/to/jeprof.out.12345.0.i0.heap \
localhost:4000/debug/prof/mem/symbol > flamegraph.svg

This is useful when:
- You collected heap dumps from a production environment
- You want to symbolicate dumps on a machine with debug symbols
- You need to analyze dumps generated by jemalloc's automatic dump mechanisms

Analyze profiling data with flamegraph

To create flamegraph according to dumped profiling data:

bash
sudo apt install -y libjemalloc-dev

jeprof <path_to_greptime_binary> <profile_data> --collapse | ./flamegraph.pl > mem-prof.svg

jeprof <path_to_greptime_binary> --base <baseline_prof> <profile_data> --collapse | ./flamegraph.pl > output.svg

---

How To/How To Write Fuzz Tests

How to write fuzz tests

This document introduces how to write fuzz tests in GreptimeDB.

What is a fuzz test


Fuzz test is tool that leverages deterministic random generation to assist in finding bugs. The goal of fuzz tests is to identify inputs generated by the fuzzer that cause system panics, crashes, or unexpected behaviors to occur. And we are using the cargo-fuzz to run our fuzz test targets.

Why we need them


- Find bugs by leveraging random generation
- Integrate with other tests (e.g., e2e)

Resources


All fuzz test-related resources are located in the /tests-fuzz directory.
There are two types of resources: (1) fundamental components and (2) test targets.

Fundamental components


They are located in the /tests-fuzz/src directory. The fundamental components define how to generate SQLs (including dialects for different protocols) and validate execution results (e.g., column attribute validation), etc.

Test targets


They are located in the /tests-fuzz/targets directory, with each file representing an independent fuzz test case. The target utilizes fundamental components to generate SQLs, sends the generated SQLs via specified protocol, and validates the results of SQL execution.

Figure 1 illustrates the fundamental components of the fuzz test provide the ability to generate random SQLs. It utilizes a Random Number Generator (Rng) to generate the Intermediate Representation (IR), then employs a DialectTranslator to produce specified dialects for different protocols. Finally, the fuzz tests send the generated SQL via the specified protocol and verify that the execution results meet expectations.

text
Rng
|
|
v
ExprGenerator
|
|
v
Intermediate representation (IR)
|
|
+----------------------+----------------------+
| | |
v v v
MySQLTranslator PostgreSQLTranslator OtherDialectTranslator
| | |
| | |
v v v
SQL(MySQL Dialect) ..... .....
|
|
v
Fuzz Test

(Figure1: Overview of fuzz tests)

For more details about fuzz targets and fundamental components, please refer to this tracking issue.

How to add a fuzz test target

1. Create an empty rust source file under the /tests-fuzz/targets/<fuzz-target>.rs directory.

2. Register the fuzz test target in the /tests-fuzz/Cargo.toml file.

toml
[[bin]]
name = "<fuzz-target>"
path = "targets/<fuzz-target>.rs"
test = false
bench = false
doc = false

3. Define the FuzzInput in the /tests-fuzz/targets/<fuzz-target>.rs.

rust
#![no_main]
use libfuzzer_sys::arbitrary::{Arbitrary, Unstructured};

#[derive(Clone, Debug)]
struct FuzzInput {
seed: u64,
}

impl Arbitrary<'_> for FuzzInput {
fn arbitrary(u: &mut Unstructured<'_>) -> arbitrary::Result<Self> {
let seed = u.int_in_range(u64::MIN..=u64::MAX)?;
Ok(FuzzInput { seed })
}
}

4. Write your first fuzz test target in the /tests-fuzz/targets/<fuzz-target>.rs.

text
/ Detailed source-code truncated for AI context efficiency. /

5. Run your fuzz test target

bash
cargo fuzz run <fuzz-target> --fuzz-dir tests-fuzz

For more details, please refer to this document.

---

How To/Migrate Local Sql File Access

Migrate Local SQL File Access

SQL access to local files is sandboxed in standalone deployments and disabled in
distributed deployments.

Standalone

The default sandbox is <storage.data_home>/copy. Relative paths in COPY and
external-table locations are resolved below this directory. Absolute paths work
only when they are inside the sandbox.

Before upgrading, identify existing COPY workflows and external tables that
use local paths outside the default sandbox. Choose one of these migrations:

- Move the files below <storage.data_home>/copy and update the SQL locations.
- Set storage.copy_root to a dedicated local directory containing the files.
- Move the files to S3, OSS, GCS, or AzBlob and update the SQL locations.

Do not set storage.copy_root to storage.data_home or to a directory that
contains GreptimeDB data, WAL, manifests, or configuration files. GreptimeDB
rejects copy roots that expose its internal data directory.

When storage.data_home is an object-storage URL, local SQL file access is
disabled unless storage.copy_root explicitly names a local directory.

Distributed

Distributed frontend and datanode processes reject local paths for COPY TABLE,
COPY QUERY, COPY DATABASE, and external tables. Migrate these workflows and
tables to S3, OSS, GCS, or AzBlob before upgrading.

---

Rfcs/2022 12 20 Promql In Rust/Rfc

---
Feature Name: "promql-in-rust"
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/596
Date: 2022-12-20
Author: "Ruihang Xia <[email protected]>"
---

Rewrite PromQL in Rust
----------------------

Summary


A Rust native implementation of PromQL, for GreptimeDB.

Motivation


Prometheus and its query language PromQL prevails in the cloud-native observability area, which is an important scenario for time series database like GreptimeDB. We already have support for its remote read and write protocols. Users can now integrate GreptimeDB as the storage backend to existing Prometheus deployment, but cannot run PromQL query directly on GreptimeDB like SQL.

This RFC proposes to add support for PromQL. Because it was created in Go, we can't use the existing code easily. For interoperability, performance and extendability, porting its logic to Rust is a good choice.

Details

Overview


One of the goals is to make use of our existing basic operators, execution model and runtime to reduce the work. So the entire proposal is built on top of Apache Arrow DataFusion. The rewrote PromQL logic is manifested as Expr or Execution Plan in DataFusion. And both the intermediate data structure and the result is in the format of Arrow's RecordBatch.

The following sections are organized in a top-down manner. Starts with evaluation procedure. Then introduces the building blocks of our new PromQL operation. Follows by an explanation of data model. And end with an example logic plan.

This RFC is heavily related to Prometheus and PromQL. It won't repeat some basic concepts of them.

Evaluation

The original implementation is like an interpreter of parsed PromQL AST. It has two characteristics: (1) Operations are evaluated in place after they are parsed to AST. And some key parameters are separated from the AST because they do not present in the query, but come from other places like another field in the HTTP payload. (2) calculation is performed per timestamp. You can see this pattern many times:

go
for ts := ev.startTimestamp; ts <= ev.endTimestamp; ts += ev.interval {}

These bring out two differences in the proposed implementation. First, to make it more general and clear, the evaluation procedure is reorganized into serval phases (and is the same as DataFusion's). And second, data are evaluated by time series (corresponding to "columnar calculation", if think timestamp as row number).

text
Logic
Query AST Plan
─────────► Parser ───────► Logical ────────► Physical ────┐
Planner Planner │

◄───────────────────────────── Executor ◄────────────────┘
Evaluation Result Execution
Plan

- Parser

Provided by promql-parser crate. Same as the original implementation.

- Logical Planner

Generates a logical plan with all the needed parameters. It should accept something like EvalStmt in Go's implementation, which contains query time range, evaluation interval and lookback range.

Another important thing done here is assembling the logic plan, with all the operations baked into logically. Like what's the filter and time range to read, how the data then flows through a selector into a binary operation, etc. Or what's the output schema of every single step. The generated logic plan is deterministic without variables, and can be EXPLAINed clearly.

- Physical Planner

This step converts a logic plan into evaluatable execution plan. There are not many special things like the previous step. Except when a query is going to be executed distributedly. In this case, a logic plan will be divided into serval parts and sent to serval nodes. One physical planner only sees its own part.

- Executor

As its name shows, this step calculates data to result. And all new calculation logic, the implementation of PromQL in rust, is placed here. And the rewrote functions are using RecordBatch and Array from Arrow as the intermediate data structure.

Each "batch" contains only data from single time series. This is from the underlying storage implementation. Though it's not a requirement of this RFC, having this property can simplify some functions.

Another thing to mention is the rewrote functions don't aware of timestamp or value columns, they are defined only based on the input data types. For example, increase() function in PromQL calculates the unbiased delta of data, its implementation here only does this single thing. Let's compare the signature of two implementations:

- Go

go
func funcIncrease(vals []parser.Value, args parser.Expressions) Vector {}

- Rust
rust
fn prom_increase(input: Array) -> Array {}

Some unimportant parameters are omitted. The original Go version only writes the logic for Point's value, either float or histogram. But the proposed rewritten one accepts a generic Array as input, which can be any type that suits, from i8 to u64 to TimestampNanosecond.

Plan and Expression

They are structures to express logic from PromQL. The proposed implementation is built on top of DataFusion, thus our plan and expression are in form of ExtensionPlan and ScalarUDF. The only difference between them in this context is the return type: plan returns a record batch while expression returns a single column.

This RFC proposes to add four new plans, they are fundamental building blocks that mainly handle data selection logic in PromQL, for the following calculation expressions.

- SeriesNormalize

Sort data inside one series on the timestamp column, and bias "offset" if has. This plan usually comes after TableScan (or TableScan and Filter) plan.

- VectorManipulator and MatrixManipulator

Corresponding to InstantSelector and RangeSelector. We don't calculate timestamp by timestamp, thus use "vector" instead of "instant", this image shows the difference. And "matrix" is another name for "range vector", for not confused with our "vector". The following section will detail how they are implemented using Arrow.

Due to "interval" parameter in PromQL, data after "selector" (or "manipulator" here) are usually shorter than input. And we have to modify the entire record batch to shorten both timestamp, value and tag columns. So they are formed as plan.

- PromAggregator

The carrier of aggregator expressions. This should not be very different from the DataFusion built-in Aggregate plan, except PromQL can use "group without" to do reverse selection.

PromQL has around 70 expressions and functions. But luckily we can reuse lots of them from DataFusion. Like unary expression, binary expression and aggregator. We only need to implement those PromQL-specific expressions, like rate or percentile. The following table lists some typical functions in PromQL, and their signature in the proposed implementation. Other function should be the same.

| Name | In Param(s) | Out Param(s) | Explain |
|-------------------- |------------------------------------------------------ |-------------- |-------------------- |
| instant_delta | Matrix T | Array T | idelta in PromQL |
| increase | Matrix T | Array T | increase in PromQL |
| extrapolate_factor | - Matrix T<br>- Array Timestamp<br>- Array Timestamp | Array T | * |

: extrapolate_factor is one of the "dark sides" in PromQL. In short it's a translation of this paragraph*

To reuse those common calculation logic, we can break them into serval expressions, and assemble in the logic planning phase. Like rate() in PromQL can be represented as increase / extrapolate_factor.

Data Model

This part explains how data is represented. Following the data model in GreptimeDB, all the data are stored as table, with tag columns, timestamp column and value column. Table to record batch is very straightforward. So an instant vector can be thought of as a row (though as said before, we don't use instant vectors) in the table. Given four basic types in PromQL: scalar, string, instant vector and range vector, only the last "range vector" need some tricks to adapt our columnar calculation.

Range vector is some sort of matrix, it's consisted of small one-dimension vectors, with each being an input of range function. And, applying range function to a range vector can be thought of kind of convolution.

(Left is an illustration of range vector. Notice the Y-axis has no meaning, it's just put different pieces separately. The right side is an imagined "matrix" as range function. Multiplying the left side to it can get a one-dimension "matrix" with four elements. That's the evaluation result of a range vector.)

To adapt this range vector to record batch, it should be represented by a column. This RFC proposes to use DictionaryArray from Arrow to represent range vector, or Matrix. This is "misusing" DictionaryArray to ship some additional information about an array. Because the range vector is sliding over one series, we only need to know the offset and length of each slides to reconstruct the matrix from an array:

The length is not fixed, it depends on the input's timestamp. An PoC implementation of Matrix and increase() can be found in this repo.

Example

The logic plan of PromQL query

promql

start: 2022-12-20T10:00:00


end: 2022-12-21T10:00:00


interval: 1m


lookback: 30s


sum (rate(request_duration[5m])) by (idc)

looks like

Drawbacks

Human-being is always error-prone. It's harder to endeavor to rewrite from the ground and requires more attention to ensure correctness, than translate line-by-line. And, since the evaluator's architecture are different, it might be painful to catch up with PromQL's breaking update (if any) in the future.

Misusing Arrow's DictionaryVector as Matrix is another point. This hack needs some unsafe function call to bypass Arrow's check. And though Arrow's API is stable, this is still an undocumented behavior.

Alternatives

There are a few alternatives we've considered:
- Wrap the existing PromQL's implementation via FFI, and import it to GreptimeDB.
- Translate its evaluator engine line-by-line, rather than rewrite one.
- Integrate the Prometheus server into GreptimeDB via RPC, making it a detached execution engine for PromQL.

The first and second options are making a separate execution engine in GreptimeDB, they may alleviate the pain during rewriting, but will have negative impacts to afterward evolve like resource management. And introduce another deploy component in the last option will bring a complex deploy architecture.

And all of them are more or less redundant in data transportation that affects performance and resources. The proposed built-in executing procedure is also easy to integrate and expose to the existing SQL interface GreptimeDB currently provides. Some concepts in PromQL like sliding windows (range vector in PromQL) are very convenient and ergonomic in analyzing series data. This makes it not only a PromQL evaluator, but also an enhancement to our query system.

---

Rfcs/2023 03 29 File External Table/Rfc

---
Feature Name: "File external table"
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/1041
Date: 2023-03-08
Author: "Xu Wenkang <[email protected]>"
---

File external table

---

Summary

Allows users to perform SQL queries on files

Motivation

User data may already exist in other storages, i.g., file systems/s3, etc. in CSV, parquet, JSON format, etc. We can provide users the ability to perform SQL queries on these files.


Details

Overview

The file external table providers users ability to perform SQL queries on these files.

For example, a user has a CSV file on the local file system /var/data/city.csv:

text
Rank , Name , State , 2023 Population , 2020 Census , Annual Change , Density (mi²)
1 , New York City , New York , 8,992,908 , 8,804,190 , 0.7% , 29,938
2 , Los Angeles , California , 3,930,586 , 3,898,747 , 0.27% , 8,382
3 , Chicago , Illinois , 2,761,625 , 2,746,388 , 0.18% , 12,146
.....

Then user can create a file external table with:

sql
CREATE EXTERNAL TABLE city with(location='/var/data/city.csv', format="CSV", field_delimiter = ',', record_delimiter = '\n', skip_header = 1);

Then query the external table with:

bash
MySQL> select * from city;

| Rank | Name | State | 2023 Population | 2020 Census | Annual Change | Density (mi²) |
| :--- | :------------ | :--------- | :-------------- | :---------- | :------------ | :------------ |
| 1 | New York City | New York | 8,992,908 | 8,804,190 | 0.7% | 29,938 |
| 2 | Los Angeles | California | 3,930,586 | 3,898,747 | 0.27% | 8,382 |
| 3 | Chicago | Illinois | 2,761,625 | 2,746,388 | 0.18% | 12,146 |

Drop the external table, if needs with:

sql
DROP EXTERNAL TABLE city


Syntax

text
CREATE EXTERNAL [<database>.]<table_name>
[
(
<col_name> <col_type> [NULL | NOT NULL] [COMMENT "<comment>"]
)
]
[ WITH
(
LOCATION = 'url'
[,FIELD_DELIMITER = 'delimiter' ]
[,RECORD_DELIMITER = 'delimiter' ]
[,SKIP_HEADER = '<number>' ]
[,FORMAT = { csv | json | parquet } ]
[,PATTERN = '<regex_pattern>' ]
[,ENDPOINT = '<uri>' ]
[,ACCESS_KEY_ID = '<key_id>' ]
[,SECRET_ACCESS_KEY = '<access_key>' ]
[,SESSION_TOKEN = '<token>' ]
[,REGION = '<region>' ]
[,ENABLE_VIRTUAL_HOST_STYLE = '<boolean>']
..
)
]

Supported File Format

The external file table supports multiple formats; We divide formats into row format and columnar format.

Row formats:

- CSV, JSON

Columnar formats:

- Parquet

Some of these formats support filter pushdown, and others don't. If users use very large files, that format doesn't support pushdown, which might consume a lot of IO for scanning full files and cause a long running query.

File Table Engine

We implement a file table engine that creates an external table by accepting user-specified file paths and treating all records as immutable.

1. File Format Decoder: decode files to the RecordBatch stream.
2. File Table Engine: implement the TableProvider trait, store necessary metadata in memory, and provide scan ability.

Our implementation is better for small files. For large files(i.g., a GB-level CSV file), suggests our users import data to the database.

Drawbacks

- Some formats don't support filter pushdown
- Hard to support indexing

Life cycle

Register a table

1. Write metadata to manifest.
2. Create the table via file table engine.
3. Register table to CatalogProvider and register table to SystemCatalog(persist tables to disk).

Deregister a table (Drop a table)

1. Fetch the target table info (figure out table engine type).
2. Deregister the target table in CatalogProvider and SystemCatalog.
3. Find the target table engine.
4. Drop the target table.

Recover a table when restarting

1. Collect tables name and engine type info.
2. Find the target tables in different engines.
3. Open and register tables.

Alternatives

Using DataFusion API

We can use datafusion API to register a file table:

rust
let ctx = SessionContext::new();

ctx.register_csv("example", "tests/data/example.csv", CsvReadOptions::new()).await?;

// create a plan
let df = ctx.sql("SELECT a, MIN(b) FROM example WHERE a <= b GROUP BY a LIMIT 100").await?;

Drawbacks

The DataFusion implements its own Object Store abstraction and supports parsing the partitioned directories, which can push down the filter and skips some directories. However, this makes it impossible to use our's LruCacheLayer(The parsing of the partitioned directories required paths as input). If we want to manage memory entirely, we should implement our own TableProvider or Table.

- Impossible to use CacheLayer

Introduce an intermediate representation layer

We convert all files into parquet as an intermediate representation. Then we only need to implement a parquet file table engine, and we already have a similar one. Also, it supports limited filter pushdown via the parquet row group stats.

Drawbacks

- Computing overhead
- Storage overhead

---

Rfcs/2024 02 21 Multi Dimension Partition Rule/Rfc

---
Feature Name: Multi-dimension Partition Rule
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/3351
Date: 2024-02-21
Author: "Ruihang Xia <[email protected]>"
---

Summary

A new region partition scheme that runs on multiple dimensions of the key space. The partition rule is defined by a set of simple expressions on the partition key columns.

Motivation

The current partition rule is from MySQL's RANGE Partition, which is based on a single dimension. It is sort of a Hilbert Curve and pick several point on the curve to divide the space. It is neither easy to understand how the data get partitioned nor flexible enough to handle complex partitioning requirements.

Considering the future requirements like region repartitioning or autonomous rebalancing, where both workload and partition may change frequently. Here proposes a new region partition scheme that uses a set of simple expressions on the partition key columns to divide the key space.

Details

Partition rule

First, we define a simple expression that can be used to define the partition rule. The simple expression is a binary expression expression on the partition key columns that can be evaluated to a boolean value. The binary operator is limited to comparison operators only, like =, !=, >, >=, <, <=. And the operands are limited either literal value or partition column.

Example of valid simple expressions are $col_A = 10$, $col_A \gt 10 \& col_B \gt 20$ or $col_A \ne 10$.

Those expressions can be used as predicates to divide the key space into different regions. The following example have two partition columns Col A and Col B, and four partitioned regions.

math
\left\{\begin{aligned}

&col_A \le 10 &Region_1 \\
&10 \lt col_A \& col_A \le 20 &Region_2 \\
&20 \lt col_A \space \& \space col_B \lt 100 &Region_3 \\
&20 \lt col_A \space \& \space col_B \ge 100 &Region_4

\end{aligned}\right\}

An advantage of this scheme is that it is easy to understand how the data get partitioned. The above example can be visualized in a 2D space (two partition column is involved in the example).

Here each expression draws a line in the 2D space. Managing data partitioning becomes a matter of drawing lines in the key space.

To make it easy to use, there is a "default region" which catches all the data that doesn't match any of previous expressions. The default region exist by default and do not need to specify. It is also possible to remove this default region if the DB finds it is not necessary.

SQL interface

The SQL interface is in response to two parts: specifying the partition columns and the partition rule. Thouth we are targeting an autonomous system, it's still allowed to give some bootstrap rules or hints on creating table.

Partition column is specified by PARTITION ON COLUMNS sub-clause in CREATE TABLE:

sql
CREATE TABLE t (...)
PARTITION ON COLUMNS (...) ();

Two following brackets are for partition columns and partition rule respectively.

Columns provided here are only used as an allow-list of how the partition rule can be defined. Which means (a) the sequence between columns doesn't matter, (b) the columns provided here are not necessarily being used in the partition rule.

The partition rule part is a list of comma-separated simple expressions. Expressions here are not corresponding to region, as they might be changed by system to fit various workload.

A full example of CREATE TABLE with partition rule is:

sql
CREATE TABLE IF NOT EXISTS demo (
a STRING,
b STRING,
c STRING,
d STRING,
ts TIMESTAMP,
memory DOUBLE,
TIME INDEX (ts),
PRIMARY KEY (a, b, c, d)
)
PARTITION ON COLUMNS (c, b, a) (
a < 10,
10 >= a AND a < 20,
20 >= a AND b < 100,
20 >= a AND b > 100
)

Combine with storage

Examining columns separately suits our columnar storage very well in two aspects.

1. The simple expression can be pushed down to storage and file format, and is likely to hit existing index. Makes pruning operation very efficient.

2. Columns in columnar storage are not tightly coupled like in the traditional row storages, which means we can easily add or remove columns from partition rule without much impact (like a global reshuffle) on data.

The data file itself can be "projected" to the key space as a polyhedron, it is guaranteed that each plane is in parallel with some coordinate planes (in a 2D scenario, this is saying that all the files can be projected to a rectangle). Thus partition or repartition also only need to consider related columns.

An additional limitation is that considering how the index works and how we organize the primary keys at present, the partition columns are limited to be a subset of primary keys for better performance.

Drawbacks

This is a breaking change.

---

Rfcs/2023 01 03 Procedure Framework

---
Feature Name: "procedure-framework"
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/286
Date: 2023-01-03
Author: "Yingwen <[email protected]>"
---

Procedure Framework
----------------------

Summary


A framework for executing operations in a fault-tolerant manner.

Motivation


Some operations in GreptimeDB require multiple steps to implement. For example, creating a table needs:
1. Check whether the table exists
2. Create the table in the table engine
1. Create a region for the table in the storage engine
2. Persist the metadata of the table to the table manifest
3. Add the table to the catalog manager

If the node dies or restarts in the middle of creating a table, it could leave the system in an inconsistent state. The procedure framework, inspired by Apache HBase's ProcedureV2 framework and Apache Accumulo’s FATE framework, aims to provide a unified way to implement multi-step operations that is tolerant to failure.

Details


Overview


The procedure framework consists of the following primary components:
- A Procedure represents an operation or a set of operations to be performed step-by-step
- ProcedureManager, the runtime to run Procedures. It executes the submitted procedures, stores procedures' states to the ProcedureStore and restores procedures from ProcedureStore while the database restarts.
- ProcedureStore is a storage layer for persisting the procedure state


Procedures


The ProcedureManager keeps calling Procedure::execute() until the Procedure is done, so the operation of the Procedure should be idempotent: it needs to be able to undo or replay a partial execution of itself.

rust
trait Procedure {
fn execute(&mut self, ctx: &Context) -> Result<Status>;

fn dump(&self) -> Result<String>;

fn rollback(&self) -> Result<()>;

// other methods...
}

The Status is an enum that has the following variants:

rust
enum Status {
Executing {
persist: bool,
},
Suspended {
subprocedures: Vec<ProcedureWithId>,
persist: bool,
},
Done,
}

A call to execute() can result in the following possibilities:
- Ok(Status::Done): we are done
- Ok(Status::Executing { .. }): there are remaining steps to do
- Ok(Status::Suspend { sub_procedure, .. }): execution is suspended and can be resumed later after the sub-procedure is done.
- Err(e): error occurs during execution and the procedure is unable to proceed anymore.

Users need to assign a unique ProcedureId to the procedure and the procedure can get this id via the Context. The ProcedureId is typically a UUID.

rust
struct Context {
id: ProcedureId,
// other fields ...
}

The ProcedureManager calls Procedure::dump() to serialize the internal state of the procedure and writes to the ProcedureStore. The Status has a field persist to tell the ProcedureManager whether it needs persistence.

Sub-procedures


A procedure may need to create some sub-procedures to process its subtasks. For example, creating a distributed table with multiple regions (partitions) needs to set up the regions in each node, thus the parent procedure should instantiate a sub-procedure for each region. The ProcedureManager makes sure that the parent procedure does not proceed till all sub-procedures are successfully finished.

The procedure can submit sub-procedures to the ProcedureManager by returning Status::Suspended. It needs to assign a procedure id to each procedure manually so it can track the status of the sub-procedures.

rust
struct ProcedureWithId {
id: ProcedureId,
procedure: BoxedProcedure,
}

ProcedureStore


We might need to provide two different ProcedureStore implementations:
- In standalone mode, it stores data on the local disk.
- In distributed mode, it stores data on the meta server or the object store service.

These implementations should share the same storage structure. They store each procedure's state in a unique path based on the procedure id:

text
Sample paths:

/procedures/{PROCEDURE_ID}/000001.step
/procedures/{PROCEDURE_ID}/000002.step
/procedures/{PROCEDURE_ID}/000003.commit

ProcedureStore behaves like a WAL. Before performing each step, the ProcedureManager can write the procedure's current state to the ProcedureStore, which stores the state in the .step file. The 000001 in the path is a monotonic increasing sequence of the step. After the procedure is done, the ProcedureManager puts a .commit file to indicate the procedure is finished (committed).

The ProcedureManager can remove the procedure's files once the procedure is done, but it needs to leave the .commit as the last file to remove in case of failure during removal.

ProcedureManager


ProcedureManager executes procedures submitted to it.

rust
trait ProcedureManager {
fn register_loader(&self, name: &str, loader: BoxedProcedureLoader) -> Result<()>;

async fn submit(&self, procedure: ProcedureWithId) -> Result<()>;
}

It supports the following operations:
- Register a ProcedureLoader by the type name of the Procedure.
- Submit a Procedure to the manager and execute it.

When ProcedureManager starts, it loads procedures from the ProcedureStore and restores the procedures by the ProcedureLoader. The manager stores the type name from Procedure::type_name() with the data from Procedure::dump() in the .step file and uses the type name to find a ProcedureLoader to recover the procedure from its data.

rust
type BoxedProcedureLoader = Box<dyn Fn(&str) -> Result<BoxedProcedure> + Send>;

Rollback


The rollback step is supposed to clean up the resources created during the execute() step. When a procedure has failed, the ProcedureManager puts a rollback file and calls the Procedure::rollback() method.


text
/procedures/{PROCEDURE_ID}/000001.step
/procedures/{PROCEDURE_ID}/000002.rollback

Rollback is complicated to implement so some procedures might not support rollback or only provide a best-efforts approach.

Locking


The ProcedureManager can provide a locking mechanism that gives a procedure read/write access to a database object such as a table so other procedures are unable to modify the same table while the current one is executing.

Drawbacks


The Procedure framework introduces additional complexity and overhead to our database.
- To execute a Procedure, we need to write to the ProcedureStore multiple times, which may slow down the server
- We need to rewrite the logic of creating/dropping/altering a table using the procedure framework

Alternatives


Another approach is to tolerate failure during execution and allow users to retry the operation until it succeeds. But we still need to:
- Make each step idempotent
- Record the status in some place to check whether we are done

---

Rfcs/2023 02 01 Table Compaction

---
Feature Name: "table-compaction"
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/930
Date: 2023-02-01
Author: "Lei, HUANG <[email protected]>"
---

Table Compaction

---

Background

GreptimeDB uses an LSM-tree based storage engine that flushes memtables to SSTs for persistence.
But currently it only supports level 0. SST files in level 0 does not guarantee to contain only rows with disjoint time ranges.
That is to say, different SST files in level 0 may contain overlapped timestamps.
The consequence is, in order to retrieve rows in some time range, all files need to be scanned, which brings a lot of IO overhead.

Also, just like other LSMT engines, delete/update to existing primary keys are converted to new rows with delete/update mark and appended to SSTs on flushing.
We need to merge the operations to same primary keys so that we don't have to go through all SST files to find the final state of these primary keys.

Goal

Implement a compaction framework to:
- maintain SSTs in timestamp order to accelerate queries with timestamp condition;
- merge rows with same primary key;
- purge expired SSTs;
- accommodate other tasks like data rollup/indexing.


Overview

Table compaction involves following components:
- Compaction scheduler: run compaction tasks, limit the consumed resources;
- Compaction strategy: find the SSTs to compact and determine the output files of compaction.
- Compaction task: read the rows from input SSTs and write to the output files.

Implementation

Compaction scheduler

CompactionScheduler is an executor that continuously polls and executes compaction request from a task queue.

rust
#[async_trait]
pub trait CompactionScheduler {
/// Schedules a compaction task.
async fn schedule(&self, task: CompactionRequest) -> Result<()>;

/// Stops compaction scheduler.
async fn stop(&self) -> Result<()>;
}

Compaction triggering

Currently, we can check whether to compact tables when memtable is flushed to SST.

https://github.com/GreptimeTeam/greptimedb/blob/4015dd80752e1e6aaa3d7cacc3203cb67ed9be6d/src/storage/src/flush.rs#L245


Compaction strategy

CompactionStrategy defines how to pick SSTs in all levels for compaction.

rust
pub trait CompactionStrategy {
fn pick(
&self,
ctx: CompactionContext,
levels: &LevelMetas,
) -> Result<CompactionTask>;
}

The most suitable compaction strategy for time-series scenario would be
a hybrid strategy that combines time window compaction with size-tired compaction, just like Cassandra and ScyllaDB does.

We can first group SSTs in level n into buckets according to some predefined time window. Within that window,
SSTs are compacted in a size-tired manner (find SSTs with similar size and compact them to level n+1).
SSTs from different time windows are neven compacted together.
That strategy guarantees SSTs in each level are mainly sorted in timestamp order which boosts queries with
explicit timestamp condition, while size-tired compaction minimizes the impact to foreground writes.

Alternatives

Currently, GreptimeDB's storage engine only support two levels.
For level 0, we can start with a simple time-window based leveled compaction, which reads from all SSTs in level 0,
align them to time windows with a fixed duration, merge them with SSTs in level 1 within the same time window
to ensure there is only one sorted run in level 1.

---

Rfcs/2023 03 08 Region Fault Tolerance

---
Feature Name: "Fault Tolerance for Region"
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/1126
Date: 2023-03-08
Author: "Luo Fucong <[email protected]>"
---

Fault Tolerance for Region
----------------------

Summary

This RFC proposes a method to achieve fault tolerance for regions in GreptimeDB's distributed mode. Or, put it in another way, achieving region high availability("HA") for GreptimeDB cluster.

In this RFC, we mainly describe two aspects of region HA: how region availability is detected, and what recovery process is need to be taken. We also discuss some alternatives and future work.

When this feature is done, our users could expect a GreptimeDB cluster that can always handle their requests to regions, despite some requests may failed during the region failover. The optimization to reduce the MTTR(Mean Time To Recovery) is not a concern of this RPC, and is left for future work.

Motivation

Fault tolerance for regions is a critical feature for our clients to use the GreptimeDB cluster confidently. High availability for users to interact with their stored data is a "must have" for any TSDB products, that include our GreptimeDB cluster.

Details

Background

Some backgrounds about region in distributed mode:

- A table is logically split into multiple regions. Each region stores a part of non-overlapping table data.
- Regions are distributed in Datanodes, the mappings are not static, are assigned and governed by Metasrv.
- In distributed mode, client requests are scoped in regions. To be more specific, when a request that needs to scan multiple regions arrived in Frontend, Frontend splits the request into multiple sub-requests, each of which scans one region only, and submits them to Datanodes that hold corresponding regions.

In conclusion, as long as regions remain available, and regions could regain availability when failures do occur, the overall region HA could be achieved. With this in mind, let's see how region failures are detected first.

Failure Detection

We detect region failures in Metasrv, and do it both passively and actively. Passively means that Metasrv do not fire some "are you healthy" requests to regions. Instead, we carry region healthy information in the heartbeat requests that are submit to Metasrv by Datanodes.

Datanode already carries its regions stats in the heartbeat request (the non-relevant fields are omitted):

protobuf
message HeartbeatRequest {
...
// Region stats on this node
repeated RegionStat region_stats = 6;
...
}

message RegionStat {
uint64 region_id = 1;
TableName table_name = 2;
...
}

For the sake of simplicity, we don't add another field bool available = 3 to the RegionStat message; instead, if the region were unavailable in the view of the Datanode that contains it, the Datanode just not includes the RegionStat of it in the heartbeat request. Or, if the Datanode itself is not unavailable, the heartbeat request is not submitted, effectively the same with not carrying the RegionStat.

The heartbeat interval is now hardcoded to five seconds.

Metasrv gathers the heartbeat requests, extracts the RegionStats, and treat them as region heartbeat. In this way, Metasrv maintains all regions healthy information. If some region's heartbeats were not received in a period of time, Metasrv speculates the region might be unavailable. To make the decision whether a region is failed or not, Metasrv uses a failure detection algorithm called the "Phi φ Accrual Failure Detection". Basically, the algorithm calculates a value called "phi" to represent the possibility of a region's unavailability, based on the historical heartbeats' arrived rate. Once the "phi" is above some pre-defined threshold, Metasrv knows the region is failed.

This algorithm has been widely adopted in some well known products, like Akka and Cassandra.

When Metasrv decides some region is failed from heartbeats, it's not the final decision. Here comes the "actively" detection. Before Metasrv decides to do region failover, it actively invokes the healthy check interface of the Datanode that the failure region resides. Only this healthy check is failed does Metasrv actually start doing failover upon the region.

To conclude, the failure detection pseudo-codes are like this:

rust
// in Metasrv:
fn failure_detection() {
loop {
// passive detection
let failed_regions = all_regions.iter().filter(|r| r.estimated_failure_possibility() > config.phi).collect();

// find the datanodes that contains the failed regions
let datanodes_and_regions = find_region_resides_datanodes(failed_regions);

// active detection
for (datanode, regions) in datanodes_and_regions {
if !datanode.is_healthy(regions) {
do_failover(datanode, regions);
}
}

sleep(config.detect_interval);
}
}

Some design considerations:

- Why active detecting while we have passively detection? Because it could be happened that the network is singly connectable sometimes (especially in the complex Cloud environment), then the Datanode's heartbeats cannot reach Metasrv, while Metasrv could request Datanode. Active detecting avoid this false positive situation.
- Why the detection works on region instead of Datanode? Because we might face the possibility that only part of the regions in the Datanode are not available, not ALL regions. Especially the situation that Datanodes are used by multiple tenants. If this is the case, it's better to do failover upon the designated regions instead of the whole regions that reside on the Datanode. All in all, we want a more subtle control over region failover.

So we detect some regions are not available. How to regain the availability back?

Region Failover

Region Failover largely relies on remote WAL, aka "Bunshin". I'm not including any of the details of it in this RFC, let's just assume we already have it.

In general, region failover is fairly simple. Once Metasrv decides to do failover upon some regions, it first chooses one or more Datanodes to hold the failed region. This can be done easily, as the Metasrv already has the whole picture of Datanodes: it knows which Datanode has the minimum regions, what Datanode historically had the lowest CPU usage and IO rate, and how the Datanodes are assigned to tenants, among other information that can all help the Metasrv choose the most suitable Datanodes. Let's call these chosen Datanodes as "candidates".

The strategy to choose the most suitable candidates required careful design, but it's another RFC.

Then, Metasrv sets the states of these failed regions as "passive". We should add a field to Region:

protobuf
message Region {
uint64 id = 1;
string name = 2;
Partition partition = 3;

message State {
Active,
Passive,
}
State state = 4;

map<string, string> attrs = 100;
}

Here Region is used in message RegionRoute, which indicates how the write request is split among regions. When a region is set as "passive", Frontend knows the write to it should be rejected at the moment (the region read is not blocked, however).

Making a region "passive" here is effectively blocking the write to it. It's ok in the failover situation, the region is failed anyway. However, when dealing with active maintenance operations, region state requires more refined design. But that's another story.

Third, Metasrv fires the "close region" requests to the failed Datanodes, and fires the "open region" requests to those candidates. "Close region" requests might be failed due to the unavailability of Datanodes, but that's fine, it's just a best-effort attempt to reduce the chance of any in-flight writes got handled unintentionally after the region is set as "passive". The "open region" requests must have succeeded though. Datanodes open regions from remote WAL.

Currently the "close region" is undefined in Datanode. It could be a local cache clean up of region data or other resources tidy up.

Finally, when a candidate successfully opens its region, it calls back to Metasrv, indicating it is ready to handle region. "call back" here is backed by its heartbeat to Metasrv. Metasrv updates the region's state to "active", so as to let Frontend lifts the restrictions of region writes (again, the read part of region is untouched).

All the above steps should be managed by remote procedure framework. It's another implementation challenge in the region failover feature. (One is the remote WAL of course.)

A picture is worth a 1000 words:

text
/ Detailed source-code truncated for AI context efficiency. /

Alternatives

The "Neon" Way

Remote WAL raises a problem that could harm the write throughput of GreptimeDB cluster: each write request has to do at least two remote call, one is from Frontend to Datanode, and one is from Datanode to remote WAL. What if we do it the "Neon" way, making remote WAL sits in between the Frontend and Datanode, couldn't that improve our write throughput? It could, though there're some consistency issues like "read-your-writes" to solve.

However, the main concerns we don't adopt this method are two-fold:

1. Remote WAL is planned to be quorum based, it can be efficiently written;
2. More importantly, we are planning to make the remote WAL an option that users could choose not to enable it (at the cost of some reliability reduction).

No WAL, Replication instead

This method replicates region across Datanodes directly, like the common way in shared-nothing database. Were the main region failed, a standby region in the replicate group is elected as new "main" and take the read/write requests. The main concern to this method is the incompatibility to our current architecture and code structure. It requires a major redesign, but gains no significant advantage over the remote WAL method.

However, the replication does have its own advantage that we can learn from to optimize this failover procedure.

Future Work

Some optimizations we could take:

- To reduce the MTTR, we could make Metasrv chooses the candidate to each region at normal time. The candidate does some preparation works to reduce the open region time, effectively accelerate the failover procedure.
- We can adopt the replication method, to the degree that region replicas are used as the fast catch-up candidates. The data difference among replicas is minor, region failover does not need to load or exchange too much data, greatly reduced the region failover time.

---

Rfcs/2023 05 09 Distributed Planner

---
Feature Name: distributed-planner
Tracking Issue: TBD
Date: 2023-05-09
Author: "Ruihang Xia <[email protected]>"
---

Distributed Planner
-------------------

Summary


Enhance the logical planner with aware of distributed, multi-region table topology. To achieve "push computation down" execution rather than the current "pull data up" manner.

Motivation


Query distributively can leverage the advantage of GreptimeDB's architecture to process large dataset that exceeds the capacity of a single node, or accelerate the query execution by executing it in parallel. This task includes two sub-tasks
- Be able to transform the plan that can push as much as possible computation down to data source.
- Be able to handle pipeline breaker (like Join or Sort) on multiple computation nodes.
This is a relatively complex topic. To keep this RFC concentrated I'll focus on the first one.

Details


Background: Partition and Region


GreptimeDB supports table partitioning, where the partition rule is set during table creation. Each partition can be further divided into one or more physical storage units known as "regions". Both partitions and regions are divided based on rows:
`` text
┌────────────────────────────────────┐
│ │
│ Table │
│ │
└─────┬────────────┬────────────┬────┘
│ │ │
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│ Region 1 │ │ Region 2 │ │ Region 3 │
└──────────┘ └──────────┘ └──────────┘
Row 1~10 Row 11~20 Row 21~30
text
General speaking, region is the minimum element of data distribution, and we can also use it as the unit to distribute computation. This can greatly simplify the routing logic of this distributed planner, by always schedule the computation to the node that currently opening the corresponding region. And is also easy to scale more node for computing since GreptimeDB's data is persisted on shared storage backend like S3. But this is a bit beyond the scope of this specific topic.

Background: Commutativity


Commutativity is an attribute that describes whether two operation can exchange their apply order: $P1(P2(R)) \Leftrightarrow P2(P1(R))$. If the equation keeps, we can transform one expression into another form without changing its result. This is useful on rewriting SQL expression, and is the theoretical basis of this RFC.

Take this SQL as an example

sql
SELECT a FROM t WHERE a > 10;
text
As we know projection and filter are commutative (todo: latex), it can be translated to the following two identical plan trees:
text
┌─────────────┐ ┌─────────────┐
│Projection(a)│ │Filter(a>10) │
└──────▲──────┘ └──────▲──────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│Filter(a>10) │ │Projection(a)│
└──────▲──────┘ └──────▲──────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ TableScan │ │ TableScan │
└─────────────┘ └─────────────┘
text

Merge Operation

This RFC proposes to add a new expression node MergeScan to merge result from several regions in the frontend. It wrap the abstraction of remote data and execution, and expose a TableScan interface to upper level.

text


┌───────┼───────┐
│ │ │
│ ┌──┴──┐ │
│ └──▲──┘ │
│ │ │
│ ┌──┴──┐ │
│ └──▲──┘ │ ┌─────────────────────────────┐
│ │ │ │ │
│ ┌────┴────┐ │ │ ┌──────────┐ ┌───┐ ┌───┐ │
│ │MergeScan◄──┼────┤ │ Region 1 │ │ │ .. │ │ │
│ └─────────┘ │ │ └──────────┘ └───┘ └───┘ │
│ │ │ │
└─Frontend──────┘ └─Remote-Sources──────────────┘
text
This merge operation simply chains all the underlying remote data sources and return RecordBatch, just like a coalesce op. And each remote sources is a gRPC query to datanode via the substrait logical plan interface. The plan is transformed and divided from the original query that comes to frontend.

Commutativity of MergeScan

Obviously, The position of MergeScan is the key of the distributed plan. The more closer to the underlying TableScan, the less computation is taken by datanodes. Thus the goal is to pull the MergeScan up as more as possible. The word "pull up" means exchange MergeScan with its parent node in the plan tree, which means we should check the commutativity between the existing expression nodes and the MergeScan. Here I classify all the possibility into five categories:

- Commutative: $P1(P2(R)) \Leftrightarrow P2(P1(R))$
- filter
- projection
- operations that match the partition key
- Partial Commutative: $P1(P2(R)) \Leftrightarrow P1(P2(P1(R)))$
- $min(R) \rightarrow min(MERGE(min(R)))$
- $max(R) \rightarrow max(MERGE(max(R)))$
- Conditional Commutative: $P1(P2(R)) \Leftrightarrow P3(P2(P1(R)))$
- $count(R) \rightarrow sum(count(R))$
- Transformed Commutative: $P1(P2(R)) \Leftrightarrow P1(P3(R)) \Leftrightarrow P3(P1(R))$
- $avg(R) \rightarrow sum(R)/count(R)$
- Non-commutative
- sort
- join
- percentile

Steps to plan


After establishing the set of commutative relations for all expressions, we can begin transforming the logical plan. There are four steps:

- Add a merge node before table scan
- Evaluate commutativity in a bottom-up way, stop at the first non-commutative node
- Divide the TableScan to scan over partitions
- Execute

First insert the MergeScan on top of the bottom TableScan node. Then examine the commutativity start from the MergeScan node transform the plan tree based on the result. Stop this process on the first non-commutative node.

text
┌─────────────┐ ┌─────────────┐
│ Sort │ │ Sort │
└──────▲──────┘ └──────▲──────┘
│ │
┌─────────────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ Sort │ │Projection(a)│ │ MergeScan │
└──────▲──────┘ └──────▲──────┘ └──────▲──────┘
│ │ │
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
│Projection(a)│ │ MergeScan │ │Projection(a)│
└──────▲──────┘ └──────▲──────┘ └──────▲──────┘
│ │ │
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ TableScan │ │ TableScan │ │ TableScan │
└─────────────┘ └─────────────┘ └─────────────┘
(a) (b) (c)
text
Then in the physical planning phase, convert the sub-tree below MergeScan into a remote query request and dispatch to all the regions. And let the MergeScan to receive the results and feed to it parent node.

To simplify the overall complexity, any error in the procedure will lead to a failure to the entire query, and cancel all other parts.

Alternatives


Spill


If only consider the ability of processing large dataset, we can enable DataFusion's spill ability to temporary persist intermediate data into disk, like the "swap" memory. But this will lead to a super slow performance and very large write amplification.

Future Work


As described in the
Motivation section we can further explore the distributed planner on the physical execution level, by introducing mechanism like Spark's shuffle to improve parallelism and reduce intermediate pipeline breaker's stage.

---

Rfcs/2023 07 06 Table Engine Refactor

---
Feature Name: table-engine-refactor
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/1869
Date: 2023-07-06
Author: "Yingwen <[email protected]>"
---

Refactor Table Engine
----------------------

Summary


Refactor table engines to address several historical tech debts.

Motivation


Both
Frontend and Datanode have to deal with multiple regions in a table. This results in code duplication and additional burden to the Datanode.

Before:

mermaid
graph TB

subgraph Frontend["Frontend"]
subgraph MyTable
A("region 0, 2 -> Datanode0")
B("region 1, 3 -> Datanode1")
end
end

MyTable --> Metasrv
Metasrv --> ETCD

MyTable-->TableEngine0
MyTable-->TableEngine1

subgraph Datanode0
Procedure0("procedure")
TableEngine0("table engine")
region0
region2
mytable0("my_table")

Procedure0-->mytable0
TableEngine0-->mytable0
mytable0-->region0
mytable0-->region2
end


subgraph Datanode1
Procedure1("procedure")
TableEngine1("table engine")
region1
region3
mytable1("my_table")

Procedure1-->mytable1
TableEngine1-->mytable1
mytable1-->region1
mytable1-->region3
end


subgraph manifest["table manifest"]
M0("my_table")
M1("regions: [0, 1, 2, 3]")
end

mytable1-->manifest
mytable0-->manifest

RegionManifest0("region manifest 0")
RegionManifest1("region manifest 1")
RegionManifest2("region manifest 2")
RegionManifest3("region manifest 3")
region0-->RegionManifest0
region1-->RegionManifest1
region2-->RegionManifest2
region3-->RegionManifest3

text
Datanodes can update the same manifest file for a table as regions are assigned to different nodes in the cluster. We also have to run procedures on Datanode to ensure the table manifest is consistent with region manifests. "Table" in a Datanode is a subset of the table's regions. The Datanode is much closer to RegionServer in HBase which only deals with regions.

In cluster mode, we store table metadata in etcd and table manifest. The table manifest becomes redundant. We can remove the table manifest if we refactor the table engines to region engines that only care about regions. What's more, we don't need to run those procedures on Datanode.

After:

mermaid
graph TB

subgraph Frontend["Frontend"]
direction LR
subgraph MyTable
A("region 0, 2 -> Datanode0")
B("region 1, 3 -> Datanode1")
end
end

MyTable --> Metasrv
Metasrv --> ETCD

MyTable-->RegionEngine
MyTable-->RegionEngine1

subgraph Datanode0
RegionEngine("region engine")
region0
region2
RegionEngine-->region0
RegionEngine-->region2
end


subgraph Datanode1
RegionEngine1("region engine")
region1
region3
RegionEngine1-->region1
RegionEngine1-->region3
end

RegionManifest0("region manifest 0")
RegionManifest1("region manifest 1")
RegionManifest2("region manifest 2")
RegionManifest3("region manifest 3")
region0-->RegionManifest0
region1-->RegionManifest1
region2-->RegionManifest2
region3-->RegionManifest3

text
This RFC proposes to refactor table engines into region engines as a first step to make the Datanode acts like a RegionServer.


Details


Overview

We plan to refactor the TableEngine trait into RegionEngine gradually. This RFC focuses on the mito engine as it is the default table engine and the most complicated engine.

Currently, we built MitoEngine upon StorageEngine that manages regions of the mito engine. Since MitoEngine becomes a region engine, we could combine StorageEngine with MitoEngine to simplify our code structure.

The chart below shows the overall architecture of the MitoEngine.

mermaid
classDiagram
class MitoEngine~LogStore~ {
-WorkerGroup workers
}
class MitoRegion {
+VersionControlRef version_control
-RegionId region_id
-String manifest_dir
-AtomicI64 last_flush_millis
+region_id() RegionId
+scan() ChunkReaderImpl
}
class RegionMap {
-HashMap&lt;RegionId, MitoRegionRef&gt; regions
}
class ChunkReaderImpl

class WorkerGroup {
-Vec~RegionWorker~ workers
}
class RegionWorker {
-RegionMap regions
-Sender sender
-JoinHandle handle
}
class RegionWorkerThread~LogStore~ {
-RegionMap regions
-Receiver receiver
-Wal~LogStore~ wal
-ObjectStore object_store
-MemtableBuilderRef memtable_builder
-FlushSchedulerRef~LogStore~ flush_scheduler
-FlushStrategy flush_strategy
-CompactionSchedulerRef~LogStore~ compaction_scheduler
-FilePurgerRef file_purger
}
class Wal~LogStore~ {
-LogStore log_store
}
class MitoConfig

MitoEngine~LogStore~ o-- MitoConfig
MitoEngine~LogStore~ o-- MitoRegion
MitoEngine~LogStore~ o-- WorkerGroup
MitoRegion o-- VersionControl
MitoRegion -- ChunkReaderImpl
WorkerGroup o-- RegionWorker
RegionWorker o-- RegionMap
RegionWorker -- RegionWorkerThread~LogStore~
RegionWorkerThread~LogStore~ o-- RegionMap
RegionWorkerThread~LogStore~ o-- Wal~LogStore~

text
We replace the RegionWriter with RegionWorker to process write requests and DDL requests.


Metadata


We also merge region's metadata with table's metadata. It should make metadata much easier to maintain.

/ Detailed source-code truncated for AI context efficiency. /
text

Drawback


This is a breaking change.

Future Work


- Rename
TableEngine to RegionEngine
- Simplify schema relationship in the
mito engine
- Refactor the
Datanode into a RegionServer.

---

Rfcs/2023 07 10 Metric Engine

---
Feature Name: metric-engine
Tracking Issue: TBD
Date: 2023-07-10
Author: "Ruihang Xia <[email protected]>"
---

Summary

A new metric engine that can significantly enhance our ability to handle the tremendous number of small tables in scenarios like Prometheus metrics, by leveraging a synthetic wide table that offers storage and metadata multiplexing capabilities over the existing engine.

Motivation

The concept "Table" in GreptimeDB is a bit "heavy" compared to other time-series storage like Prometheus or VictoriaMetrics. This has lots of disadvantages in aspects from performance, footprint, and storage to cost.

Details

Top level description

- User Interface

This feature will add a new type of storage engine. It might be available to be an option like with ENGINE=mito or an internal interface like auto create table on Prometheus remote write. From the user side, there is no difference from tables in mito engine. All the DDL like CREATE, ALTER and DML like SELECT should be supported.

- Implementation Overlook

This new engine doesn't re-implement low level components like file R/W etc. It's a wrapper layer over the existing mito engine, with extra storage and metadata multiplexing capabilities. I.e., it expose multiple table based on one mito engine table like this:

plaintext
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Metric Engine │ │ Metric Engine │ │ Metric Engine │
│ Table 1 │ │ Table 2 │ │ Table 3 │
└───────────────┘ └───────────────┘ └───────────────┘
▲ ▲ ▲
│ │ │
└───────────────┼───────────────────┘

┌─────────┴────────┐
│ Metric Region │
│ Engine │
│ ┌─────────────┤
│ │ Mito Region │
│ │ Engine │
└────▲─────────────┘


┌─────┴───────────────┐
│ │
│ Mito Engine Table │
│ │
└─────────────────────┘
text
The following parts will describe these implementation details:
- How to route these metric region tables and how those table are distributed
- How to maintain the schema and other metadata of the underlying mito engine table
- How to maintain the schema of metric engine table
- How the query goes

Routing

Before this change, the region route rule was based on a group of partition keys. Relation of physical table to region is one-to-many.

rust
pub struct PartitionDef {
partition_columns: Vec<String>,
partition_bounds: Vec<PartitionBound>,
}
text
And for metric engine tables, the key difference is we split the concept of "physical table" and "logical table". Like the previous ASCII chart, multiple logical tables are based on one physical table. The relationship of logical table to region becomes many-to-many. Thus, we must include the table name (of logical table) into partition rules.

Consider the partition/route interface is a generic map of string array to region id, all we need to do is to insert logical table name into the request:

rust
fn route(request: Vec<String>) -> RegionId;
text
The next question is, where to do this conversion? The basic idea is to dispatch different routing behavior based on the engine type. Since we have all the necessary information in frontend, it's a good place to do that. And can leave meta server untouched. The essential change is to associate engine type with route rule.

Physical Region Schema

The idea "physical wide table" is to perform column-level multiplexing. I.e., map all logical columns to physical columns by their names.


┌────────────┐ ┌────────────┐ ┌────────────┐
│ Table 1 │ │ Table 2 │ │ Table 3 │
├───┬────┬───┤ ├───┬────┬───┤ ├───┬────┬───┤
│C1 │ C2 │ C3│ │C1 │ C3 │ C5├──────┐ │C2 │ C4 │ C6│
└─┬─┴──┬─┴─┬─┘ ┌────┴───┴──┬─┴───┘ │ └─┬─┴──┬─┴─┬─┘
│ │ │ │ │ │ │ │ │
│ │ │ │ └──────────┐ │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ ┌─────────────────┐ │ │ │ │ │
│ │ │ │ │ Physical Table │ │ │ │ │ │
│ │ │ │ ├──┬──┬──┬──┬──┬──┘ │ │ │ │ │
└────x───x───┴─►│C1│C2│C3│C4│C5│C6◄─┼─x────x────x───┘
│ │ └──┘▲─┘▲─┴─▲└─▲└──┘ │ │ │ │
│ │ │ │ │ │ │ │ │ │
├───x──────────┘ ├───x──x─────┘ │ │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ │ └───────┘ │ │
│ │ │ │
└─────────────────────x───────────────┘ │
│ │
└────────────────────┘
text
This approach is very straightforward but has one problem. It only works when two columns have different semantic type (time index, tag or field) or data types but with the same name. E.g., CREATE TABLE t1 (c1 timestamp(3) TIME INDEX) and CREATE TABLE t2 (c1 STRING PRIMARY KEY).

One possible workaround is to prefix each column with its data type and semantic type, like _STRING_PK_c1. However, considering the primary goal at present is to support data from monitoring metrics like Prometheus remote write, it's acceptable not to support this at first because data types are often simple and limited here.


The next point is changing the physical table's schema. This is only needed when creating a new logical table or altering the existing table. Typically speaking, table creating and altering are explicit. We only need to emit an add column request to underlying physical table on processing logical table's DDL. GreptimeDB can create or alter table automatically on some protocols, but the internal logic is the same.

Also for simplicity, we don't support shrinking the underlying table at first. This can be achieved by introducing mechanism on the physical column.

Frontend needs not to keep physical table's schema.

Metadata of physical regions

Those metric engine regions need to store extra metadata like the schema of logical table or all logical table's name. That information is relatively simple and can be stored in a format like key-value pair. For now, we have to use another physical mito region for metadata. This involves an issue with region scheduling. Since we don't have the ability to perform affinity scheduling, the initial version will just assume the data region and metadata region are in the same instance. See alternatives - other storage for physical region's metadata for possible future improvement.

Here is the schema of metadata region and how we would use it. The CREATE TABLE clause of metadata region looks like the following. Notice that it wouldn't be actually created by SQL.

sql
CREATE TABLE metadata(
ts timestamp time index,
key string primary key,
value string
);
text
The ts field is just a placeholder -- for the constraints that a mito region must contain a time index field. It will always be 0. The other two fields key and value will be used as a k-v storage. It contains two group of key
-
__table_<TABLE_NAME> is used for marking table existence. It doesn't have value.
-
__column_<TABLE_NAME>_<COLUMN_NAME> is used for marking table existence, the value is column's semantic type.

Physical region implementation

This RFC proposes to add a new region implementation named "MetricRegion". As showed in the first chart, it's wrapped over the existing mito region. This section will describe the implementation details. Firstly, here is a chart shows how the region hierarchy looks like:

plaintext
┌───────────────────────┐
│ Metric Region │
│ │
│ ┌────────┬──────────┤
│ │ Mito │ Mito │
│ │ Region │ Region │
│ │ for │ for │
│ │ Data │ Metadata │
└───┴────────┴──────────┘
text
All upper levels only see the Metric Region. E.g., Meta Server schedules on this region, or Frontend routes requests to this Metrics Region's id. To be scheduled (open or close etc.), Metric Region needs to implement its own procedures. Most of those procedures can be simply assembled from underlying Mito Regions', but those related to data like alter or drop will have its own new logic.

Another point is region id. Since the region id is used widely from meta server to persisted state, it's better to keep it unchanged. This means we can't use the same id for two regions, but one for each. To achieve this, this RFC proposes a concept named "region id group". A region id group is a group of region ids that are bound for different purposes. Like the two underlying regions here.

This preserves the first 8 bits of the u32 region number for grouping. Each group has one main id (the first one) and other sub ids (the rest non-zero ids). All components other than the region implementation itself doesn't aware of the existence of region id group. They only see the main id. The region implementation is in response of managing and using the region id group.

plaintext
63 31 23 0
┌────────────────────────────────────┬──────────┬──────────────────┐
│ Table Id(32) │ Group(8) │ Region Number(24)│
└────────────────────────────────────┴──────────┴──────────────────┘
Region Id(32)
text

Routing in meta server

From previous sections, we can conclude the following points about routing:
- Each "logical table" has its own, universe unique table id.
- Logical table doesn't have physical region, they share the same physical region with other logical tables.
- Route rule of logical table's is a strict subset of physical table's.

To associate the logical table with physical region, we need to specify necessary information in the create table request. Specifically, the table type and its parent table. This require to change our gRPC proto's definition. And once meta recognize the table to create is a logical table, it will use the parent table's region to create route entry.

And to reduce the consumption of region failover (which need to update the physical table route info), we'd better to split the current route table structure into two parts:

rust
region_route: Map<TableName, [RegionId]>,
node_route: Map<RegionId, NodeId>,
text
By doing this on each failover the meta server only needs to update the second node_route map and leave the first one untouched.

Query

Like other existing components, a user query always starts in the frontend. In the planning phase, frontend needs to fetch related schemas of the queried table. This part is the same. I.e., changes in this RFC don't affect components above the Table abstraction.

Alternatives

Other routing method

We can also do this "special" route rule in the meta server. But there is no difference with the proposed method.

Other storage for physical region's metadata

Once we have implemented the "region family" that allows multiple physical schemas exist in one region, we can store the metadata and table data into one region.

Before that, we can also let the MetricRegion holds a KvBackend to access the storage layer directly. But this breaks the abstraction in some way.

Drawbacks

Since the physical storage is mixed together. It's hard to do fine-grained operations in table level. Like configuring TTL, memtable size or compaction strategy in table level. Or define different partition rules for different tables. For scenarios like this, it's better to move the table out of metrics engine and "upgrade" it to a normal mito engine table. This requires a migration process in a low cost. And we have to ensure data consistency during the migration, which may require a out-of-service period.

---

Rfcs/2023 08 04 Table Trait Refactor

---
Feature Name: table-trait-refactor
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/2065
Date: 2023-08-04
Author: "Ruihang Xia <[email protected]>"
---

Refactor Table Trait
--------------------

Summary


Refactor
Table trait to adapt the new region server architecture and make code more straightforward.

Motivation


The
Table is designed in the background of both frontend and datanode keeping the same concepts. And all the operations are served by a Table. However, in our practice, we found that not all the operations are suitable to be served by a Table. For example, the Table doesn't hold actual physical data itself, thus operations like write or alter are simply a proxy over underlying regions. And in the recent refactor to datanode (rfc table-engine-refactor), we are changing datanode to region server that is only aware of Region things. This also calls for a refactor to the Table trait.

Details

Definitions

The current Table trait contains the following methods:


/ Detailed source-code truncated for AI context efficiency. /
text
We can divide those methods into three categories from the perspective of functionality:

| Retrieve Metadata | Manipulate Data | Read Data |
| :------------------------: | :-------------: | :--------------: |
|
schema | insert | scan_to_stream |
|
table_info | alter | |
|
table_type | delete | |
|
supports_filter_pushdown | flush | |
|
region_stats | close | |
|
contains_region | compact | |
|
statistics | | |

And considering most of the access to metadata happens in frontend, like route or query; and all the persisted data are stored in regions; while only the query engine needs to read data. We can divide the Table trait into three concepts:

- struct Table provides metadata:

rust
impl Table {
/// Get a reference to the schema for this table
fn schema(&self) -> SchemaRef;

/// Get a reference to the table info.
fn table_info(&self) -> TableInfoRef;

/// Get the type of this table for metadata/catalog purposes.
fn table_type(&self) -> TableType;

/// Get statistics for this table, if available
fn statistics(&self) -> Option<TableStatistics>;

fn to_data_source(&self) -> DataSourceRef;
}

text
- Requests to region server
-
InsertRequest
-
AlterRequest
-
DeleteRequest
-
FlushRequest
-
CompactRequest
-
CloseRequest

- trait DataSource provides data (RecordBatch)

rust
trait DataSource {
fn get_stream(&self, request: ScanRequest) -> Result<SendableRecordBatchStream>;
}
text

Use Table

Table will only be used in frontend. It's constructed from the OpenTableRequest or CreateTableRequest.

Table also provides a method to_data_source to generate a DataSource from itself. But this method is only for non-TableType::Base tables (i.e., TableType::View and TableType::Temporary) because TableType::Base table doesn't hold actual data itself. Its DataSource should be constructed from the Region directly (in other words, it's a remote query).

And it requires some extra information to construct a DataSource, named TableSourceProvider:

rust
type TableFactory = Arc<dyn Fn() -> DataSourceRef>;

pub enum TableSourceProvider {
Base,
View(LogicalPlan),
Temporary(TableFactory),
}

text

Use DataSource

DataSource will be adapted to the TableProvider from DataFusion that can be scan()ed in a TableScan plan.

In frontend this is done in the planning phase. And datanode will have one implementation for Region to generate record batch stream.

Interact with RegionServer

Previously, persisted state change operations were through the old Table trait, like said before. Now they will come from the action source, like the procedure or protocol handler directly to the region server. E.g., on alter table, the corresponding procedure will generate its AlterRequest and send it to regions. Or write request will be split in frontend handler, and sent to regions. Table only provides necessary metadata like route information if needed, but not the necessary part anymore.

Implement temporary table

Temporary table is a special table that doesn't revolves to any persistent physical region. Examples are:
- the
Numbers table for testing, which produces a record batch that contains 0-100 integers.
- tables in information schema. It is an interface for querying catalog's metadata. The contents are generated on the fly with information from
CatalogManager. The CatalogManager can be held in TableFactory.
- Function table that produces data generated by a formula or a function. Like something that always
sin(current_timestamp()).

Relationship among those components

Here is a diagram to show the relationship among those components, and how they interact with each other.

mermaid
erDiagram
CatalogManager ||--|{ Table : manages
Table ||--|{ DataStream : generates
Table ||--|{ Region : routes
Region ||--|{ DataStream : implements
DataStream }|..|| QueryEngine : adapts-to
Procedure ||--|{ Region : requests
Protocol ||--|{ Region : writes
Protocol ||--|{ QueryEngine : queries
text

Drawback


This is a breaking change.

---

Rfcs/2023 08 13 Metadata Txn

---
Feature Name: Update Metadata in single transaction
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/1715
Date: 2023-08-13
Author: "Feng Yangsen <[email protected]>, Xu Wenkang <[email protected]>"
---

Summary


Update Metadata in single transaction.

Motivation


Currently, multiple transactions are involved during the procedure. This implementation is inefficient, and it's hard to make data consistent. Therefore, We can update multiple metadata in a single transaction.

Details


Now we have the following table metadata keys:

TableInfo

rust
// __table_info/{table_id}
pub struct TableInfoKey {
table_id: TableId,
}

pub struct TableInfoValue {
pub table_info: RawTableInfo,
version: u64,
}

text
TableRoute
rust
// __table_route/{table_id}
pub struct NextTableRouteKey {
table_id: TableId,
}

pub struct TableRoute {
pub region_routes: Vec<RegionRoute>,
}

text
DatanodeTable
rust
// __table_route/{datanode_id}/{table_id}
pub struct DatanodeTableKey {
datanode_id: DatanodeId,
table_id: TableId,
}

pub struct DatanodeTableValue {
pub table_id: TableId,
pub regions: Vec<RegionNumber>,
version: u64,
}

text
TableNameKey
rust
// __table_name/{CatalogName}/{SchemaName}/{TableName}
pub struct TableNameKey<'a> {
pub catalog: &'a str,
pub schema: &'a str,
pub table: &'a str,
}

pub struct TableNameValue {
table_id: TableId,
}

text
These table metadata only updates in the following operations.

Region Failover


It needs to update
TableRoute key and DatanodeTable keys. If the TableRoute equals the Snapshot of TableRoute submitting the Failover task, then we can safely update these keys.

After submitting Failover tasks to acquire locks for execution, the TableRoute may be updated by another task. After acquiring the lock, we can get the latest TableRoute again and then execute it if needed.

Create Table DDL


Creates all of the above keys.
TableRoute, TableInfo, should be empty.

The TableNameKey's lock will be held by the procedure framework.

Drop Table DDL

TableInfoKey and NextTableRouteKey will be added with __removed- prefix, and the other above keys will be deleted. The transaction will not compare any keys.

Alter Table DDL

1. Rename table, updates TableInfo and TableName. Compares TableInfo, and the new TableNameKey should be empty, and TableInfo should equal the Snapshot when submitting DDL.

The old and new TableNameKey's lock will be held by the procedure framework.

2. Alter table, updates TableInfo. TableInfo should equal the Snapshot when submitting DDL.

---

Rfcs/2023 11 03 Inverted Index

---
Feature Name: Inverted Index for SST File
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/2705
Date: 2023-11-03
Author: "Zhong Zhenchi <[email protected]>"
---

Summary


This RFC proposes an optimization towards the storage engine by introducing an inverted indexing methodology aimed at optimizing label selection queries specifically pertaining to Metrics with tag columns as the target for optimization.

Introduction


In the current system setup, in the Mito Engine, the first column of Primary Keys has a Min-Max index, which significantly optimizes the outcome. However, there are limitations when it comes to other columns, primarily tags. This RFC suggests the implementation of an inverted index to provide enhanced filtering benefits to bridge these limitations and improve overall system performance.

Design Detail

Inverted Index

The primary aim of the proposed inverted index is to optimize tag columns in the SST Parquet Files within the Mito Engine. The mapping and construction of an inverted index, from Tag Values to Row Groups, enables efficient logical structures that provide faster and more flexible queries.

When scanning SST Files, pushed-down filters applied to a respective Tag's inverted index, determine the final Row Groups to be indexed and scanned, further bolstering the speed and efficiency of data retrieval processes.

Index Format

The Inverted Index for each SST file follows the format shown below:


inverted_index₀ inverted_index₁ ... inverted_indexₙ footer
text
The structure inside each Inverted Index is as followed:

bitmap₀ bitmap₁ bitmap₂ ... bitmapₙ null_bitmap fst
text
The format is encapsulated by a footer:

footer_payload footer_payload_size
text
The footer_payload is presented in protobuf encoding of InvertedIndexFooter.

The complete format is containerized in Puffin with the type defined as greptime-inverted-index-v1.

Protobuf Details

The InvertedIndexFooter is defined in the following protobuf structure:

protobuf
message InvertedIndexFooter {
repeated InvertedIndexMeta metas;
}

message InvertedIndexMeta {
string name;
uint64 row_count_in_group;
uint64 fst_offset;
uint64 fst_size;
uint64 null_bitmap_offset;
uint64 null_bitmap_size;
InvertedIndexStats stats;
}

message InvertedIndexStats {
uint64 null_count;
uint64 distinct_count;
bytes min_value;
bytes max_value;
}

text

Bitmap

Bitmaps are used to represent indices of fixed-size groups. Rows are divided into groups of a fixed size, defined in the InvertedIndexMeta as row_count_in_group.

For example, when row_count_in_group is 4096, it means each group has 4096 rows. If there are a total of 10000 rows, there will be 3 groups in total. The first two groups will have 4096 rows each, and the last group will have 1808 rows. If the indexed values are found in row 200 and 9000, they will correspond to groups 0 and 2, respectively. Therefore, the bitmap should show 0 and 2.

Bitmap is implemented using BitVec, selected due to its efficient representation of dense data arrays typical of indices of groups.


Finite State Transducer (FST)

FST is a highly efficient data structure ideal for in-memory indexing. It represents ordered sets or maps where the keys are bytes. The choice of the FST effectively balances the need for performance, space efficiency, and the ability to perform complex analyses such as regular expression matching.

The conventional usage of FST and u64 values has been adapted to facilitate indirect indexing to row groups. As the row groups are represented as Bitmaps, we utilize the u64 values split into bitmap's offset (higher 32 bits) and size (lower 32 bits) to represent the location of these Bitmaps.

API Design

Two APIs InvertedIndexBuilder for building indexes and InvertedIndexSearcher for querying indexes are designed:

rust
type Bytes = Vec<u8>;
type GroupId = u64;

trait InvertedIndexBuilder {
fn add(&mut self, name: &str, value: Option<&Bytes>, group_id: GroupId) -> Result<()>;
fn finish(&mut self) -> Result<()>;
}

enum Predicate {
Gt(Bytes),
GtEq(Bytes),
Lt(Bytes),
LtEq(Bytes),
InList(Vec<Bytes>),
RegexMatch(String),
}

trait InvertedIndexSearcher {
fn search(&mut self, name: &str, predicates: &[Predicate]) -> Result<impl IntoIterator<GroupId>>;
}

text
---

Rfcs/2023 11 07 Region Migration

---
Feature Name: Region Migration Procedure
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/2700
Date: 2023-11-03
Author: "Xu Wenkang <[email protected]>"
---

Summary


This RFC proposes a way that brings the ability of Meta Server to move regions between the Datanodes.

Motivation


Typically, We need this ability in the following scenarios:
- Migrate hot-spot Regions to idle Datanode
- Move the failure Regions to an available Datanode

Details


/ Detailed source-code truncated for AI context efficiency. /
text
Only the red nodes will persist state after it has succeeded, and other nodes won't persist state. (excluding the Start and End nodes).

Steps

The persistent context: It's shared in each step and available after recovering. It will only be updated/stored after the Red node has succeeded.

Values:
-
region_id: The target leader region.
-
peer: The target datanode.
-
close_old_leader: Indicates whether close the region.
-
leader_may_unreachable: It's used to support the failover procedure.

The Volatile context: It's shared in each step and available in executing (including retrying). It will be dropped if the procedure runner crashes.

Select Candidate

The Persistent state: Selected Candidate Region.

Update Metadata(Down)

The Persistent context:
- The (latest/updated)
version of TableRouteValue, It will be used in the step of Update Metadata(Up).

Downgrade Leader


This step sends an instruction via heartbeat and performs:
1. Downgrades leader region.
2. Retrieves the
last_entry_id (if available).

If the target leader region is not found:
- Sets
close_old_leader to true.
- Sets
leader_may_unreachable to true.

If the target Datanode is unreachable:
- Waits for region lease expired.
- Sets
close_old_leader to true.
- Sets
leader_may_unreachable to true.

The Persistent context:
None

The Persistent state:
-
last_entry_id

*Passes to next step.


Upgrade Candidate


This step sends an instruction via heartbeat and performs:
1. Replays the WAL to latest(
last_entry_id).
2. Upgrades the candidate region.

If the target region is not found:
- Rollbacks.
- Notifies the failover detector if
leader_may_unreachable == true.
- Exits procedure.

If the target Datanode is unreachable:
- Rollbacks.
- Notifies the failover detector if
leader_may_unreachable == true.
- Exits procedure.

The Persistent context:
None

Update Metadata(Up)


This step performs
1. Switches Leader.
2. Removes Old Leader(Opt.).
3. Moves Old Leader to follower(Opt.).

The TableRouteValue version should equal the TableRouteValue's version in Persistent context. Otherwise, verifies whether TableRouteValue already updated.

The Persistent context:
None

Close Old Leader(Opt.)


This step sends a close region instruction via heartbeat.

If the target leader region is not found:
- Ignore.

If the target Datanode is unreachable:
- Ignore.

Open Candidate(Opt.)


This step sends an open region instruction via heartbeat and waits for conditions to be met (typically, the condition is that the
last_entry_id of the Candidate Region is very close to that of the Leader Region or the latest).

If the target Datanode is unreachable:
- Exits procedure.

---

Rfcs/2023 12 22 Enclose Column Id

---
Feature Name: Enclose Column Id
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/2982
Date: 2023-12-22
Author: "Ruihang Xia <[email protected]>"
---

Summary


This RFC proposes to enclose the usage of
ColumnId into the region engine only.

Motivation


ColumnId is an identifier for columns. It's assigned by meta server, stored in TableInfo and RegionMetadata and used in region engine to distinguish columns.

At present, Both Frontend, Datanode and Metasrv are aware of ColumnId but it's only used in region engine. Thus this RFC proposes to remove it from Frontend (mainly used in TableInfo) and Metasrv.

Details

ColumnId is used widely on both read and write paths. Removing it from Frontend and Metasrv implies several things:

- A column may have different column id in different regions.
- A column is identified by its name in all components.
- Column order in the region engine is not restricted, i.e., no need to be in the same order with table info.

The first thing doesn't matter IMO. This concept doesn't exist anymore outside of region server, and each region is autonomous and independent -- the only guarantee it should hold is those columns exist. But if we consider region repartition, where the SST file would be re-assign to different regions, things would become a bit more complicated. A possible solution is store the relation between name and ColumnId in the manifest, but it's out of the scope of this RFC. We can likely give a workaround by introducing a indirection mapping layer of different version of partitions.

And more importantly, we can still assume columns have the same column ids across regions. We have procedure to maintain consistency between regions and the region engine should ensure alterations are idempotent. So it is possible that region repartition doesn't need to consider column ids or other region metadata in the future.

Users write and query column by their names, not by ColumnId or something else. The second point also means to change the column reference in ScanRequest from index to name. This change can hugely alleviate the misuse of the column index, which has given us many surprises.

And for the last one, column order only matters in table info. This order is used in user-faced table structure operation, like add column, describe column or as the default order of INSERT clause. None of them is connected with the order in storage.

Drawback


Firstly, this is a breaking change. Delivering this change requires a full upgrade of the cluster. Secondly, this change may introduce some performance regression. For example, we have to pass the full table name in the
ScanRequest instead of the ColumnId. But this influence is very limited, since the column index is only used in the region engine.

Alternatives

There are two alternatives from the perspective of "what can be used as the column identifier":

- Index of column to the table schema
-
ColumnId of that column

The first one is what we are using now. By choosing this way, it's required to keep the column order in the region engine the same as the table info. This is not hard to achieve, but it's a bit annoying. And things become tricky when there is internal column or different schemas like those stored in file format. And this is the initial purpose of this RFC, which is trying to decouple the table schema and region schema.

The second one, in other hand, requires the ColumnId should be identical in all regions and TableInfo. It has the same drawback with the previous alternative, that the TableInfo and RegionMetadata are tighted together. Another point is that the ColumnId is assigned by the Metasrv, who doesn't need it but have to maintain it. And this also limits the functionality of ColumnId, by taking the ability of assigning it from concrete region engine.

---

Rfcs/2024 01 17 Dataflow Framework

---
Feature Name: Dataflow Framework
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/3187
Date: 2024-01-17
Author: "Discord9 <[email protected]>"
---

Summary


This RFC proposes a Lightweight Module for executing continuous aggregation queries on a stream of data.

Motivation


Being able to do continuous aggregation is a very powerful tool. It allows you to do things like:
1. downsample data from i.e. 1 milliseconds to 1 second
2. calculate the average of a stream of data
3. Keeping a sliding window of data in memory
In order to do those things while maintaining a low memory footprint, you need to be able to manage the data in a smart way. Hence, we only store necessary data in memory, and send/recv data deltas to/from the client.

Details

System boundary / What it's and isn't


- GreptimeFlow provides a way to perform continuous aggregation over time-series data.
- It's not a complete streaming-processing system. Only a must subset functionalities are provided.
- Flow can process a configured range of fresh data. Data exceeding this range will be dropped directly. Thus it cannot handle random datasets (random on timestamp).
- Both sliding windows (e.g., latest 5m from present) and fixed windows (every 5m from some time) are supported. And these two are the major targeting scenarios.
- Flow can handle most aggregate operators within one table(i.e. Sum, avg, min, max and comparison operators). But others (join, trigger, txn etc.) are not the target feature.

Framework


- Greptime Flow's is built on top of Hydroflow.
- We have three choices for the Dataflow/Streaming process framework for our simple continuous aggregation feature:
1. Based on the timely/differential dataflow crate that materialize based on. Later, it's proved too obscure for a simple usage, and is hard to customize memory usage control.
2. Based on a simple dataflow framework that we write from ground up, like what arroyo or risingwave did, for example the core streaming logic of arroyo only takes up to 2000 line of codes. However, it means maintaining another layer of dataflow framework, which might seem easy in the beginning, but I fear it might be too burdensome to maintain once we need more features.
3. Based on a simple and lower level dataflow framework that someone else write, like hydroflow, this approach combines the best of both worlds. Firstly, it boasts ease of comprehension and customization. Secondly, the dataflow framework offers precisely the necessary features for crafting uncomplicated single-node dataflow programs while delivering decent performance.

Hence, we choose the third option, and use a simple logical plan that's anagonistic to the underlying dataflow framework, as it only describe how the dataflow graph should be doing, not how it do that. And we built operator in hydroflow to execute the plan. And the result hydroflow graph is wrapped in a engine that only support data in/out and tick event to flush and compute the result. This provide a thin middle layer that's easy to maintain and allow switching to other dataflow framework if necessary.

Deploy mode and protocol


- Greptime Flow is an independent streaming compute component. It can be used either within a standalone node or as a dedicated node at the same level as frontend in distributed mode.
- It accepts insert request Rows, which is used between frontend and datanode.
- New flow job is submitted in the format of modified SQL query like snowflake do, like:
CREATE TASK avg_over_5m WINDOW_SIZE = "5m" AS SELECT avg(value) FROM table WHERE time > now() - 5m GROUP BY time(1m). Flow job then got stored in Metasrv.
- It also persists results in the format of Rows to frontend.
- The query plan uses Substrait as codec format. It's the same with GreptimeDB's query engine.
- Greptime Flow needs a WAL for recovering. It's possible to reuse datanode's.

The workflow is shown in the following diagram

mermaid
graph TB
subgraph Flownode["Flownode"]
subgraph Dataflows
df1("Dataflow_1")
df2("Dataflow_2")
end
end
subgraph Frontend["Frontend"]
newLines["Mirror Insert
Create Task From Query
Write result from flow node"]
end

subgraph Datanode["Datanode"]
end

User --> Frontend
Frontend -->|Register Task| Metasrv
Metasrv -->|Read Task Metadata| Frontend
Frontend -->|Create Task| Flownode

Frontend -->|Mirror Insert| Flownode
Flownode -->|Write back| Frontend

Frontend --> Datanode
Datanode --> Frontend

text

Lifecycle of data


- New data is inserted into frontend like before. Frontend will mirror insert request to Flow node if there is configured flow job.
- Depending on the timestamp of incoming data, flow will either drop it (outdated data) or process it (fresh data).
- Greptime Flow will periodically write results back to the result table through frontend.
- Those result will then be written into a result table stored in datanode.
- A small table of intermediate state is kept in memory, which is used to calculate the result.

Supported operations


- Greptime Flow accepts a configurable "materialize window", data point exceeds that time window is discarded.
- Data within that "materialize window" is queryable and updateable.
- Greptime Flow can handle partitioning, if and only if the input query can be transformed to a fully partitioned plan according to the existing commutative rules. Otherwise the corresponding flow job has to be calculated in a single node.
- Notice that Greptime Flow has to see all the data belongs to one partition.
- Deletion and duplicate insertion are not supported at early stage.

Miscellaneous


- Greptime Flow can translate SQL to it's own plan, however only a selected few aggregate function is supported for now, like min/max/sum/count/avg
- Greptime Flow's operator is configurable in terms of the size of the materialize window, whether to allow delay of incoming data etc., so simplest operator can choose to not tolerate any delay to save memory.

Future Work


- Support UDF that can do one-to-one mapping. Preferably, we can reuse the UDF mechanism in GreptimeDB.
- Support join operator.
- Design syntax for config operator for different materialize window and delay tolerance.
- Support cross partition merge operator that allows complex query plan that not necessary accord with partitioning rule to communicate between nodes and create final materialize result.
- Duplicate insertion, which can be reverted easily within the current framework, so supporting it could be easy
- Deletion within "materialize window", this requires operators like min/max to store all inputs within materialize window, which might require further optimization.

---

Rfcs/2024 08 06 Json Datatype

---
Feature Name: Json Datatype
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/4230
Date: 2024-8-6
Author: "Yuhan Wang <[email protected]>"
---

Summary


This RFC proposes a method for storing and querying JSON data in the database.

Motivation


JSON is widely used across various scenarios. Direct support for writing and querying JSON can significantly enhance the database's flexibility.

Details

Storage and Query

GreptimeDB's type system is built on Arrow/DataFusion, where each data type in GreptimeDB corresponds to a data type in Arrow/DataFusion. The proposed JSON type will be implemented on top of the existing Binary type, leveraging the current datatype::value::Value and datatype::vectors::BinaryVector implementations, utilizing the JSONB format as the encoding of JSON data. JSON data is stored and processed similarly to binary data within the storage layer and query engine.

This approach brings problems when dealing with insertions and queries of JSON columns.

Insertion

Users commonly write JSON data as strings. Thus we need to make conversions between string and JSONB. There are 2 ways to do this:

1. MySQL and PostgreSQL servers provide auto-conversions between strings and JSONB. When a string is inserted into a JSON column, the server will try to parse the string as JSON and convert it to JSONB. The non-JSON strings will be rejected.

2. A function parse_json is provided to convert string to JSONB. If the string is not a valid JSON string, the function will return an error.

For example, in MySQL client:

SQL
CREATE TABLE IF NOT EXISTS test (
ts TIMESTAMP TIME INDEX,
a INT,
b JSON
);

INSERT INTO test VALUES(
0,
0,
'{
"name": "jHl2oDDnPc1i2OzlP5Y",
"timestamp": "2024-07-25T04:33:11.369386Z",
"attributes": { "event_attributes": 48.28667 }
}'
);

INSERT INTO test VALUES(
0,
0,
parse_json('{
"name": "jHl2oDDnPc1i2OzlP5Y",
"timestamp": "2024-07-25T04:33:11.369386Z",
"attributes": { "event_attributes": 48.28667 }
}')
);

text
Are both valid.

The dataflow of the insertion process is as follows:


Insert JSON strings directly through client:
Parse Insert
String(Serialized JSON)┌──────────┐Arrow Binary(JSONB)┌──────┐Arrow Binary(JSONB)
Client ---------------------->│ Server │------------------>│ Mito │------------------> Storage
└──────────┘ └──────┘
(Server identifies JSON type and performs auto-conversion)

Insert JSON strings through parse_json function:
Parse Insert
String(Serialized JSON)┌──────────┐String(Serialized JSON)┌─────┐Arrow Binary(JSONB)┌──────┐Arrow Binary(JSONB)
Client ---------------------->│ Server │---------------------->│ UDF │------------------>│ Mito │------------------> Storage
└──────────┘ └─────┘ └──────┘
(Conversion is performed by UDF inside Query Engine)

text
Servers identify JSON column through column schema and perform auto-conversions. But when using prepared statements and binding parameters, the corresponding cached plans in datafusion generated by prepared statements cannot identify JSON columns. Under this circumstance, the servers identify JSON columns through the given parameters and perform auto-conversions.

The following is an example of inserting JSON data through prepared statements:

Rust
sqlx::query(
"create table test(ts timestamp time index, j json)",
)
.execute(&pool)
.await
.unwrap();

let json = serde_json::json!({
"code": 200,
"success": true,
"payload": {
"features": [
"serde",
"json"
],
"homepage": null
}
});

// Valid, can identify serde_json::Value as JSON type
sqlx::query("insert into test values($1, $2)")
.bind(i)
.bind(json)
.execute(&pool)
.await
.unwrap();

// Invalid, cannot identify String as JSON type
sqlx::query("insert into test values($1, $2)")
.bind(i)
.bind(json.to_string())
.execute(&pool)
.await
.unwrap();

text

Query

Correspondingly, users prefer to display JSON data as strings. Thus we need to make conversions between JSON data and strings before presenting JSON data. There are also 2 ways to do this: auto-conversions on MySQL and PostgreSQL servers, and function json_to_string.

For example, in MySQL client:

SQL
SELECT b FROM test;

SELECT json_to_string(b) FROM test;

text
Will both return the JSON as human-readable strings.

Specifically, to perform auto-conversions, we attach a message to JSON data in the metadata of Field in Arrow/Datafusion schema when scanning a JSON column. Frontend servers could identify JSON data and convert it to strings.

The dataflow of the query process is as follows:


Query directly through client:
Decode Scan
String(Serialized JSON)┌──────────┐Arrow Binary(JSONB)┌──────────────┐Arrow Binary(JSONB)
Client <----------------------│ Server │<------------------│ Query Engine │<----------------- Storage
└──────────┘ └──────────────┘
(Server identifies JSON type and performs auto-conversion based on column metadata)

Query through json_to_string function:
Scan & Decode
String(Serialized JSON)┌──────────┐String(Serialized JSON)┌──────────────┐Arrow Binary(JSONB)
Client <----------------------│ Server │<----------------------│ Query Engine │<----------------- Storage
└──────────┘ └──────────────┘
(Conversion is performed by UDF inside Query Engine)

text
However, if a function uses JSON type as its return type, the metadata method mentioned above is not applicable. Thus the functions of JSON type should specify the return type explicitly instead of returning a JSON type, such as json_get_int and json_get_float which return corresponding data of INT and FLOAT type respectively.

Functions


Similar to the common JSON type, JSON data can be queried with functions.

For example:

SQL
CREATE TABLE IF NOT EXISTS test (
ts TIMESTAMP TIME INDEX,
a INT,
b JSON
);

INSERT INTO test VALUES(
0,
0,
'{
"name": "jHl2oDDnPc1i2OzlP5Y",
"timestamp": "2024-07-25T04:33:11.369386Z",
"attributes": { "event_attributes": 48.28667 }
}'
);

SELECT json_get_string(b, 'name') FROM test;
+---------------------+
| b.name |
+---------------------+
| jHl2oDDnPc1i2OzlP5Y |
+---------------------+

SELECT json_get_float(b, 'attributes.event_attributes') FROM test;
+--------------------------------+
| b.attributes.event_attributes |
+--------------------------------+
| 48.28667 |
+--------------------------------+

text
And more functions can be added in the future.

Drawbacks

As a general purpose JSON data type, JSONB may not be as efficient as specialized data types for specific scenarios.

The auto-conversion mechanism is not supported in all scenarios. We need to find workarounds for these scenarios.

Alternatives

Extract and flatten JSON schema to store in a structured format through pipeline. For nested data, we can provide nested types like STRUCT or ARRAY.

---

Rfcs/2025 02 06 Remote Wal Purge

---
Feature Name: Remote WAL Purge
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/5474
Date: 2025-02-06
Author: "Yuhan Wang <[email protected]>"
---

Summary

This RFC proposes a method for purging remote WAL in the database.

Motivation

Currently only local wal entries are purged when flushing, while remote wal does nothing.

Details

mermaid
sequenceDiagram
Region0->>Kafka: Last entry id of the topic in use
Region0->>WALPruner: Heartbeat with last entry id
WALPruner->>+WALPruner: Time Loop
WALPruner->>+ProcedureManager: Submit purge procedure
ProcedureManager->>Region0: Flush request
ProcedureManager->>Kafka: Prune WAL entries
Region0->>Region0: Flush
text

Steps

Before purge

Before purging remote WAL, metasrv needs to know:

1. last_entry_id of each region.
2.
kafka_topic_last_entry_id which is the last entry id of the topic in use. Can be lazily updated and needed when region has empty memtable.
3. Kafka topics that each region uses.

The states are maintained through:
1. Heartbeat: Datanode sends
last_entry_id to metasrv in heartbeat. As for regions with empty memtable, last_entry_id should equals to kafka_topic_last_entry_id.
2. Metasrv maintains a topic-region map to know which region uses which topic.

kafka_topic_last_entry_id will be maintained by the region itself. Region will update the value after k heartbeats if the memtable is empty.

Purge procedure

We can better handle locks utilizing current procedure. It's quite similar to the region migration procedure.

After a period of time, metasrv will submit a purge procedure to ProcedureManager. The purge will apply to all topics.

The procedure is divided into following stages:

1. Preparation:
- Retrieve
last_entry_id of each region kvbackend.
- Choose regions that have a relatively small
last_entry_id as candidate regions, which means we need to send a flush request to these regions.
2. Communication:
- Send flush requests to candidate regions.
3. Purge:
- Choose proper entry id to delete for each topic. The entry should be the smallest
last_entry_id - 1 among all regions.
- Delete legacy entries in Kafka.
- Store the
last_purged_entry_id in kvbackend. It should be locked to prevent other regions from replaying the purged entries.

After purge

After purge, there may be some regions that have last_entry_id smaller than the entry we just deleted. It's legal since we only delete the entries that are not needed anymore.

When restarting a region, it should query the last_purged_entry_id from metasrv and replay from min(last_entry_id, last_purged_entry_id).

Error handling

No persisted states are needed since all states are maintained in kvbackend.

Retry when failed to retrieving metadata from kvbackend.

Alternatives

Purge time can depend on the size of the WAL entries instead of a fixed period of time, which may be more efficient.

---

Rfcs/2025 06 20 Repartition

---
Feature Name: Repartition
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/6558
Date: 2025-06-20
Author: "Ruihang Xia <[email protected]>"
---

Summary

This RFC proposes a method for repartitioning a table, to adjust the partition rule and data distribution.

Motivation

With time passing, the data distribution and skew pattern of a table might change. We need a way to repartition the table to suit the new pattern.

Details

Here is a rough workflow diagram of the entire repartition process, each step is described in detail below.

mermaid
sequenceDiagram
participant Frontend
participant Metasrv
participant Datanodes
participant Region0 as Region 0

Frontend->>Frontend: Process request, validation etc.
Frontend->>Metasrv: Submit procedure
Metasrv->>Metasrv: Compute diff and generate migration plan
Metasrv->>Metasrv: Allocate necessary region resources (with Paas)
Metasrv->>Datanodes: Stop compaction and snapshot
rect rgb(255, 225, 225)
note over Frontend, Region0: No Ingestion Period
Metasrv->>Frontend: Stop processing write requests
Metasrv->>Metasrv: Update metadata
Metasrv->>Frontend: Start processing read requests
end
Metasrv->>Datanodes: Update region rule, stage version changes from now on
Region0->>Region0: Compute new manifests for all regions
Region0->>Datanodes: Submit manifest changes
Metasrv->>Datanodes: Recover compaction and snapshot, make staged changes visible

note over Frontend, Datanodes: Reload Cache
Metasrv->>Metasrv: Release resources (with Paas)
Metasrv->>Metasrv: Schedule optional compaction (to remote compactor)

text

Preprocessing

This phase is for static analysis of the new partition rule. The server can know whether the repartitioning is possible, how to do the repartitioning, and how much resources are needed.

In theory, the input and output partition rules for repartitioning can be completely unrelated. But in practice, to avoid a very large change set, we'll only allow two simple kinds of change. One splits one region into two regions (region split) and another merges two regions into one (region merge).

After validating the new partition rule using the same validation logic as table creation, we compute the difference between the old and new partition rules. The resulting diff may contain several independent groups of changes. During subsequent processing, each group of changes can be handled independently and can succeed or fail without affecting other groups or creating non-idempotently retryable scenarios.

Next, we generate a repartition plan for each group of changes. Each plan contains this information for all regions involved in that particular plan. And one target region will only be referenced by a single plan.

With those plans, we can determine the resource requirements for the repartition operation, where resources here primarily refer to Regions. Metasrv will coordinate with PaaS layer to pre-allocate the necessary regions at this stage. These new regions start completely empty, and their metadata and manifests will be populated during subsequent modification steps.

Data Processing

This phase is primarily for region's change, including region's metadata (route table and the corresponding rule) and manifest.

Once we start processing one plan through a procedure, we'll first stop the region's compaction and snapshot. This is to avoid any states being removed due to compaction (which may removes old SST files) and snapshot (which may removes old manifest files).

Metasrv will trying to update the metadata of partition, or the region route table (related to PartitionRuleManager). This step is in the "no ingestion" scope, so no new data will be ingested. Since this won't take much time, the affection to the cluster is minimized. Metasrv will also update the region rule to corresponding regions on Datanodes.

Every regions and all the ingestion requests to the region server will have a version of region rule, to identify under which rule the request is processed. The version can be something like hash(region_rule). Once the region rule on region server is updated, all ingestion request with old rule will be rejected, and all requests with new rule will be accepted but not visible. They can still be flushed to persisted storage, but their version change (new manifest) will be staged.

Then region 0 (or let metasrv to pick any operational region) will compute the new manifests for all target regions. This step is done by first reading all old manifests, and remapping the files with new partition rule, to get the content of new manifests. Notice this step only handles the manifests before region rule change on region server, and won't touch those staged manifests, as they are already with the new rule.

Those new manifest will be submitted to the corresponding target regions by region 0 via a RegionEdit request. If this request falls after a few retries, region 0 will try to rollback this change by directly overwriting the manifest on object storage. and report this failure to metasrv and let the entire repartition procedure to fail. And we can also optionally compute the new manifest for those staged version changes (like another repartition) and submit them to the target regions to make the also visible even if the repartition fails.

In the other hand, a successful RegionEdit request also acknowledges those staged version changes and make them visible.

After this step, the repartition is done in the data plane. We can start to process compaction and snapshot again.

Postprocessing

After the main processing is done, we can do some extra postprocessing to reduce the performance impact of repartition. Including reloading caches in frontend's route table, metasrv's kv cache and datanode's read/write/page cache etc.

We can also schedule an optional compaction to reorganize all the data file under the new partition rule to reduce potential fragmentation or read amplification.

Procedure

Here describe the repartition procedure step by step:

- <on frontend> Validating repartition request
- <on frontend> Initialize the repartition procedure
- Calculate rule diff and repartition plan group
- Allocate necessary new regions
- Lock the table key
- For each repartition subprocedure
- Stop compaction and snapshot
- Forbid new ingestion requests, update metadata, allow ingestion requests.
- Update region rule to regions
- Pick one region to calculate new manifest for all regions in this repartition group
- Let that region to apply new manifest to each region via
RegionEdit
- If failed after some retries, revert this manifest change to other succeeded regions and mark this failure.
- If all succeeded, acknowledge those staged version changes and make them visible.
- Return result
- Collect results from subprocedure.
- For those who failed, we need to restart those regions to force reconstruct their status from manifests
- For those who succeeded, collect and merge their rule diff
- Unlock the table key
- Report the result to user.
- <in background> Reload cache
- <in background> Maybe trigger a special compaction

In addition of sequential step, rollback is also an important part of this procedure. There are three steps can be rolled back when unrecoverable failure occurs.

If the metadata update is not committed, we can overwrite the metadata to previous version. This step is scoped in the "no ingestion" period, so no new data will be ingested and the status of both datanode and metasrv will be consistent.

If the RegionEdit to other regions is not acknowledged, or partial acknowledged, we can directly overwrite the manifest on object storage from the central region (who computes the new manifest), and force region server to reload corresponding region to load its state from object storage to recover.

If the staged version changes are not acknowledged, we can re-compute manifest based on old rule for staged data, and apply them directly like above. This is like another smaller repartition for those staged data.

Region rule validation and diff calculation

In the current codebase, the rule checker is not complete. It can't check uniqueness and completeness of the rule. This RFC also propose a new way to validate the rule.

The proposed validation way is based on a check-point system, which first generates a group of check-points from the rule, and then check if all the point is covered and only covered by one rule.

All the partition rule expressionis limited to be the form of <column> <operator> <value>, and the operator is limited to be comparison operators. Those expressions are allowed to be nested with AND and OR operators. Based on this, we can first extract all the unique values on each column, adding and subtracting a little epsilon to cover its left and right boundary.

Since we accept integer, float and string as the value type, compute on them directly is not convenient. So we'll first normalize them to a common type and only need to preserve the relative partial ordering. This also avoids the problem of "what is next/previous value" of string and "what's a good precision" for float.

After normalization, we get a set of scatter points for each column. Then we can generate a set of check-points by combining all the scatter points like building a cartesian product. This might bring a large number of check-points, so we can do an prune optimization to remove some of them by merging some of the expression zones. Those expressions who have identical N-1 edge sub-expressions with one adjacent edge can be merged together. This prune check is with a time complexity of O(N M log(M)), where N is the number of active dimensions and M is the number of expression zones. Diff calculation is also done by finding different expression zones between the old and new rule set, and check if we can transform one to another by merging some of the expression zones.

The step to validate the check-points set against expressions can be treated as a tiny expression of PhysicalExpr. This evaluation will give a boolean matrix of K*M shape, where K is the number of check-points. We then check in each row of the matrix, if there is one and only one true value.

Compute and use new manifest

We can generate a new set of manifest file based on old manifest and two versions of rule. From abvoe rule processing part, we can tell how a new rule & region is from previous one. So a simple way to get the new manifest is also apply the step of change to manifest files. E.g., if region A is from region B and C, we simply combine all file IDs from B and C to generate the content of A.

If necessary, we can do this better by involving some metadata related to data, like min-max statistics of each file, and pre-evaluate over min-max to filter out unneeded files when generating new manifest.

The way to use new manifest needs one more extra step based on the current implementation. We'll need to record either in manifest or in file metadata, of what rule is used when generating (flush or compaction) a SST file. Then in every single read request, we need to append the current region rule as predicate to the read request, to ensure no data belong to other regions will be read. We can use the stored region rule to reduce the number of new predicates to apply, by removing the identical predicate between the current region rule and the stored region rule. So ideally in a table that has not been repartitioned recently, the overhead of checking region rule is minimal.

Pre-required tasks

In above steps, we assume some functionalities are implemented. Here list them with where they are used and how to implement them.

Cross-region read

The current data directory structure is {table_id}/{region_id}/[data/metadata]/{file_id}, every region can only access files under their own directory. After repartition, data file may be placed in other previous old regions. So we need to support cross-region read. This new access method allows region to access any file under the same table. Related tracking issue is <https://github.com/GreptimeTeam/greptimedb/issues/6409>.

Global GC worker

This is to simplify state management of data files. As one file may be referenced in multiple manifests, or no manifest at all. After this, every region and the repartition process only need to care about generateing and using new files, without tracking whether a file should be deleted or not. Leaving the deletion to the global GC worker. This worker basically works by counting reference from manifest file, and remove unused one. Related tracking issue is TBD.

Alternatives

In the "Data Processing" section, we can enlarge the "no ingestion" period to include almost all the steps. This can simplify the entire procedure by a lot, but will bring a longer time of ingestion pause which may not be acceptable.

---

Rfcs/2025 07 04 Compatibility Test Framework

---
Feature Name: Compatibility Test Framework
Tracking Issue: TBD
Date: 2025-07-04
Author: "Ruihang Xia <[email protected]>"
---

Summary

This RFC proposes a compatibility test framework for GreptimeDB to ensure backward/forward compatibility for different versions of GreptimeDB.

Motivation

In current practice, we don't have a systematic way to test and ensure the compatibility of different versions of GreptimeDB. Each time we release a new version, we need to manually test the compatibility with ad-hoc cases. This is not only time-consuming, but also prone to errors and unmaintainable. Highly rely on the release manager to ensure the compatibility of different versions of GreptimeDB.

We don't have a detailed guide on the release SoP of how to test and ensure the compatibility of the new version. And has broken the compatibility of the new version many times (v0.14.1 and v0.15.1 are two examples, which are both released right after the major release).

Details

This RFC proposes a compatibility test framework that is easy to maintain, extend and run. It can tell the compatibility between any given two versions of GreptimeDB, both backward and forward. It's based on the Sqlness library but used in a different way.

Generally speaking, the framework is composed of two parts:

1. Test cases: A set of test cases that are maintained dedicatedly for the compatibility test. Still in the .sql and .result format.
2. Test framework: A new sqlness runner that is used to run the test cases. With some new features that is not required by the integration sqlness test.

Test Cases

Structure

The case set is organized in three parts:

- 1.feature: Use a new feature
-
2.verify: Verify database behavior
-
3.cleanup: Paired with 1.feature, cleanup the test environment.

These three parts are organized in a tree structure, and should be run in sequence:


compatibility_test/
├── 1.feature/
│ ├── feature-a/
│ ├── feature-b/
│ └── feature-c/
├── 2.verify/
│ ├── verify-metadata/
│ ├── verify-data/
│ └── verify-schema/
└── 3.cleanup/
├── cleanup-a/
├── cleanup-b/
└── cleanup-c/
text

Example

For example, for a new feature like adding new index option (#6416), we (who implement the feature) create a new test case like this:

sql
-- path: compatibility_test/1.feature/index-option/granularity_and_false_positive_rate.sql

-- SQLNESS ARG since=0.15.0
-- SQLNESS IGNORE_RESULT
CREATE TABLE granularity_and_false_positive_rate (ts timestamp time index, val double) with ("index.granularity" = "8192", "index.false_positive_rate" = "0.01");

text
And
sql
-- path: compatibility_test/3.cleanup/index-option/granularity_and_false_positive_rate.sql
drop table granularity_and_false_positive_rate;
text
Since this new feature don't require some special way to verify the database behavior, we can reuse existing test cases in 2.verify/ to verify the database behavior. For example, we can reuse the verify-metadata test case to verify the metadata of the table.
sql
-- path: compatibility_test/2.verify/verify-metadata/show-create-table.sql

-- SQLNESS TEMPLATE TABLE="SHOW TABLES";
SHOW CREATE TABLE $TABLE;

text
In this example, we use some new sqlness features that will be introduced in the next section (since, IGNORE_RESULT, TEMPLATE).

Maintenance

Each time implement a new feature that should be covered by the compatibility test, we should create a new test case in 1.feature/ and 3.cleanup/ for them. And check if existing cases in 2.verify/ can be reused to verify the database behavior.

This simulates an enthusiastic user who uses all the new features at the first time. All the new Maintenance burden is on the feature implementer to write one more test case for the new feature, to "fixation" the behavior. And once there is a breaking change in the future, it can be detected by the compatibility test framework automatically.

Another topic is about deprecation. If a feature is deprecated, we should also mark it in the test case. Still use above example, assume we deprecate the index.granularity and index.false_positive_rate index options in v0.99.0, we can mark them as:

sql
-- SQLNESS ARG since=0.15.0 till=0.99.0
...
text
This tells the framework to ignore this feature in version v0.99.0 and later. Currently, we have so many experimental features that are scheduled to be broken in the future, this is a good way to mark them.

Test Framework

This section is about new sqlness features required by this framework.

Since and Till

Follows the ARG interceptor in sqlness, we can mark a feature is available between two given versions. Only the since is required:

sql
-- SQLNESS ARG since=VERSION_STRING [till=VERSION_STRING]
text

IGNORE_RESULT

IGNORE_RESULT is a new interceptor, it tells the runner to ignore the result of the query, only check whether the query is executed successfully.

This is useful to reduce the Maintenance burden of the test cases, unlike the integration sqlness test, in most cases we don't care about the result of the query, only need to make sure the query is executed successfully.

TEMPLATE

TEMPLATE is another new interceptor, it can generate queries from a template based on a runtime data.

In above example, we need to run the SHOW CREATE TABLE query for all existing tables, so we can use the TEMPLATE interceptor to generate the query with a dynamic table list.

RUNNER

There are also some extra requirement for the runner itself:

- It should run the test cases in sequence, first 1.feature/, then 2.verify/, and finally 3.cleanup/.
- It should be able to fetch required version automatically to finish the test.
- It should handle the
since and till properly.

On the 1.feature phase, the runner needs to identify all features need to be tested by version number. And then restart with a new version (the to version) to run 2.verify/ and 3.cleanup/ phase.

Test Report

Finally, we can run the compatibility test to verify the compatibility between any given two versions of GreptimeDB, for example:

bash

check backward compatibility between v0.15.0 and v0.16.0 when releasing v0.16.0


./sqlness run --from=0.15.0 --to=0.16.0

check forward compatibility when downgrading from v0.15.0 to v0.13.0


./sqlness run --from=0.15.0 --to=0.13.0
text
We can also use a script to run the compatibility test for all the versions in a given range to give a quick report with all versions we need.

And we always bump the version in Cargo.toml to the next major release version, so the next major release version can be used as "latest" unpublished version for scenarios like local testing.

Alternatives

There was a previous attempt to implement a compatibility test framework that was disabled due to some reasons #3728.

---

Rfcs/2025 07 23 Global Gc Worker

---
Feature Name: "global-gc-worker"
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/6571
Date: 2025-07-23
Author: "discord9 <[email protected]>"
---

Global GC Worker

Summary

This RFC proposes the integration of a garbage collection (GC) mechanism within the Compaction process. This mechanism aims to manage and remove stale files that are no longer actively used by any system component, thereby reclaiming storage space.

Motivation

With the introduction of features such as table repartitioning, a substantial number of Parquet files can become obsolete. Furthermore, failures during manifest updates may result in orphaned files that are never referenced by the system. Therefore, a periodic garbage collection mechanism is essential to reclaim storage space by systematically removing these unused files.

Details

Overview

The garbage collection process will be integrated directly into the Compaction process. Upon the completion of a Compaction for a given region, the GC worker will be automatically triggered. Its primary function will be to identify and subsequently delete obsolete files that have persisted beyond their designated retention period. This integration ensures that garbage collection is performed in close conjunction with data lifecycle management, effectively leveraging the compaction process's inherent knowledge of file states.

This design prioritizes correctness and safety by explicitly linking GC execution to a well-defined operational boundary: the successful completion of a compaction cycle.

Terminology

- Unused File: Refers to a file present in the storage directory that has never been formally recorded in any manifest. A common scenario for this includes cases where a new SST file is successfully written to storage, but the subsequent update to the manifest fails, leaving the file unreferenced.
- Obsolete File: Denotes a file that was previously recorded in a manifest but has since been explicitly marked for removal. This typically occurs following operations such as data repartitioning or compaction.

GC Worker Process

The GC worker operates as an integral part of the Compaction process. Once a Compaction for a specific region is completed, the GC worker is automatically triggered. Executing this process on a datanode is preferred to eliminate the overhead associated with having to set object storage configurations in the metasrv.

The detailed process is as follows:

1. Invocation: Upon the successful completion of a Compaction for a region, the GC worker is invoked.
2. Manifest Reading: The worker reads the region's primary manifest to obtain a comprehensive list of all files marked as obsolete. Concurrently, it reads any temporary manifests generated by long-running queries to identify files that are currently in active use, thereby preventing their premature deletion.
3. Lingering Time Check (Obsolete Files): For each identified obsolete file, the GC worker evaluates its "lingering time." Which is the time passed after it had been removed from manifest.
4. Deletion Marking (Obsolete Files): Files that have exceeded their maximum configurable lingering time and are not referenced by any active temporary manifests are marked for deletion.
5. Lingering Time (Unused Files): Unused files (those never recorded in any manifest) are also subject to a configurable maximum lingering time before they are eligible for deletion.

Following flowchart illustrates the GC worker's process:

mermaid
flowchart TD
A[Compaction Completed] --> B[Trigger GC Worker]
B --> C[Scan Region Manifest]
C --> D[Identify File Types]
D --> E[Unused Files<br/>Never recorded in manifest]
D --> F[Obsolete Files<br/>Previously in manifest<br/>but marked for removal]
E --> G[Check Lingering Time]
F --> G
G --> H{File exceeds<br/>configured lingering time?}
H -->|No| I[Skip deletion]
H -->|Yes| J[Check Temporary Manifest]
J --> K{File in use by<br/>active queries?}
K -->|Yes| L[Retain file<br/>Wait for next GC cycle]
K -->|No| M[Safely delete file]
I --> N[End GC cycle]
L --> N
M --> O[Update Manifest]
O --> N
N --> P[Wait for next Compaction]
P --> A
style A fill:#e1f5fe
style B fill:#f3e5f5
style M fill:#e8f5e8
style L fill:#fff3e0
text
#### Handling Obsolete Files

An obsolete file is permanently deleted only if two conditions are met:
1. The time elapsed since its removal from the manifest (its obsolescence timestamp) exceeds a configurable threshold.
2. It is not currently referenced by any active temporary manifests.


#### Handling Unused Files

With the integration of the GC worker into the Compaction process, the risk of accidentally deleting newly created SST files that have not yet been recorded in the manifest is significantly mitigated. Consequently, the concept of "Unused Files" as a distinct category primarily susceptible to accidental deletion is largely resolved. Any files that are genuinely "unused" (i.e., never referenced by any manifest, including temporary ones) can be safely deleted after a configurable maximum lingering time.

For debugging and auditing purposes, a comprehensive list of recently deleted files can be maintained.

Ensuring Read Consistency

To prevent the GC worker from inadvertently deleting files that are actively being utilized by long-running analytical queries, a robust protection mechanism is introduced. This mechanism relies on temporary manifests that are actively kept "alive" by the queries using them.

When a long-running query is detected (e.g., by a slow query recorder), it will write a temporary manifest to the region's manifest directory. This manifest lists all files required for the query. However, simply creating this file is not enough, as a query runner might crash, leaving the temporary manifest orphaned and preventing garbage collection indefinitely.

To address this, the following "heartbeat" mechanism is implemented:
1. Periodic Updates: The process executing the long-running query is responsible for periodically updating the modification timestamp of its temporary manifest file (i.e., "touching" the file). This serves as a heartbeat, signaling that the query is still active.
2. GC Worker Verification: When the GC worker runs, it scans for temporary manifests. For each one it finds, it checks the file's last modification time.
3. Stale File Handling: If a temporary manifest's last modification time is older than a configurable threshold, the GC worker considers it stale (left over from a crashed or terminated query). The GC worker will then delete this stale temporary manifest. Files that were protected only by this stale manifest are no longer shielded from garbage collection.

This approach ensures that only files for genuinely active queries are protected. The lifecycle of the temporary manifest is managed dynamically: it is created when a long query starts, kept alive through periodic updates, and is either deleted by the query upon normal completion or automatically cleaned up by the GC worker if the query terminates unexpectedly.

This mechanism may be too complex to implement at once. We can consider a two-phased approach:
1. Phase 1 (Simple Time-Based Deletion): Initially, implement a simpler GC strategy that deletes obsolete files based solely on a configurable lingering time. This provides a baseline for space reclamation without the complexity of temporary manifests.
2. Phase 2 (Consistency-Aware GC): Based on the practical effectiveness and observed issues from Phase 1, we can then decide whether to implement the full temporary manifest and heartbeat mechanism to handle long-running queries. This iterative approach allows for a quicker initial implementation while gathering real-world data to justify the need for a more complex solution.

Drawbacks

- Dependency on Compaction Frequency: The integration of the GC worker with Compaction means that GC cycles are directly tied to the frequency of compactions. In environments with infrequent compaction operations, obsolete files may accumulate for extended periods before being reclaimed, potentially leading to increased storage consumption.
- Race Condition with Long-Running Queries: A potential race condition exists if a long-running query initiates but haven't write its temporary manifest in time, while a compaction process simultaneously begins and marks files used by that query as obsolete. This scenario could lead to the premature deletion of files still required by the active query. To mitigate this, the threshold time for writing a temporary manifest should be significantly shorter than the lingering time configured for obsolete files, ensuring that next GC worker runs do not delete files that are now referenced by a temporary manifest if the query is still running.
Also the read replica shouldn't be later in manifest version for more than the lingering time of obsolete files, otherwise it might ref to files that are already deleted by the GC worker.
- need to upload tmp manifest to object storage, which may introduce additional complexity and potential performance overhead. But since long-running queries are typically not frequent, the performance impact is expected to be minimal.

one potential race condition with region-migration is illustrated below:

mermaid
sequenceDiagram
participant gc_worker as GC Worker(same dn as region 1)
participant region1 as Region 1 (Leader → Follower)
participant region2 as Region 2 (Follower → Leader)
participant region_dir as Region Directory

gc_worker->>region1: Start GC, get region manifest
activate region1
region1-->>gc_worker: Region 1 manifest
deactivate region1
gc_worker->>region_dir: Scan region directory

Note over region1,region2: Region Migration Occurs
region1-->>region2: Downgrade to Follower
region2-->>region1: Becomes Leader

region2->>region_dir: Add new file

gc_worker->>region_dir: Continue scanning
gc_worker-->>region_dir: Discovers new file
Note over gc_worker: New file not in Region 1's manifest
gc_worker->>gc_worker: Mark file as orphan(incorrectly)

text
which could cause gc worker to incorrectly mark the new file as orphan and delete it, if config the lingering time for orphan files(files not mentioned anywhere(in used or unused)) is not long enough.

A good enough solution could be to use lock to prevent gc worker to happen on the region if region migration is happening on the region, and vise versa.

The race condition between gc worker and repartition also needs to be considered carefully. For now, acquiring lock for both region-migration and repartition during gc worker process could be a simple solution.

Conclusion and Rationale

This section summarizes the key aspects and trade-offs of the proposed integrated GC worker, highlighting its advantages and potential challenges.

| Aspect | Current Proposal (Integrated GC) |
| :--- | :--- |
| Implementation Complexity | Medium. Requires careful integration with the compaction process and the slow query recorder for temporary manifest management. |
| Reliability | High. Integration with compaction and leveraging temporary manifests from long-running queries significantly mitigates the risk of incorrect deletion. Accurate management of lingering times for obsolete files and prevention of accidental deletion of newly created SSTs enhance data safety. |
| Performance Overhead | Low to Medium. The GC worker runs post-compaction, minimizing direct impact on write paths. Overhead from temporary manifest management by the slow query recorder is expected to be acceptable for long-running queries. |
| Impact on Other Components | Moderate. Requires modifications to the compaction process to trigger GC and the slow query recorder to manage temporary manifests. This introduces some coupling but enhances overall data safety. |
| Deletion Strategy | State- and Time-Based. Obsolete files are deleted based on a configurable lingering time, which is paused if the file is referenced by a temporary manifest. Unused files (never in a manifest) are also subject to a lingering time. |

Unresolved Questions and Future Work

This section outlines key areas requiring further discussion and defines potential avenues for future development.

* Slow Query Recorder Implementation: Detailed specifications for modify slow query recorder's implementation and its precise interaction mechanisms with temporary manifests are needed.
* Configurable Lingering Times: Establish and make configurable the specific lingering times for both obsolete and unused files to optimize storage reclamation and data availability.

Alternatives

1. Standalone GC Service

Instead of integrating the GC worker directly into the Compaction process, a standalone GC service could be implemented. This service would operate independently, periodically scanning the storage for obsolete and unused files based on manifest information and predefined retention policies.

Pros:
* Decoupling: Separates GC logic from compaction, allowing independent scaling and deployment.
* Flexibility: Can be configured to run at different frequencies and with different strategies than compaction.

Cons:
* Increased Complexity: Requires a separate service to manage, monitor, and coordinate with other components.
* Potential for Redundancy: May duplicate some file scanning logic already present in compaction.
* Consistency Challenges: Ensuring read consistency would require more complex coordination mechanisms between the standalone GC service and active queries, potentially involving a distributed lock manager or a more sophisticated temporary manifest system.

This alternative could be implemented in the future if the integrated GC worker proves insufficient or if there is a need for more advanced GC strategies.

2. Manifest-Driven Deletion (No Lingering Time)

This alternative would involve immediate deletion of files once they are removed from the manifest, without a lingering time.

Pros:
* Simplicity: Simplifies the GC logic by removing the need for lingering time management.
* Immediate Space Reclamation: Storage space is reclaimed as soon as files are marked for deletion.

Cons:
* Increased Risk of Data Loss: Higher risk of deleting files still in use by long-running queries or other processes if not perfectly synchronized.
* Complex Read Consistency: Requires extremely robust and immediate mechanisms to ensure that no active queries are referencing files marked for deletion, potentially leading to performance bottlenecks or complex error handling.
* Debugging Challenges: Difficult to debug issues related to premature file deletion due to the immediate nature of the operation.

---

Rfcs/2025 08 16 Async Index Build

---
Feature Name: Async Index Build
Tracking Issue: https://github.com/GreptimeTeam/greptimedb/issues/6756
Date: 2025-08-16
Author: "SNC123 <[email protected]>"
---

Summary


This RFC proposes an asynchronous index build mechanism in the database, with a configuration option to choose between synchronous and asynchronous modes, aiming to improve flexibility and adapt to different workload requirements.

Motivation


Currently, index creation is performed synchronously, which may lead to prolonged write suspension and impact business continuity. As data volume grows, the time required for index building increases significantly. An asynchronous solution is urgently needed to enhance user experience and system throughput.

Details

Overview

The following table highlights the difference between async and sync index approach:

| Approach | Trigger | Data Source | Additional Index Metadata Installation | Fine-grained FileMeta Index |
| :--- | :--- | :--- | :--- | :--- |
| Sync Index | On
write_sst | Memory (on flush) / Disk (on compact) | Not required(already installed synchronously) | Not required |
| Async Index | 4 trigger types | Disk | Required | Required |

The index build mode (synchronous or asynchronous) can be selected via configuration file.

Four Trigger Types

This RFC introduces four IndexBuildTypes to trigger index building:

- Manual Rebuild: Triggered by the user via ADMIN build_index("table_name"), for scenarios like recovering from failed builds or migrating data. SST files whose ColumnIndexMetadata (see below) is already consistent with the RegionMetadata will be skipped.
- Schema Change: Automatically triggered when the schema of an indexed column is altered.
- Flush: Automatically builds indexes for new SST files created by a flush.
- Compact: Automatically builds indexes for new SST files created by a compaction.

Additional Index Metadata Installation

Previously, index information in the in-memory FileMeta was updated synchronously. The async approach requires an explicit installation step.

A race condition can occur when compaction and index building run concurrently, leading to:
1. Building an index for a file that is about to be deleted by compaction.
2. Creating an unnecessary index file and an incorrect manifest record.
3. On restart, replaying the manifest could load metadata for a non-existent file.

To prevent this, the system checks if a file's FileMeta is in a compacting state before updating the manifest. If it is, the installation is aborted.

Fine-grained FileMeta Index

The original FileMeta only stored file-level index information. However, manual rebuilds require column-level details to identify files inconsistent with the current DDL. Therefore, the indexes field in FileMeta is updated as follows:

rust
struct FileMeta {
...
// From file-level:
// available_indexes: SmallVec<[IndexType; 4]>
// To column-level:
indexes: Vec<ColumnIndexMetadata>,
...
}
pub struct ColumnIndexMetadata {
pub column_id: ColumnId,
pub created_indexes: IndexTypes,
}
text

Process

The index building process is similar to a flush and is illustrated below:

mermaid
sequenceDiagram
Region0->>Region0: Triggered by one of 4 conditions, targets specific files
loop For each target file
Region0->>IndexBuildScheduler: Submits an index build task
end
IndexBuildScheduler->>IndexBuildTask: Executes the task
IndexBuildTask->>Storage Interfaces: Reads SST data from disk
IndexBuildTask->>IndexBuildTask: Builds the index file
alt Index file size > 0
IndexBuildTask->>Region0: Sends IndexBuildFinished notification
end
alt File exists in Version and is not compacting
Region0->>Storage Interfaces: Updates manifest and Version
end
`

Task Triggering and Scheduling

The process starts with one of the four IndexBuildType triggers. In handle_rebuild_index, the RegionWorkerLoop identifies target SSTs from the request or the current region version. It then creates an IndexBuildTask for each file and submits it to the index_build_scheduler.

Similar to Flush and Compact operations, index build tasks are ultimately dispatched to the LocalScheduler. Resource usage can be adjusted via configuration files. Since asynchronous index tasks are both memory-intensive and IO-intensive but have lower priority, it is recommended to allocate fewer resources to them compared to compaction and flush tasks—for example, limiting them to 1/8 of the CPU cores.

Index Building and Notification

The scheduled IndexBuildTask executes its index_build method. It uses an indexer_builder to create an Indexer that reads SST data and builds the index. If a new index file is created (IndexOutput.file_size > 0), the task sends an IndexBuildFinished notification back to the RegionWorkerLoop.

Index Metadata Installation

Upon receiving the IndexBuildFinished notification in handle_index_build_finished, the RegionWorkerLoop verifies that the file still exists in the current version and is not being compacted. If the check passes, it calls manifest_ctx.update_manifest to apply a RegionEdit` with the new index information, completing the installation.

Drawbacks

Asynchronous index building may consume extra system resources, potentially affecting overall performance during peak periods.

There may be a delay before the new index becomes available for queries, which could impact certain use cases.

Unresolved Questions and Future Work

Resource Management and Throttling: The resource consumption (CPU, I/O) of background index building can be managed and limited to some extent by configuring a dedicated background thread pool. However, this approach cannot fully eliminate resource contention, especially under heavy workloads or when I/O is highly competitive. Additional throttling mechanisms or dynamic prioritization may still be necessary to avoid impacting foreground operations.

Alternatives

Instead of being triggered by events like Flush or Compact, index building could be performed in batches during scheduled maintenance windows. This offers predictable resource usage but delays index availability.

---