Files
greptimedb/tests/compatibility
jeremyhi 60655a933b fix(log-store): persist object store WAL region releases (#9579)
* fix(log-store): persist object store WAL region releases

A region's release lived only in memory: a restart after DROP TABLE and
before the next object never re-established the dropped region's
watermark, so its segments counted as live and pinned garbage collection
of the whole prefix.

A release now writes a release record (region id and the entry id the
region is released through) into an object sealed at once, and returns
once that object is durable and indexed, in both acknowledgement modes.
Recovery seeds the obsolete watermarks from the release records of the
indexed objects before it computes any reclaim boundary. obsolete_all
releases in full; the new LogStore::release_region, a no-op by default,
releases a dropped region through the largest entry id the store handed
out for it, and Mito calls it after obsolete when it drops a region.

Objects with release records use format version 2, the version 1 layout
with the records at the end of the footer; every other object is still
written as version 1. A counter of release records made durable is added.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): describe persisted object store WAL region releases

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(log-store): carry a failed region release into the next object

A release whose batch failed left its record only in an orphan object,
so a drop that failed there could still leave the region unreleased
after a restart. The store now keeps the release records of failed
batches and seals them with its next batch, on a timer tick when no
entry is admitted, so the release becomes durable once a create
succeeds again.

The version 1 and version 2 object fixtures are frozen under
tests/compatibility/wal/object_store and loaded by the format tests,
and the RFC no longer says that state is persisted only in headers.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(mito2): complete a region drop whose WAL release fails

A drop request is not sent again once the region was removed, so a drop
whose WAL release failed used to leave the region half dropped. The
drop now completes, cleaning up the region's runtime state and
scheduling its files for deletion, and reports the release failure.

In the durable mode the object store WAL now creates one more object at
stop with the release records of failed and dropped batches, so a
graceful stop does not lose a release that failed. The release record
counter is described as counting the records of indexed objects.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(log-store): describe the stop-time release create of the WAL actor

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): state the accepted window of unreleased dropped regions

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(log-store): describe release waiters in the WAL actor's outcome table

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(log-store): fail region releases while the object store WAL is failing

A failing store keeps its backlog and rejects appends until a create
succeeds, so a release would otherwise wait for an upload that is not
coming. A release that arrives in that state now fails at once with the
retryable error, and one whose batch is still pending when the store
enters it fails as well. Neither poisons the store: the record of the
first waits for the next sealed batch, like that of a batch that failed,
and the record of the second stays in the backlog, so both become durable
once a create succeeds.

Rejected releases count in rejected_appends_total.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): describe region releases while the object store WAL is failing

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): place the drop crash window in the drop procedure

The crash window between the deletion of a table's metadata and a
durable release record is a gap of the drop procedure, not of the WAL.
Describe it as far as the code shows, name the remedy that belongs in
the procedure, cleaning up offline a region a resumed region drop finds
not open, and list it under future work.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(log-store): keep rejected release records coalesced while failing

A release rejected while the store is failing kept its record for the
next sealed batch, but every timer tick then sealed that record alone
behind the refused backlog, so a release repeated across ticks grew the
backlog without bound. A failing store now keeps such records coalesced
and seals them on the first tick after a create succeeds.

The fault-matrix case of a release while the backlog is refused now runs
with a 10 ms flush interval, repeats the release, and checks that the
backlog does not grow and that recovery reads the record back.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): keep rejected release records unsealed while failing

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): cite the drop procedure issue for the crash window

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(log-store): let a stop seal rejected release records of a failing store

Keeping the records of rejected releases out of sealed batches while the
store is failing also kept them out of the seal a stop requests, so a
stop whose last attempt of the backlog succeeded dropped them and the
released region pinned collection after the restart. A stop now seals
them with the backlog.

A fault-matrix case rejects a release while the store is failing, stops,
lets both creates succeed, and checks the record after a reopen.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): describe the stop of a failing store with rejected releases

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(log-store): keep release records readable by older object store WAL readers

Release records were written as a version 2 footer, which a build without
it refuses to read, so rolling a node back after a drop failed to open
the whole WAL prefix. They are now the single entry of a segment of the
reserved region u64::MAX at the first position of a version 1 object:
an older reader indexes that segment as a region it never opens, and
recovery reads the segment back with one range read per object that
holds one. The segment never holds the reclaim boundary and names no
namespace.

A durable stop whose last create of release records fails now reports
the error instead of returning Ok and losing the records silently.

The tests are trimmed: the release unit tests and fault-matrix cases
that repeated each other are merged, and the Mito drop tests share one
parameterized case.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): describe release records as a segment of a reserved region

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(log-store): report release records a poisoning stop drops

A durable stop whose last create of release records met a conflict
poisoned the store and returned Ok, losing the records silently. Stop
now reports the error whenever release records are dropped after it
began, by a rollback or by poisoning. The test of a failed release
covers that conflict, and seals its record on a tick after a one-shot
create failure, so the next tick cannot fail too.

The documentation now says that recovery reads the segments of release
records, and that one that does not decode fails recovery.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): describe how recovery reads segments of release records

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): say that stop reports release records a conflict drops

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test(log-store): restore the transiently failing release create in enqueued mode

The fault matrix again covers a release whose create fails transiently in
the enqueued mode: the release waits for the repeat under the same
sequence and returns once it succeeds. The encode_batch doc now says the
release segment is the last segment of the object.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): place the release segment and count the restored case

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(mito2): do not fail a region drop on a failed WAL release

Returning the release error from the drop left a metric physical region
half dropped: the metric engine stops after the data region and never
drops the metadata region or forgets the physical region, and a retried
drop finds the data region gone and counts it as done. The release error
gave the caller nothing to retry, since the region is already removed
and the log store keeps the record for its next object. The drop now
logs the error and succeeds.

A metric engine test drops a physical region while the WAL releases of
both mito regions fail, and checks that both regions and the physical
region state are gone.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): a failed release no longer fails the drop

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(mito2): do not fail a region drop on a failed WAL obsolete

A store that the release of another region poisoned fails obsolete as
well, which left the metric engine with its metadata region undropped.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test(metric-engine): drop a physical region whose WAL obsolete or release fails

Cover a failed data release, two failed releases and a conflicting
release that poisons the store, after the regions wrote to the WAL, and
check that a store opened later keeps no segment of either region.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): a failed obsolete does not fail the drop either

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix(mito2): fail the drop on an obsolete error of other WAL providers

Only the object store WAL is shared by the regions of a store that the
release of one can poison; Raft Engine and Kafka keep failing the drop
when obsolete fails.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs(rfc): a stopped or poisoned store keeps no failed release

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
2026-10-15 03:27:43 +00: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)'

Object store WAL fixtures

Frozen objects of the object store WAL are checked byte for byte by the format tests of log-store. The SQL runner cannot cover the object store WAL: it owns wal.provider. See the fixture notes.

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.