Files
greptimedb/tests/compatibility
discord9 7309800aef feat(datatypes): describe label decoding in the raw series frame schema (#9786)
* feat(datatypes): describe label decoding in the raw series frame schema

A raw series frame written with key-encoded labels carries the primary key
as opaque bytes; only the binding held by the scanner says how to turn
those bytes back into a metric name and labels. A consumer that receives
the frame without that binding cannot decode it, so the schema now
describes its own label layout.

The description is compact JSON under `greptime.series.label_decode`,
carrying a version, the metric name, the primary-key encoding, the ordered
key columns with their physical types, and the logical tags to expose
-- exactly what decoding reads. It is derived from the binding, so there
is one writer, and a reader that does not implement the version it finds
rejects the frame instead of guessing.

`unpack_raw_series` keeps its binding argument and now requires it to
agree with the description the schema declares, so the two cannot drift.
`PrimaryKeyLabelDecoder` reduces both of its inputs to one internal key
layout, so the metadata-backed and description-backed paths share their
validation instead of growing a second decoder.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(datatypes): compare label descriptions by what their order means

The frame's declared label-decode description was compared to the binding's with
plain equality, but the description has one part whose order carries no meaning:
the decoder sorts logical tags by name when it binds them, so the same tags listed
in another order denote the same labels and were being refused.

`same_label_layout` canonicalises exactly that part and leaves `primary_key` in
place, because for a dense key the array position is the encoding position: the same
columns in another order are a different key, not the same key reordered. Both
fields now say which order they mean.

Three checks go with it. The binding stays for a stated reason -- it establishes
identity, the physical region key layout a decoded identity came from -- rather than
because a count of call sites kept it. The post-pack schema comparison in the series
scan could not fire: the leaf already re-derives that schema and compares it, and
every `SeriesSchema` field is covered by the checks between them. And two test
groups re-asserted what the tracked v1_metadata fixture pins case by case.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* feat(promql): decode a raw frame from the frame alone

`unpack_raw_series` took the label mode, the ordering and the caller's binding
along with the stream, so a consumer that received a frame from a peer still
needed a local copy of the binding to read it — the thing the frame's own
description exists to avoid, and the reason the description was freezing a wire
format for nobody.

The stream is now the only input: the field schema, the label mode, the label
decode description and the producer ordering all come from the frame's schema,
and every frame is validated whether or not a caller asked for it. `expected`
stays as an optional check on the schema that is declared — field names and their
order, and no evaluation grid — and supplies nothing to the decoder, so a caller
cannot smuggle in a layout the frame does not declare.

Three call sites compared the caller's binding against the frame and all three
were load-bearing: a peer's response is not trusted to be the region that was
planned, and a native plan's child is an arbitrary execution plan. Those became
`ensure_frame_matches_label_source`, which states what it compares: the label
layout, not the region. Two regions with the same tag ids and names compare equal,
and the doc now says so instead of claiming to establish identity.

A stream with no scan-bound identity reports `NO_BOUND_LABEL_SOURCE` and answers
`label_decode_spec()` instead; every consumer that decides whether to decode reads
the description, so the placeholder never stands in for "these labels are
complete".

The merge contract is the description, not the binding: inputs are compared by
the layout they declare, and a binding that could not be expressed on the wire — a
`RegionMetadata` snapshot — no longer makes the merge stricter than the frames it
merges. `logical_tags` gets one rule rather than two: the writer canonicalizes a
copy, because the caller's binding keeps its own order and the table scan compares
producer tag lists positionally, and readers compare by equality.

The text embeds `ConcreteDataType`'s serde, which `LABEL_DECODE_VERSION` has to
cover. Rather than mirror all 25 datatype variants into a type this module owns, a
frozen fixture records the text of every variant and an exhaustive match makes a
new variant fail to compile until it is recorded. The version policy is written
next to the constant: exact-text comparison across nodes, unknown versions
rejected, any same-version change breaking — with the planning-time fallback
stated as intended and marked not implemented.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(store-api): make every key-encoded raw stream declare its layout

A raw series stream whose chunks carry key-encoded labels had to be decoded
against a scan-bound `RawLabelSource`, and two consumers compensated for
producers that stayed silent by deriving a description from that binding:
`pack_raw_series` and the merge's `declared_layout`. `materialize_raw_labels`
did not compensate, so an unframed producer stream reached it with no
description and a chunk carrying no labels, and failed.

The stream itself is the authority for its chunks. A key-encoded producer
knows its own region metadata and can state the layout it writes, exactly as a
frame states the layout it carries, so the split contract — "a producer
declares its identity, a frame declares its layout" — is replaced by one rule:
a stream whose chunks carry key-encoded labels declares the description, and a
stream whose chunks carry complete labels answers `None`.

mito2's `RawStream` caches the derivation at construction, so the declaration
exists before the first poll and for an empty stream. The trait documentation
states the rule and no longer exempts unframed producers. Both fallbacks are
gone: `pack_raw_series` reads the stream's declaration only, and rejects a
key-encoded stream that declares none rather than packing it as an
explicit-label frame, which would describe chunks carrying no labels as if they
carried all of them. The merge reads the declaration only.

Test fixtures that model key-encoded producers now declare their layout too;
fixtures that carry complete labels keep answering `None`. The new mito2 test
decodes a real produced key through the declared description for both dense and
sparse encodings, and checks the declaration is present before polling even for
an empty stream.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(common-query): let the declaration decide the frame mode alone

`pack_raw_series` rejected a key-encoded stream that declared no description
outright, so a stream that stays silent could not be packed at all. That check
was redundant: the declaration already decides the frame's label mode, and the
first chunk whose labels disagree with it is refused by `RawSeriesChunkCursor`
with "raw chunk label mode does not match frame mode", so no such stream can
pack data either way.

What the check added was an early error for a producer that already breaks the
contract, in exchange for reading the stream's scan binding here — in the one
function whose rule is that the declaration is the only decode input. Drop it,
so pack keeps deciding the mode from the declaration and nothing else.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(store-api): assert the fixture's declared label decode description

The mock prepared source derives a `LabelDecodeSpec` from its label source and
reports it through `RawSeriesStream::label_decode_spec`, but no assertion read
it, so the derivation could drift from the chunks the fixture actually builds.
Check it against the description the fixture's own key columns imply: an
explicit source declares nothing, a primary-key source declares the metric, the
dense encoding, the one key column and its tag.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): let the frame's declaration be the only label authority

Two things still treated a caller-supplied binding as authoritative after the
declaration became the decode input:

- A merged stream forwarded its first input's scan-bound identity, though a
  merge is not a region scan and decodes from the declaration alone. It now
  reports no bound identity.
- `KeyColumn.tag` repeated an invariant every key column is a tag. The check
  moves to the one place a decoder is bound, so a snapshot built without
  validation is still rejected instead of silently decoded.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 19:11:24 +08:00
..

GreptimeDB Compatibility Test Framework

Compatibility tests verify that one GreptimeDB version can restart on state written by another version.

SQL restart tests are run via cargo sqlness compat and reuse the sqlness-runner infrastructure.

Wire fixtures

Frozen protobuf fixtures are exercised by the Rust tests that own their context conversion, not by SQLness case discovery. The legacy query-context fixture checks omitted read-preference defaulting and unrelated metadata preservation:

cargo nextest run -p session -E 'test(test_legacy_query_context_wire_defaults_to_leader)'

Quick Start

# Self-compat smoke test (current binary only):
cargo run -p sqlness-runner -- compat

# Test from a specific released version to current:
cargo run -p sqlness-runner -- compat --from-version v0.9.5

# Test between two local binary directories:
cargo run -p sqlness-runner -- compat --from-bins-dir ./bins/old --to-bins-dir ./bins/new

# Test a downgrade from the current build to a released binary:
cargo run -p sqlness-runner -- compat --from-bins-dir ./bins/current --to-version v1.1.4

# Run a compatibility case in standalone mode:
cargo run -p sqlness-runner -- compat --topology standalone --test-filter "downgrade_compatibility"

# Run a specific case:
cargo run -p sqlness-runner -- compat --test-filter "basic_table"

# Preview which cases would run (no services started):
cargo run -p sqlness-runner -- compat --dry-run --from-version v0.9.5

# See all options:
cargo run -p sqlness-runner -- compat --help

Prerequisites

  • Docker (for etcd): PR1 always uses Docker etcd for distributed metadata. External metadata stores are future work.
  • From binary: Either --from-version <version> to auto-pull a release, or --from-bins-dir <path> to use a local build. The binary greptime must exist directly inside the given directory.
  • To binary: Defaults to the current debug build (target/debug/greptime). Override with --to-bins-dir <path> or fetch a release with --to-version <version>.
  • Custom target-dir: If you use a non-default CARGO_TARGET_DIR, the debug binary won't be at target/debug/greptime. Pass --from-bins-dir / --to-bins-dir explicitly pointing to your custom target directory. Alternatively, run cargo build -p greptime without a custom target-dir.

Case Format

Each compat case is a directory under tests/compatibility/cases/ containing three required files plus an expected output file:

my_case/
  case.toml       # Metadata (required)
  setup.sql       # SQL to run on the from version (required)
  verify.sql      # SQL to run on the to version (required)
  verify.result   # Expected output from verify.sql

case.toml — Required Metadata

name = "my_case"
reason = "Why this compatibility case exists"
introduced_by = "PR #1234 or feature name"
topologies = ["distributed", "standalone"]
from_range = ["*"]
to_range = ["*"]
features = ["table"]
owner = "team-name"
# optional:
namespace = "my_explicit_namespace"   # defaults to sanitized directory name

Required fields: name, reason, introduced_by, topologies, from_range, to_range, features, owner.

Old-Stage Datanode Configuration Overlay

To apply a datanode configuration overlay while running the old stage, add this strict optional table to case.toml:

[old_config]
datanode = "old-datanode.overlay.toml"

datanode is required whenever [old_config] is present; empty tables and unknown keys are rejected. The reference is relative to the case directory and must remain confined to that directory. The sidecar is native datanode TOML, which the runner loads and preflights before starting services or creating state.

The runner first applies the datanode baseline, then merges the sidecar. Tables merge recursively only when both values are tables. Scalars, type mismatches, arrays, and arrays of tables replace the baseline value atomically. In particular, region_engine has no special merge behavior.

Runner-owned fields cannot be changed by an overlay: mode, node_id, storage.data_home, meta_client_options.metasrv_addrs, and wal.provider, plus wal.dir for Raft WAL or wal.broker_endpoints for Kafka WAL. The runner restores these fields to its baseline values, or deletes them when the baseline has no value. It warns about protected-field overrides without printing their values.

Version-Range Filtering

from_range and to_range control which binary versions a case applies to:

Entry Meaning
"*" Matches any version (including unknown).
"vX.Y.Z" or "=vX.Y.Z" Matches exactly version X.Y.Z.
">=vX.Y.Z" Matches X.Y.Z or later.
">vX.Y.Z" Matches versions strictly later than X.Y.Z.
"<=vX.Y.Z" Matches X.Y.Z or earlier.
"<vX.Y.Z" Matches versions strictly earlier than X.Y.Z.

The range list is OR: a case matches if any entry matches.

Best-effort enforcement: The runner tries to determine the effective version:

  • --from-version is used directly.
  • --from-bins-dir / --to-bins-dir (or the default debug build) runs <binary> --version to infer the version.
  • When the version cannot be determined (e.g. binary missing or --version fails), non-wildcard ranges are skipped with a message; wildcard (*) ranges still match.

Example (legacy_jsonb):

from_range = ["<=v1.1.0"]
to_range = [">=v1.1.1"]

This case only runs when the old binary is <= v1.1.0 and the new binary is >= v1.1.1.

CI Version Window

The CI job uses tests/compatibility/ci.toml to choose the small sliding window of recent released from versions to test against the PR-built to binary:

from_versions = ["v1.0.0", "v1.1.0"]

Keep this window small for PR and merge-queue latency: the goal is to catch upgrade compatibility issues from recent releases to the latest build, not to retest every historical version on every PR. Case-level from_range/to_range still decides which cases run for each version pair; the CI window only decides which old binaries are sampled. Broader historical windows belong in nightly or release-validation workflows.

The GitHub Actions workflow delegates the window loading and compat invocation to .github/scripts/run-compat.py; the workflow YAML should stay as a thin wrapper around artifact download/extraction and this script.

downgrade_to_versions optionally lists releases that CI restarts after the PR-built cluster. Those runs select only the downgrade_compatibility case in both distributed and standalone topologies.

setup.sql — Setup Phase (From Version)

SQL statements executed on the from version cluster. These must succeed (any error fails the case). Setup output is NOT compared against any result file.

Rules:

  • Statements are semicolon-terminated
  • -- prefix for ordinary comments
  • -- SQLNESS ... interceptor comments follow ordinary sqlness semantics

verify.sql — Verify Phase (To Version)

SQL statements executed on the to version cluster. Output is compared against verify.result in sqlness snapshot style.

verify.result — Expected Output

Expected output in sqlness format. If this file is missing, the runner generates it from actual output and fails — the author must review, commit the generated file, and rerun.

<statement>;

<output>

<next statement>;

<output>

If output differs from expected, the run fails and verify.result is updated with actual output.

PR1 Limitations

  • Sqlness interceptors: -- SQLNESS ... comments are applied per statement using the same interceptor registry as the ordinary sqlness runner, including the GreptimeDB PROTOCOL interceptor. For PROTOCOL POSTGRES, the namespace prelude uses SET search_path instead of USE. Avoid unqualified PostgreSQL-protocol table names starting with pg_: GreptimeDB's current PostgreSQL compatibility parser rewrites them to pg_catalog.<table>.
  • Distributed topology: The compat runner starts 1 metasrv + 3 datanodes + 1 frontend + 1 flownode. Standalone compatibility runs need no external metadata store.
  • No comment-based compat config: The compat runner does not define extra compatibility configuration in SQL comments; sqlness comments keep their normal sqlness meaning.

Namespace Isolation

Each case runs in its own database namespace to prevent cross-case interference:

  • Default namespace is derived from the case directory name (sanitized to [a-z][a-z0-9_]*)
  • Override with namespace in case.toml
  • Duplicate namespaces are rejected at discovery time (before version filtering)
  • Before each statement, the runner executes a namespace prelude (not written to verify.result): CREATE DATABASE IF NOT EXISTS <ns> via gRPC; then USE <ns> for gRPC/MySQL statements or SET search_path TO '<ns>' for PostgreSQL statements.

Batch Behavior

  • The baseline (no-overlay) profile runs first. Cases whose old datanode TOML is semantically equivalent share one profile; profiles run serially and in isolation.
  • Each profile has its own state and etcd lifecycle. Its overlay is applied only to old-stage datanodes and remains in effect through old-stage setup restarts. The current stage always uses a clean configuration.
  • Cases run serially (no parallelism in PR1). Namespace state is session/protocol state and cannot be shared concurrently.
  • Same namespace across cases is rejected.
  • Without fail-fast, the runner verifies only cases whose setup succeeded. With fail-fast, it cleans up the active profile before stopping.
  • --dry-run displays the selected profiles, cases, and sidecar paths without printing configuration values; it starts no services.

xfail Policy (Future)

For PR1, all cases are expected to pass. Future PRs will add xfail support with required issue and expiry fields.

Cross-Job Distributed State

PR1 runs setup and verify in the same job (same process). Cross-job artifact restore for distributed state is not supported in PR1 due to port randomization and etcd lease expiration.