You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2026/04/26 07:53:31 UTC
(skywalking-banyandb) branch main updated: Schema consistency Phase 1: revision gates and barrier RPCs (#1091)
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 69c8f4d20 Schema consistency Phase 1: revision gates and barrier RPCs (#1091)
69c8f4d20 is described below
commit 69c8f4d20ebb6532ea4c16a7ed7114dd6ec9770b
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Sun Apr 26 15:53:26 2026 +0800
Schema consistency Phase 1: revision gates and barrier RPCs (#1091)
---
CHANGES.md | 12 +
.../banyandb/cluster/v1/node_schema_status.proto | 81 +++
api/proto/banyandb/common/v1/common.proto | 2 +
api/proto/banyandb/database/v1/rpc.proto | 72 ++-
api/proto/banyandb/database/v1/schema.proto | 14 +
api/proto/banyandb/measure/v1/query.proto | 8 +
api/proto/banyandb/model/v1/write.proto | 1 +
api/proto/banyandb/schema/v1/barrier.proto | 104 ++++
api/proto/banyandb/stream/v1/query.proto | 8 +
api/proto/banyandb/trace/v1/query.proto | 8 +
banyand/liaison/grpc/barrier.go | 269 ++++++++++
banyand/liaison/grpc/barrier_test.go | 426 ++++++++++++++++
banyand/liaison/grpc/clamp_test.go | 140 ++++++
banyand/liaison/grpc/deletion.go | 18 +-
banyand/liaison/grpc/deletion_test.go | 28 +-
banyand/liaison/grpc/discovery_test.go | 214 ++++++++
banyand/liaison/grpc/measure.go | 62 ++-
banyand/liaison/grpc/measure_write_test.go | 132 +++++
banyand/liaison/grpc/query_gate_test.go | 197 ++++++++
banyand/liaison/grpc/registry.go | 83 ++--
banyand/liaison/grpc/schema_revision.go | 139 ++++++
banyand/liaison/grpc/server.go | 22 +-
banyand/liaison/grpc/stream.go | 58 ++-
banyand/liaison/grpc/stream_write_test.go | 132 +++++
banyand/liaison/grpc/trace.go | 70 ++-
banyand/liaison/grpc/trace_write_test.go | 138 ++++++
banyand/liaison/grpc/write_helpers_test.go | 118 +++++
banyand/measure/metadata.go | 7 +-
banyand/measure/metadata_test.go | 17 +-
banyand/measure/metrics.go | 6 +-
banyand/measure/svc_data.go | 6 +-
banyand/metadata/schema/property/cache.go | 142 +++++-
banyand/metadata/schema/property/cache_test.go | 55 +++
banyand/metadata/schema/property/client.go | 278 +++++++++--
banyand/metadata/schema/property/client_test.go | 76 +--
banyand/metadata/schema/property/converter.go | 122 ++++-
banyand/metadata/schema/property/converter_test.go | 5 +-
.../metadata/schema/property/created_at_test.go | 123 +++++
.../schema/property/tombstone_invariant_test.go | 103 ++++
banyand/metadata/schema/schema.go | 32 +-
banyand/metadata/schema/schemaserver/service.go | 13 +-
banyand/property/db/shard.go | 15 +-
banyand/property/gossip/trace.go | 3 +-
banyand/stream/metadata.go | 8 +-
banyand/stream/metadata_test.go | 13 +-
banyand/trace/metadata.go | 8 +-
banyand/trace/metadata_test.go | 18 +-
docs/api-reference.md | 547 ++++++++++++++++++++-
docs/interacting/schema-consistency/barriers.md | 194 ++++++++
docs/interacting/schema-consistency/overview.md | 68 +++
.../schema-consistency/revision-gates.md | 141 ++++++
docs/interacting/schema-consistency/scenarios.md | 278 +++++++++++
docs/menu.yml | 10 +
pkg/meter/native/provider.go | 3 +-
pkg/schema/cache.go | 239 ++++++---
pkg/schema/cache_test.go | 170 +++++++
pkg/schema/schema.go | 16 +-
pkg/test/helpers/constant.go | 3 +-
pkg/test/measure/schema.go | 21 +-
pkg/test/property/schema.go | 2 +-
pkg/test/replicated/measure/etcd.go | 6 +-
pkg/test/replicated/stream/etcd.go | 4 +-
pkg/test/replicated/trace/etcd.go | 3 +-
pkg/test/setup/watch_control.go | 42 ++
pkg/test/stream/schema.go | 8 +-
pkg/test/trace/schema.go | 15 +-
test/cases/schema/apply_rollback.go | 525 ++++++++++++++++++++
test/cases/schema/clamp.go | 313 ++++++++++++
test/cases/schema/clients.go | 176 +++++++
test/cases/schema/deletion.go | 26 +-
test/cases/schema/query_gate.go | 373 ++++++++++++++
test/cases/schema/shape_break.go | 523 ++++++++++++++++++++
test/cases/schema/tombstone_gc.go | 187 +++++++
test/cases/schema/write_gate.go | 384 +++++++++++++++
test/integration/distributed/schema/common.go | 1 +
test/integration/standalone/schema/common.go | 3 +-
76 files changed, 7526 insertions(+), 361 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 8ed6a0415..aee219881 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -19,6 +19,18 @@ Release Notes.
- The `--namespace` CLI flag has been removed (it previously configured the etcd key prefix).
- The `--node-discovery-mode` flag no longer accepts `etcd` (supported values: `none`, `dns`, `file`).
- The `--schema-registry-mode` flag only accepts `property`.
+- Schema consistency (Phase 1): introduce client-observable revision and propagation primitives. All gates are opt-in and zero-valued requests preserve prior behavior.
+ - Add `mod_revision` to Group / IndexRule / IndexRuleBinding / TopNAggregation Create and Update responses.
+ - Add `delete_time` to all `*ServiceDeleteResponse` messages so clients can observe tombstones.
+ - Add `created_at` to Stream / Measure / Trace / Property / IndexRule / IndexRuleBinding / TopNAggregation / Group; preserved across updates.
+ - Add `STATUS_SCHEMA_NOT_APPLIED` (10) for writes/queries whose `mod_revision` is ahead of the server cache.
+ - Add three-way write-path `ModRevision` gate on Stream and Measure write RPCs (`<` expired, `==` succeed, `>` not-applied, `0` skipped).
+ - Add per-group query-path gate via `QueryRequest.group_mod_revisions` and `QueryResponse.group_statuses` for Stream / Measure / Trace queries.
+ - Add automatic time-range clamp `max(time_range.start, schema.created_at)`; multi-group uses the maximum across queried groups; nil for pre-upgrade schemas is a no-op.
+ - Add `SchemaBarrierService` with `AwaitRevisionApplied`, `AwaitSchemaApplied`, `AwaitSchemaDeleted` (10 000-key cap, timeout-bounded, returns laggards on expiry).
+ - Add tombstone retention/GC (default 7 days, configurable via `--schema-server-tombstone-retention`) with a per-cache count cap to bound memory under bulk deletes.
+ - Reject `Create` with `updated_at <= tombstone.delete_time` to prevent replayed creates from overwriting newer deletes.
+ - Guard `pkg/schema/cache` against out-of-order `EventDelete` events; expose monotonic `LatestModRevision` watermark.
### Bug Fixes
diff --git a/api/proto/banyandb/cluster/v1/node_schema_status.proto b/api/proto/banyandb/cluster/v1/node_schema_status.proto
new file mode 100644
index 000000000..fe395724b
--- /dev/null
+++ b/api/proto/banyandb/cluster/v1/node_schema_status.proto
@@ -0,0 +1,81 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+syntax = "proto3";
+
+package banyandb.cluster.v1;
+
+import "banyandb/schema/v1/barrier.proto";
+
+option go_package = "github.com/apache/skywalking-banyandb/api/proto/banyandb/cluster/v1";
+
+// NodeSchemaStatusService is exposed by every data node so the liaison can
+// inspect each node's local schema cache when satisfying a SchemaBarrierService
+// request. The implementation lands in Step 2.1.
+service NodeSchemaStatusService {
+ // GetMaxRevision returns the highest mod_revision currently observed by the
+ // node's schema cache.
+ rpc GetMaxRevision(GetMaxRevisionRequest) returns (GetMaxRevisionResponse);
+ // GetKeyRevisions returns the per-key mod_revision observed by the node, and
+ // a presence flag for each key.
+ rpc GetKeyRevisions(GetKeyRevisionsRequest) returns (GetKeyRevisionsResponse);
+ // GetAbsentKeys partitions the requested keys into those the node has
+ // already removed and those that are still present.
+ rpc GetAbsentKeys(GetAbsentKeysRequest) returns (GetAbsentKeysResponse);
+}
+
+// GetMaxRevisionRequest carries no parameters; the node returns its current
+// max mod_revision.
+message GetMaxRevisionRequest {}
+
+// GetMaxRevisionResponse holds the node's current max mod_revision.
+message GetMaxRevisionResponse {
+ int64 max_mod_revision = 1;
+}
+
+// GetKeyRevisionsRequest names the keys whose per-key revisions the caller
+// wants to inspect.
+message GetKeyRevisionsRequest {
+ repeated banyandb.schema.v1.SchemaKey keys = 1;
+}
+
+// KeyRevision pairs a SchemaKey with the node's local mod_revision and
+// presence flag.
+message KeyRevision {
+ banyandb.schema.v1.SchemaKey key = 1;
+ int64 mod_revision = 2;
+ bool present = 3;
+}
+
+// GetKeyRevisionsResponse lists per-key revisions in the same order the
+// caller supplied keys.
+message GetKeyRevisionsResponse {
+ repeated KeyRevision revisions = 1;
+}
+
+// GetAbsentKeysRequest names the keys whose absence the caller wants to
+// confirm.
+message GetAbsentKeysRequest {
+ repeated banyandb.schema.v1.SchemaKey keys = 1;
+}
+
+// GetAbsentKeysResponse partitions the requested keys into absent and
+// still-present subsets.
+message GetAbsentKeysResponse {
+ repeated banyandb.schema.v1.SchemaKey absent_keys = 1;
+ repeated banyandb.schema.v1.SchemaKey still_present_keys = 2;
+}
diff --git a/api/proto/banyandb/common/v1/common.proto b/api/proto/banyandb/common/v1/common.proto
index 644b08de9..499c6632f 100644
--- a/api/proto/banyandb/common/v1/common.proto
+++ b/api/proto/banyandb/common/v1/common.proto
@@ -119,4 +119,6 @@ message Group {
ResourceOpts resource_opts = 3;
// updated_at indicates when resources of the group are updated
google.protobuf.Timestamp updated_at = 4;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 5;
}
diff --git a/api/proto/banyandb/database/v1/rpc.proto b/api/proto/banyandb/database/v1/rpc.proto
index d9a1cda92..a7d7a882a 100644
--- a/api/proto/banyandb/database/v1/rpc.proto
+++ b/api/proto/banyandb/database/v1/rpc.proto
@@ -52,6 +52,10 @@ message StreamRegistryServiceDeleteRequest {
message StreamRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message StreamRegistryServiceGetRequest {
@@ -114,13 +118,19 @@ message IndexRuleBindingRegistryServiceCreateRequest {
banyandb.database.v1.IndexRuleBinding index_rule_binding = 1;
}
-message IndexRuleBindingRegistryServiceCreateResponse {}
+message IndexRuleBindingRegistryServiceCreateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message IndexRuleBindingRegistryServiceUpdateRequest {
banyandb.database.v1.IndexRuleBinding index_rule_binding = 1;
}
-message IndexRuleBindingRegistryServiceUpdateResponse {}
+message IndexRuleBindingRegistryServiceUpdateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message IndexRuleBindingRegistryServiceDeleteRequest {
banyandb.common.v1.Metadata metadata = 1;
@@ -128,6 +138,10 @@ message IndexRuleBindingRegistryServiceDeleteRequest {
message IndexRuleBindingRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message IndexRuleBindingRegistryServiceGetRequest {
@@ -190,13 +204,19 @@ message IndexRuleRegistryServiceCreateRequest {
banyandb.database.v1.IndexRule index_rule = 1;
}
-message IndexRuleRegistryServiceCreateResponse {}
+message IndexRuleRegistryServiceCreateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message IndexRuleRegistryServiceUpdateRequest {
banyandb.database.v1.IndexRule index_rule = 1;
}
-message IndexRuleRegistryServiceUpdateResponse {}
+message IndexRuleRegistryServiceUpdateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message IndexRuleRegistryServiceDeleteRequest {
banyandb.common.v1.Metadata metadata = 1;
@@ -204,6 +224,10 @@ message IndexRuleRegistryServiceDeleteRequest {
message IndexRuleRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message IndexRuleRegistryServiceGetRequest {
@@ -284,6 +308,10 @@ message MeasureRegistryServiceDeleteRequest {
message MeasureRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message MeasureRegistryServiceGetRequest {
@@ -346,13 +374,19 @@ message GroupRegistryServiceCreateRequest {
banyandb.common.v1.Group group = 1;
}
-message GroupRegistryServiceCreateResponse {}
+message GroupRegistryServiceCreateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message GroupRegistryServiceUpdateRequest {
banyandb.common.v1.Group group = 1;
}
-message GroupRegistryServiceUpdateResponse {}
+message GroupRegistryServiceUpdateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
// GroupRegistryServiceDeleteRequest is the request for deleting a group.
message GroupRegistryServiceDeleteRequest {
@@ -373,6 +407,10 @@ message GroupRegistryServiceDeleteRequest {
message GroupRegistryServiceDeleteResponse {
// schema_info contains the schema resources that would be deleted (populated in dry-run mode).
SchemaInfo schema_info = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
// GroupDeletionTask represents the status of a group deletion operation.
@@ -604,13 +642,19 @@ message TopNAggregationRegistryServiceCreateRequest {
banyandb.database.v1.TopNAggregation top_n_aggregation = 1;
}
-message TopNAggregationRegistryServiceCreateResponse {}
+message TopNAggregationRegistryServiceCreateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message TopNAggregationRegistryServiceUpdateRequest {
banyandb.database.v1.TopNAggregation top_n_aggregation = 1;
}
-message TopNAggregationRegistryServiceUpdateResponse {}
+message TopNAggregationRegistryServiceUpdateResponse {
+ // mod_revision is the etcd revision assigned by the server on successful create/update.
+ int64 mod_revision = 1;
+}
message TopNAggregationRegistryServiceDeleteRequest {
banyandb.common.v1.Metadata metadata = 1;
@@ -618,6 +662,10 @@ message TopNAggregationRegistryServiceDeleteRequest {
message TopNAggregationRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message TopNAggregationRegistryServiceGetRequest {
@@ -723,6 +771,10 @@ message PropertyRegistryServiceDeleteRequest {
message PropertyRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message PropertyRegistryServiceGetRequest {
@@ -803,6 +855,10 @@ message TraceRegistryServiceDeleteRequest {
message TraceRegistryServiceDeleteResponse {
bool deleted = 1;
+ // delete_time is the server-assigned tombstone timestamp in unix nanos.
+ int64 delete_time = 2;
+ // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+ int64 mod_revision = 3;
}
message TraceRegistryServiceGetRequest {
diff --git a/api/proto/banyandb/database/v1/schema.proto b/api/proto/banyandb/database/v1/schema.proto
index d23594184..33fd3abbc 100644
--- a/api/proto/banyandb/database/v1/schema.proto
+++ b/api/proto/banyandb/database/v1/schema.proto
@@ -59,6 +59,8 @@ message Stream {
Entity entity = 3 [(validate.rules).message.required = true];
// updated_at indicates when the stream is updated
google.protobuf.Timestamp updated_at = 4;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 5;
}
message Entity {
@@ -119,6 +121,8 @@ message Measure {
bool index_mode = 7;
// sharding_key determines the distribution of TopN-related data.
ShardingKey sharding_key = 8;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 9;
}
// TopNAggregation generates offline TopN statistics for a measure's TopN approximation
@@ -145,6 +149,8 @@ message TopNAggregation {
int32 lru_size = 8;
// updated_at indicates when the measure is updated
google.protobuf.Timestamp updated_at = 9;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 10;
}
// IndexRule defines how to generate indices based on tags and the index type
@@ -180,6 +186,8 @@ message IndexRule {
string analyzer = 5;
// no_sort indicates whether the index is not for sorting.
bool no_sort = 6;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 7;
}
// Subject defines which stream or measure would generate indices
@@ -208,6 +216,8 @@ message IndexRuleBinding {
google.protobuf.Timestamp expire_at = 5 [(validate.rules).timestamp.required = true];
// updated_at indicates when the IndexRuleBinding is updated
google.protobuf.Timestamp updated_at = 6;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 7;
}
// Property stores the user defined data
@@ -218,6 +228,8 @@ message Property {
repeated TagSpec tags = 2 [(validate.rules).repeated.min_items = 1];
// updated_at indicates when the property is updated
google.protobuf.Timestamp updated_at = 6;
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 7;
}
// TraceTagSpec defines the specification of a tag in a trace.
@@ -245,4 +257,6 @@ message Trace {
google.protobuf.Timestamp updated_at = 5;
// span_id_tag_name is the name of the tag that stores the span ID.
string span_id_tag_name = 6 [(validate.rules).string.min_len = 1];
+ // created_at is the first-appearance timestamp; survives updates unchanged.
+ google.protobuf.Timestamp created_at = 7;
}
diff --git a/api/proto/banyandb/measure/v1/query.proto b/api/proto/banyandb/measure/v1/query.proto
index e518762cc..2caceaaac 100644
--- a/api/proto/banyandb/measure/v1/query.proto
+++ b/api/proto/banyandb/measure/v1/query.proto
@@ -22,6 +22,7 @@ package banyandb.measure.v1;
import "banyandb/common/v1/trace.proto";
import "banyandb/model/v1/common.proto";
import "banyandb/model/v1/query.proto";
+import "banyandb/model/v1/write.proto";
import "google/protobuf/timestamp.proto";
import "validate/validate.proto";
@@ -53,6 +54,9 @@ message QueryResponse {
repeated DataPoint data_points = 1;
// trace contains the trace information of the query when trace is enabled
common.v1.Trace trace = 2;
+ // group_statuses reports the per-group gate outcome. Populated even when the query
+ // short-circuits because any group failed the gate.
+ map<string, model.v1.Status> group_statuses = 50;
}
// InternalDataPoint wraps DataPoint with shard information for internal use.
@@ -142,4 +146,8 @@ message QueryRequest {
repeated string stages = 14;
// rewrite_agg_top_n_result will rewrite agg result to raw data
bool rewrite_agg_top_n_result = 15;
+ // group_mod_revisions gates the query per group. Keys match entries in `groups`;
+ // values are the client's known mod_revision for that group. Empty map or value 0
+ // means "don't gate". A group not listed in the map is not gated.
+ map<string, int64> group_mod_revisions = 50;
}
diff --git a/api/proto/banyandb/model/v1/write.proto b/api/proto/banyandb/model/v1/write.proto
index 3c7a35040..20f76c790 100644
--- a/api/proto/banyandb/model/v1/write.proto
+++ b/api/proto/banyandb/model/v1/write.proto
@@ -34,4 +34,5 @@ enum Status {
STATUS_VERSION_UNSUPPORTED = 7; // Client version not supported
STATUS_VERSION_DEPRECATED = 8; // Client version deprecated but still supported
STATUS_METADATA_REQUIRED = 9; // Metadata is required for the first request
+ STATUS_SCHEMA_NOT_APPLIED = 10; // Client's ModRevision is ahead of the server cache; the server waited and timed out.
}
diff --git a/api/proto/banyandb/schema/v1/barrier.proto b/api/proto/banyandb/schema/v1/barrier.proto
new file mode 100644
index 000000000..8150d3051
--- /dev/null
+++ b/api/proto/banyandb/schema/v1/barrier.proto
@@ -0,0 +1,104 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+syntax = "proto3";
+
+package banyandb.schema.v1;
+
+import "google/protobuf/duration.proto";
+
+option go_package = "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1";
+option java_package = "org.apache.skywalking.banyandb.schema.v1";
+
+// SchemaBarrierService lets clients block until every data node in the cluster
+// has caught up to a target schema state. The standalone implementation lands
+// in Step 1.8; the distributed liaison fan-out lands in Steps 1.15-1.17.
+service SchemaBarrierService {
+ // AwaitRevisionApplied blocks until every data node's local schema cache has
+ // observed mod_revision >= min_revision, or the timeout elapses.
+ rpc AwaitRevisionApplied(AwaitRevisionAppliedRequest) returns (AwaitRevisionAppliedResponse);
+ // AwaitSchemaApplied blocks until every data node reports all requested keys
+ // present at or above the per-key mod_revision.
+ rpc AwaitSchemaApplied(AwaitSchemaAppliedRequest) returns (AwaitSchemaAppliedResponse);
+ // AwaitSchemaDeleted blocks until every data node has removed all requested
+ // keys from its cache.
+ rpc AwaitSchemaDeleted(AwaitSchemaDeletedRequest) returns (AwaitSchemaDeletedResponse);
+}
+
+// SchemaKey identifies a schema resource by kind and name. Valid kind values:
+// "measure", "stream", "trace", "property", "index_rule",
+// "index_rule_binding", "group", "top_n_aggregation".
+message SchemaKey {
+ string kind = 1;
+ string group = 2;
+ string name = 3;
+}
+
+// AwaitRevisionAppliedRequest carries the minimum mod_revision the caller is
+// waiting for and a wall-clock budget for the wait.
+message AwaitRevisionAppliedRequest {
+ int64 min_revision = 1;
+ google.protobuf.Duration timeout = 2;
+}
+
+// NodeLaggard reports a single data node that has not caught up to the
+// requested schema state. missing_keys is populated by AwaitSchemaApplied
+// responses; still_present_keys is populated by AwaitSchemaDeleted responses.
+message NodeLaggard {
+ string node = 1;
+ int64 current_mod_revision = 2;
+ repeated SchemaKey missing_keys = 3;
+ repeated SchemaKey still_present_keys = 4;
+}
+
+// AwaitRevisionAppliedResponse reports whether every node reached the target
+// revision before the timeout, and lists laggards otherwise.
+message AwaitRevisionAppliedResponse {
+ bool applied = 1;
+ repeated NodeLaggard laggards = 2;
+}
+
+// AwaitSchemaAppliedRequest pairs each key with a per-key minimum revision.
+// keys is capped at 10000 server-side; exceeding the cap returns
+// InvalidArgument. A min_revisions entry of 0 means "just present, any
+// revision".
+message AwaitSchemaAppliedRequest {
+ repeated SchemaKey keys = 1;
+ repeated int64 min_revisions = 2;
+ google.protobuf.Duration timeout = 3;
+}
+
+// AwaitSchemaAppliedResponse reports whether every requested key is present at
+// or above its target revision on every node.
+message AwaitSchemaAppliedResponse {
+ bool applied = 1;
+ repeated NodeLaggard laggards = 2;
+}
+
+// AwaitSchemaDeletedRequest names the keys that must disappear from every
+// node's cache before the call returns.
+message AwaitSchemaDeletedRequest {
+ repeated SchemaKey keys = 1;
+ google.protobuf.Duration timeout = 2;
+}
+
+// AwaitSchemaDeletedResponse reports whether every node has removed the
+// requested keys.
+message AwaitSchemaDeletedResponse {
+ bool applied = 1;
+ repeated NodeLaggard laggards = 2;
+}
diff --git a/api/proto/banyandb/stream/v1/query.proto b/api/proto/banyandb/stream/v1/query.proto
index ae23dd335..71dbcc5b1 100644
--- a/api/proto/banyandb/stream/v1/query.proto
+++ b/api/proto/banyandb/stream/v1/query.proto
@@ -21,6 +21,7 @@ package banyandb.stream.v1;
import "banyandb/common/v1/trace.proto";
import "banyandb/model/v1/query.proto";
+import "banyandb/model/v1/write.proto";
import "google/protobuf/timestamp.proto";
import "validate/validate.proto";
@@ -52,6 +53,9 @@ message QueryResponse {
repeated Element elements = 1;
// trace contains the trace information of the query when trace is enabled
common.v1.Trace trace = 2;
+ // group_statuses reports the per-group gate outcome. Populated even when the query
+ // short-circuits because any group failed the gate.
+ map<string, model.v1.Status> group_statuses = 50;
}
// QueryRequest is the request contract for query.
@@ -79,4 +83,8 @@ message QueryRequest {
bool trace = 9;
// stage is used to specify the stage of the query in the lifecycle
repeated string stages = 10;
+ // group_mod_revisions gates the query per group. Keys match entries in `groups`;
+ // values are the client's known mod_revision for that group. Empty map or value 0
+ // means "don't gate". A group not listed in the map is not gated.
+ map<string, int64> group_mod_revisions = 50;
}
diff --git a/api/proto/banyandb/trace/v1/query.proto b/api/proto/banyandb/trace/v1/query.proto
index 97ed0fe2b..c105ff88d 100644
--- a/api/proto/banyandb/trace/v1/query.proto
+++ b/api/proto/banyandb/trace/v1/query.proto
@@ -21,6 +21,7 @@ package banyandb.trace.v1;
import "banyandb/common/v1/trace.proto";
import "banyandb/model/v1/query.proto";
+import "banyandb/model/v1/write.proto";
import "validate/validate.proto";
option go_package = "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1";
@@ -60,6 +61,9 @@ message QueryResponse {
repeated Trace traces = 1;
// trace_query_result contains the trace of the query execution if tracing is enabled.
common.v1.Trace trace_query_result = 2;
+ // group_statuses reports the per-group gate outcome. Populated even when the query
+ // short-circuits because any group failed the gate.
+ map<string, model.v1.Status> group_statuses = 50;
}
// InternalQueryResponse is the response of an internal query.
@@ -94,4 +98,8 @@ message QueryRequest {
bool trace = 9;
// stage is used to specify the stage of the query in the lifecycle
repeated string stages = 10;
+ // group_mod_revisions gates the query per group. Keys match entries in `groups`;
+ // values are the client's known mod_revision for that group. Empty map or value 0
+ // means "don't gate". A group not listed in the map is not gated.
+ map<string, int64> group_mod_revisions = 50;
}
diff --git a/banyand/liaison/grpc/barrier.go b/banyand/liaison/grpc/barrier.go
new file mode 100644
index 000000000..8e135a2e8
--- /dev/null
+++ b/banyand/liaison/grpc/barrier.go
@@ -0,0 +1,269 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "context"
+ "time"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/durationpb"
+
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+ "github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+)
+
+const (
+ barrierMaxKeys = 10000
+ barrierInitInterval = 10 * time.Millisecond
+ barrierMaxInterval = 500 * time.Millisecond
+ barrierGrowthFactor = 1.5
+ // barrierDefaultTimeout bounds calls that pass a nil/zero timeout. It must
+ // be large enough to cover normal schema propagation (watch event delivery
+ // + handler dispatch + tsdb open) so a no-timeout caller does not see false
+ // applied=false. Distinct from barrierMaxInterval, which only caps the poll
+ // backoff between probes.
+ barrierDefaultTimeout = 5 * time.Second
+)
+
+// barrierCacheReader abstracts the property schema cache so the barrier service
+// can poll for mod-revision progress without coupling to a concrete type.
+type barrierCacheReader interface {
+ GetMaxModRevision() int64
+ GetKeyModRevision(propID string) (int64, bool)
+}
+
+type barrierService struct {
+ schemav1.UnimplementedSchemaBarrierServiceServer
+ cacheProvider func() barrierCacheReader
+}
+
+func newBarrierService(cacheProvider func() barrierCacheReader) *barrierService {
+ return &barrierService{cacheProvider: cacheProvider}
+}
+
+// cache resolves the current barrier cache reader. It returns nil if the
+// underlying schema registry is not yet initialized.
+func (b *barrierService) cache() barrierCacheReader {
+ if b.cacheProvider == nil {
+ return nil
+ }
+ return b.cacheProvider()
+}
+
+// AwaitRevisionApplied blocks until the cache's max modRevision is >= req.MinRevision
+// or the timeout elapses. In standalone mode there is one node, so Laggards carries a
+// single entry whose current_mod_revision reports the cache watermark — this lets
+// callers diagnose how far behind the standalone cache is even when applied=false.
+func (b *barrierService) AwaitRevisionApplied(ctx context.Context, req *schemav1.AwaitRevisionAppliedRequest) (*schemav1.AwaitRevisionAppliedResponse, error) {
+ deadline := time.Now().Add(barrierDeadlineDuration(req.GetTimeout()))
+ pollCtx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+ interval := barrierInitInterval
+ currentRevision := func() int64 {
+ if c := b.cache(); c != nil {
+ return c.GetMaxModRevision()
+ }
+ return 0
+ }
+ for {
+ if currentRevision() >= req.GetMinRevision() {
+ return &schemav1.AwaitRevisionAppliedResponse{Applied: true}, nil
+ }
+ if time.Now().After(deadline) {
+ return &schemav1.AwaitRevisionAppliedResponse{
+ Applied: false,
+ Laggards: []*schemav1.NodeLaggard{{CurrentModRevision: currentRevision()}},
+ }, nil
+ }
+ select {
+ case <-time.After(interval):
+ case <-pollCtx.Done():
+ return &schemav1.AwaitRevisionAppliedResponse{
+ Applied: false,
+ Laggards: []*schemav1.NodeLaggard{{CurrentModRevision: currentRevision()}},
+ }, nil
+ }
+ interval = barrierBackoff(interval)
+ }
+}
+
+// AwaitSchemaApplied blocks until all requested keys are present at or above their
+// per-key min_revisions, or the timeout elapses.
+func (b *barrierService) AwaitSchemaApplied(ctx context.Context, req *schemav1.AwaitSchemaAppliedRequest) (*schemav1.AwaitSchemaAppliedResponse, error) {
+ if len(req.GetKeys()) > barrierMaxKeys {
+ return nil, status.Errorf(codes.InvalidArgument, "too many keys: max=%d", barrierMaxKeys)
+ }
+ deadline := time.Now().Add(barrierDeadlineDuration(req.GetTimeout()))
+ pollCtx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+ interval := barrierInitInterval
+ for {
+ missing := b.collectMissingKeys(req.GetKeys(), req.GetMinRevisions())
+ if len(missing) == 0 {
+ return &schemav1.AwaitSchemaAppliedResponse{Applied: true}, nil
+ }
+ if time.Now().After(deadline) {
+ return &schemav1.AwaitSchemaAppliedResponse{
+ Applied: false,
+ Laggards: []*schemav1.NodeLaggard{{MissingKeys: missing}},
+ }, nil
+ }
+ select {
+ case <-time.After(interval):
+ case <-pollCtx.Done():
+ return &schemav1.AwaitSchemaAppliedResponse{
+ Applied: false,
+ Laggards: []*schemav1.NodeLaggard{{MissingKeys: missing}},
+ }, nil
+ }
+ interval = barrierBackoff(interval)
+ }
+}
+
+// AwaitSchemaDeleted blocks until all requested keys are absent from the cache,
+// or the timeout elapses.
+func (b *barrierService) AwaitSchemaDeleted(ctx context.Context, req *schemav1.AwaitSchemaDeletedRequest) (*schemav1.AwaitSchemaDeletedResponse, error) {
+ if len(req.GetKeys()) > barrierMaxKeys {
+ return nil, status.Errorf(codes.InvalidArgument, "too many keys: max=%d", barrierMaxKeys)
+ }
+ deadline := time.Now().Add(barrierDeadlineDuration(req.GetTimeout()))
+ pollCtx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+ interval := barrierInitInterval
+ for {
+ stillPresent := b.collectPresentKeys(req.GetKeys())
+ if len(stillPresent) == 0 {
+ return &schemav1.AwaitSchemaDeletedResponse{Applied: true}, nil
+ }
+ if time.Now().After(deadline) {
+ return &schemav1.AwaitSchemaDeletedResponse{
+ Applied: false,
+ Laggards: []*schemav1.NodeLaggard{{StillPresentKeys: stillPresent}},
+ }, nil
+ }
+ select {
+ case <-time.After(interval):
+ case <-pollCtx.Done():
+ return &schemav1.AwaitSchemaDeletedResponse{
+ Applied: false,
+ Laggards: []*schemav1.NodeLaggard{{StillPresentKeys: stillPresent}},
+ }, nil
+ }
+ interval = barrierBackoff(interval)
+ }
+}
+
+// collectMissingKeys returns the subset of keys that are not yet present in the
+// cache at or above their required mod_revision. When the cache is not yet
+// initialized, all keys are reported as missing so polling continues.
+func (b *barrierService) collectMissingKeys(keys []*schemav1.SchemaKey, minRevisions []int64) []*schemav1.SchemaKey {
+ c := b.cache()
+ if c == nil {
+ missing := make([]*schemav1.SchemaKey, len(keys))
+ copy(missing, keys)
+ return missing
+ }
+ var missing []*schemav1.SchemaKey
+ for idx, key := range keys {
+ propID := schemaKeyToPropID(key)
+ rev, ok := c.GetKeyModRevision(propID)
+ var minRev int64
+ if idx < len(minRevisions) {
+ minRev = minRevisions[idx]
+ }
+ if !ok || (minRev > 0 && rev < minRev) {
+ missing = append(missing, key)
+ }
+ }
+ return missing
+}
+
+// collectPresentKeys returns the subset of keys that are still present in the cache.
+// When the cache is not yet initialized, all keys are reported as still-present
+// to avoid falsely claiming successful deletion before the cache is online.
+func (b *barrierService) collectPresentKeys(keys []*schemav1.SchemaKey) []*schemav1.SchemaKey {
+ c := b.cache()
+ if c == nil {
+ present := make([]*schemav1.SchemaKey, len(keys))
+ copy(present, keys)
+ return present
+ }
+ var present []*schemav1.SchemaKey
+ for _, key := range keys {
+ propID := schemaKeyToPropID(key)
+ if _, ok := c.GetKeyModRevision(propID); ok {
+ present = append(present, key)
+ }
+ }
+ return present
+}
+
+// schemaKeyToPropID converts a SchemaKey to the property-ID string format used
+// by the schema cache. The propID format mirrors property.BuildPropertyID:
+// kindString + "_" + group + "/" + name (or kindString + "_" + name for groups).
+// SchemaKey.Kind uses proto underscore_case; kind.String() uses camelCase for compound
+// kinds, so this function normalises the mapping.
+func schemaKeyToPropID(key *schemav1.SchemaKey) string {
+ kindStr := protoKindToString(key.GetKind())
+ if key.GetKind() == "group" {
+ return kindStr + "_" + key.GetName()
+ }
+ return kindStr + "_" + key.GetGroup() + "/" + key.GetName()
+}
+
+// protoKindToString maps the proto-style kind string from SchemaKey (underscore_case) to
+// the value returned by schema.Kind.String() (camelCase for compound kinds).
+func protoKindToString(protoKind string) string {
+ switch protoKind {
+ case "index_rule":
+ return schema.KindIndexRule.String()
+ case "index_rule_binding":
+ return schema.KindIndexRuleBinding.String()
+ case "top_n_aggregation":
+ return schema.KindTopNAggregation.String()
+ default:
+ // For "stream", "measure", "trace", "group", "property", "node" the
+ // proto kind string already matches kind.String().
+ return protoKind
+ }
+}
+
+// barrierBackoff returns the next poll interval, capped at barrierMaxInterval.
+func barrierBackoff(d time.Duration) time.Duration {
+ next := time.Duration(float64(d) * barrierGrowthFactor)
+ if next > barrierMaxInterval {
+ return barrierMaxInterval
+ }
+ return next
+}
+
+// barrierDeadlineDuration returns the effective wait duration from a proto Duration.
+// A nil or zero timeout maps to barrierDefaultTimeout so callers that omit the field
+// get a realistic propagation budget rather than the poll-interval cap.
+func barrierDeadlineDuration(d *durationpb.Duration) time.Duration {
+ if d == nil {
+ return barrierDefaultTimeout
+ }
+ dur := d.AsDuration()
+ if dur <= 0 {
+ return barrierDefaultTimeout
+ }
+ return dur
+}
diff --git a/banyand/liaison/grpc/barrier_test.go b/banyand/liaison/grpc/barrier_test.go
new file mode 100644
index 000000000..a6214db33
--- /dev/null
+++ b/banyand/liaison/grpc/barrier_test.go
@@ -0,0 +1,426 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "context"
+ "maps"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/durationpb"
+
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+)
+
+// Mock cache helpers follow.
+
+// staticBarrierCache implements barrierCacheReader with fixed data — no advancing.
+type staticBarrierCache struct {
+ keys map[string]int64
+ maxModRevision int64
+}
+
+func (c *staticBarrierCache) GetMaxModRevision() int64 { return c.maxModRevision }
+func (c *staticBarrierCache) GetKeyModRevision(propID string) (int64, bool) {
+ rev, ok := c.keys[propID]
+ return rev, ok
+}
+
+// advancingBarrierCache implements barrierCacheReader where individual fields can
+// be updated concurrently (used to simulate cache catch-up).
+type advancingBarrierCache struct {
+ keys map[string]int64
+ maxRev int64
+ mu sync.RWMutex
+}
+
+func newAdvancingCache(maxRev int64, keys map[string]int64) *advancingBarrierCache {
+ copied := make(map[string]int64, len(keys))
+ maps.Copy(copied, keys)
+ return &advancingBarrierCache{maxRev: maxRev, keys: copied}
+}
+
+func (c *advancingBarrierCache) GetMaxModRevision() int64 {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.maxRev
+}
+
+func (c *advancingBarrierCache) GetKeyModRevision(propID string) (int64, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ rev, ok := c.keys[propID]
+ return rev, ok
+}
+
+func (c *advancingBarrierCache) setMaxRev(rev int64) {
+ c.mu.Lock()
+ c.maxRev = rev
+ c.mu.Unlock()
+}
+
+func (c *advancingBarrierCache) addKey(propID string, rev int64) {
+ c.mu.Lock()
+ c.keys[propID] = rev
+ c.mu.Unlock()
+}
+
+func (c *advancingBarrierCache) removeKey(propID string) {
+ c.mu.Lock()
+ delete(c.keys, propID)
+ c.mu.Unlock()
+}
+
+// schemaKey is a convenience constructor.
+func schemaKey(kind, group, name string) *schemav1.SchemaKey {
+ return &schemav1.SchemaKey{Kind: kind, Group: group, Name: name}
+}
+
+// AwaitRevisionApplied tests follow.
+
+// TestBarrier_AwaitRevisionApplied_AlreadyAtRevision verifies that when the
+// cache is already at or above the requested min_revision, Applied=true is
+// returned immediately without any polling.
+func TestBarrier_AwaitRevisionApplied_AlreadyAtRevision(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{maxModRevision: 100} })
+ resp, rpcErr := svc.AwaitRevisionApplied(context.Background(), &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: 100,
+ Timeout: durationpb.New(50 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitRevisionApplied_NeverReaches_ReturnsFalse verifies that when
+// the cache never advances to the required revision, Applied=false is returned
+// after the timeout elapses.
+func TestBarrier_AwaitRevisionApplied_NeverReaches_ReturnsFalse(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{maxModRevision: 50} })
+ resp, rpcErr := svc.AwaitRevisionApplied(context.Background(), &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: 99999,
+ Timeout: durationpb.New(60 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.False(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitRevisionApplied_CacheAdvances_ReturnsTrue verifies that when
+// the cache advances to the required revision within the timeout window, Applied=true
+// is returned as soon as the advancement is detected.
+func TestBarrier_AwaitRevisionApplied_CacheAdvances_ReturnsTrue(t *testing.T) {
+ cache := newAdvancingCache(50, nil)
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ cache.setMaxRev(200)
+ }()
+ resp, rpcErr := svc.AwaitRevisionApplied(context.Background(), &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: 200,
+ Timeout: durationpb.New(500 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitRevisionApplied_ContextCancelled_ReturnsFalse verifies that
+// when the caller's context is canceled before the revision is reached, the RPC
+// returns Applied=false without error.
+func TestBarrier_AwaitRevisionApplied_ContextCancelled_ReturnsFalse(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{maxModRevision: 1} })
+ cancelCtx, cancel := context.WithCancel(context.Background())
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ cancel()
+ }()
+ resp, rpcErr := svc.AwaitRevisionApplied(cancelCtx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: 99999,
+ Timeout: durationpb.New(500 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.False(t, resp.GetApplied())
+}
+
+// AwaitSchemaApplied tests follow.
+
+// TestBarrier_AwaitSchemaApplied_AllKeysPresent_ReturnsTrue verifies that when
+// all requested schema keys are already in the cache, Applied=true is returned immediately.
+func TestBarrier_AwaitSchemaApplied_AllKeysPresent_ReturnsTrue(t *testing.T) {
+ cache := &staticBarrierCache{keys: map[string]int64{"stream_g/s": 10}}
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ resp, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+ Keys: []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ Timeout: durationpb.New(50 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitSchemaApplied_MissingKey_TimesOut_ReturnsFalse verifies that
+// when a required key is absent from the cache, Applied=false is returned with
+// the missing key in the Laggards list after the timeout.
+func TestBarrier_AwaitSchemaApplied_MissingKey_TimesOut_ReturnsFalse(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+ resp, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+ Keys: []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ Timeout: durationpb.New(60 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.False(t, resp.GetApplied())
+ require.Len(t, resp.GetLaggards(), 1)
+ assert.Len(t, resp.GetLaggards()[0].GetMissingKeys(), 1)
+}
+
+// TestBarrier_AwaitSchemaApplied_TooManyKeys_ReturnsInvalidArgument verifies that
+// requests with more than barrierMaxKeys entries are rejected with InvalidArgument.
+func TestBarrier_AwaitSchemaApplied_TooManyKeys_ReturnsInvalidArgument(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+ keys := make([]*schemav1.SchemaKey, barrierMaxKeys+1)
+ for i := range keys {
+ keys[i] = schemaKey("stream", "g", "s")
+ }
+ _, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+ Keys: keys,
+ Timeout: durationpb.New(50 * time.Millisecond),
+ })
+ require.Error(t, rpcErr)
+ st, _ := status.FromError(rpcErr)
+ assert.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+// TestBarrier_AwaitSchemaApplied_KeyAppearsInTime_ReturnsTrue verifies that when
+// the missing key appears in the cache before the timeout, Applied=true is returned.
+func TestBarrier_AwaitSchemaApplied_KeyAppearsInTime_ReturnsTrue(t *testing.T) {
+ cache := newAdvancingCache(0, map[string]int64{})
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ cache.addKey("stream_g/s", 10)
+ }()
+ resp, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+ Keys: []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ Timeout: durationpb.New(500 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.True(t, resp.GetApplied())
+}
+
+// AwaitSchemaDeleted tests follow.
+
+// TestBarrier_AwaitSchemaDeleted_AllKeysAbsent_ReturnsTrue verifies that when all
+// requested keys are already absent from the cache, Applied=true is returned immediately.
+func TestBarrier_AwaitSchemaDeleted_AllKeysAbsent_ReturnsTrue(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+ resp, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+ Keys: []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ Timeout: durationpb.New(50 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitSchemaDeleted_KeyPresent_TimesOut_ReturnsFalse verifies that
+// when a key is still in the cache at timeout, Applied=false is returned with the
+// key in the Laggards list.
+func TestBarrier_AwaitSchemaDeleted_KeyPresent_TimesOut_ReturnsFalse(t *testing.T) {
+ cache := &staticBarrierCache{keys: map[string]int64{"stream_g/s": 10}}
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ resp, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+ Keys: []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ Timeout: durationpb.New(60 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.False(t, resp.GetApplied())
+ require.Len(t, resp.GetLaggards(), 1)
+ assert.Len(t, resp.GetLaggards()[0].GetStillPresentKeys(), 1)
+}
+
+// TestBarrier_AwaitSchemaDeleted_TooManyKeys_ReturnsInvalidArgument verifies
+// that requests with more than barrierMaxKeys entries are rejected.
+func TestBarrier_AwaitSchemaDeleted_TooManyKeys_ReturnsInvalidArgument(t *testing.T) {
+ svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+ keys := make([]*schemav1.SchemaKey, barrierMaxKeys+1)
+ for i := range keys {
+ keys[i] = schemaKey("stream", "g", "s")
+ }
+ _, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+ Keys: keys,
+ Timeout: durationpb.New(50 * time.Millisecond),
+ })
+ require.Error(t, rpcErr)
+ st, _ := status.FromError(rpcErr)
+ assert.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+// TestBarrier_AwaitSchemaDeleted_KeyRemovedInTime_ReturnsTrue verifies that when
+// the key is removed from the cache within the timeout, Applied=true is returned.
+func TestBarrier_AwaitSchemaDeleted_KeyRemovedInTime_ReturnsTrue(t *testing.T) {
+ cache := newAdvancingCache(0, map[string]int64{"stream_g/s": 10})
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ cache.removeKey("stream_g/s")
+ }()
+ resp, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+ Keys: []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ Timeout: durationpb.New(500 * time.Millisecond),
+ })
+ require.NoError(t, rpcErr)
+ assert.True(t, resp.GetApplied())
+}
+
+// Pure function tests follow.
+
+// TestBarrierBackoff_GrowsByFactor verifies the backoff grows by barrierGrowthFactor each step.
+func TestBarrierBackoff_GrowsByFactor(t *testing.T) {
+ d := barrierInitInterval // 10ms
+ next := barrierBackoff(d)
+ expected := time.Duration(float64(d) * barrierGrowthFactor)
+ assert.Equal(t, expected, next)
+}
+
+// TestBarrierBackoff_CapsAtMaxInterval verifies the backoff never exceeds barrierMaxInterval.
+func TestBarrierBackoff_CapsAtMaxInterval(t *testing.T) {
+ // Feed a very large duration and verify it is capped.
+ result := barrierBackoff(10 * time.Second)
+ assert.Equal(t, barrierMaxInterval, result)
+}
+
+// TestBarrierDeadlineDuration_NilInput_ReturnsDefault verifies that a nil timeout proto
+// returns barrierDefaultTimeout — large enough to cover normal schema propagation rather
+// than the poll-interval cap.
+func TestBarrierDeadlineDuration_NilInput_ReturnsDefault(t *testing.T) {
+ assert.Equal(t, barrierDefaultTimeout, barrierDeadlineDuration(nil))
+}
+
+// TestBarrierDeadlineDuration_ZeroInput_ReturnsDefault verifies that a zero duration
+// proto returns barrierDefaultTimeout for the same reason as the nil case.
+func TestBarrierDeadlineDuration_ZeroInput_ReturnsDefault(t *testing.T) {
+ assert.Equal(t, barrierDefaultTimeout, barrierDeadlineDuration(durationpb.New(0)))
+}
+
+// TestBarrierDeadlineDuration_PositiveInput_ReturnsExact verifies that a positive
+// timeout is passed through unchanged.
+func TestBarrierDeadlineDuration_PositiveInput_ReturnsExact(t *testing.T) {
+ want := 2 * time.Second
+ assert.Equal(t, want, barrierDeadlineDuration(durationpb.New(want)))
+}
+
+// TestSchemaKeyToPropID_Stream verifies that a stream key is encoded as
+// "stream_<group>/<name>".
+func TestSchemaKeyToPropID_Stream(t *testing.T) {
+ key := schemaKey("stream", "my-group", "my-stream")
+ assert.Equal(t, "stream_my-group/my-stream", schemaKeyToPropID(key))
+}
+
+// TestSchemaKeyToPropID_Measure verifies that a measure key is encoded as
+// "measure_<group>/<name>".
+func TestSchemaKeyToPropID_Measure(t *testing.T) {
+ key := schemaKey("measure", "grp", "m1")
+ assert.Equal(t, "measure_grp/m1", schemaKeyToPropID(key))
+}
+
+// TestSchemaKeyToPropID_Group verifies that a group key (no group field) is
+// encoded as "group_<name>".
+func TestSchemaKeyToPropID_Group(t *testing.T) {
+ key := schemaKey("group", "", "g1")
+ assert.Equal(t, "group_g1", schemaKeyToPropID(key))
+}
+
+// TestProtoKindToString_CompoundKinds verifies that underscore_case proto kind strings
+// are normalised to their schema.Kind.String() equivalents.
+func TestProtoKindToString_CompoundKinds(t *testing.T) {
+ cases := []struct {
+ proto string
+ want string
+ }{
+ {"index_rule", "indexRule"},
+ {"index_rule_binding", "indexRuleBinding"},
+ {"top_n_aggregation", "topNAggregation"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.proto, func(t *testing.T) {
+ assert.Equal(t, tc.want, protoKindToString(tc.proto))
+ })
+ }
+}
+
+// TestProtoKindToString_SimpleKinds verifies that simple kinds (stream, measure,
+// trace, group, property) pass through unchanged.
+func TestProtoKindToString_SimpleKinds(t *testing.T) {
+ for _, kind := range []string{"stream", "measure", "trace", "group", "property", "node"} {
+ t.Run(kind, func(t *testing.T) {
+ assert.Equal(t, kind, protoKindToString(kind))
+ })
+ }
+}
+
+// TestBarrier_CollectMissingKeys_PartialPresence verifies that only absent or
+// below-minRevision keys appear in the missing list.
+func TestBarrier_CollectMissingKeys_PartialPresence(t *testing.T) {
+ cache := &staticBarrierCache{
+ keys: map[string]int64{
+ "stream_g/s1": 10, // present, above minRev 5
+ // "stream_g/s2" absent
+ },
+ }
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ keys := []*schemav1.SchemaKey{
+ schemaKey("stream", "g", "s1"),
+ schemaKey("stream", "g", "s2"),
+ }
+ missing := svc.collectMissingKeys(keys, []int64{5, 1})
+ require.Len(t, missing, 1)
+ assert.Equal(t, "s2", missing[0].GetName())
+}
+
+// TestBarrier_CollectMissingKeys_BelowMinRevision verifies that a key present in
+// the cache but below the required minRevision is reported as missing.
+func TestBarrier_CollectMissingKeys_BelowMinRevision(t *testing.T) {
+ cache := &staticBarrierCache{keys: map[string]int64{"stream_g/s": 5}} // present at rev 5, but minRev = 10
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ missing := svc.collectMissingKeys(
+ []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+ []int64{10},
+ )
+ require.Len(t, missing, 1, "key at rev 5 with minRev 10 must be reported as missing")
+}
+
+// TestBarrier_CollectPresentKeys_FiltersAbsent verifies that only keys still
+// present in the cache are returned.
+func TestBarrier_CollectPresentKeys_FiltersAbsent(t *testing.T) {
+ cache := &staticBarrierCache{
+ keys: map[string]int64{
+ "stream_g/s1": 10,
+ // "stream_g/s2" absent
+ },
+ }
+ svc := newBarrierService(func() barrierCacheReader { return cache })
+ keys := []*schemav1.SchemaKey{
+ schemaKey("stream", "g", "s1"),
+ schemaKey("stream", "g", "s2"),
+ }
+ present := svc.collectPresentKeys(keys)
+ require.Len(t, present, 1)
+ assert.Equal(t, "s1", present[0].GetName())
+}
diff --git a/banyand/liaison/grpc/clamp_test.go b/banyand/liaison/grpc/clamp_test.go
new file mode 100644
index 000000000..613b4341f
--- /dev/null
+++ b/banyand/liaison/grpc/clamp_test.go
@@ -0,0 +1,140 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// base anchors all test timestamps to a fixed, deterministic instant.
+var base = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// TestClampTimeRangeBegin_EmptyCreatedAts_ReturnsBeginUnchanged verifies that
+// when no createdAts are supplied the begin time is returned unchanged with empty=false.
+func TestClampTimeRangeBegin_EmptyCreatedAts_ReturnsBeginUnchanged(t *testing.T) {
+ begin := base
+ end := base.Add(time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, nil)
+ assert.Equal(t, begin, gotBegin)
+ assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_AllZeroCreatedAts_ReturnsBeginUnchanged verifies that
+// zero-value createdAts entries are ignored and begin is returned unchanged.
+func TestClampTimeRangeBegin_AllZeroCreatedAts_ReturnsBeginUnchanged(t *testing.T) {
+ begin := base
+ end := base.Add(time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{{}, {}, {}})
+ assert.Equal(t, begin, gotBegin)
+ assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_BeginBeforeMaxCreatedAt_ClampsToMax verifies that when
+// begin is before the maximum createdAt, begin is advanced to maxCreatedAt.
+func TestClampTimeRangeBegin_BeginBeforeMaxCreatedAt_ClampsToMax(t *testing.T) {
+ createdAt := base.Add(30 * time.Minute)
+ begin := base // before createdAt
+ end := base.Add(2 * time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+ assert.Equal(t, createdAt, gotBegin, "begin must be advanced to maxCreatedAt")
+ assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_BeginAfterMaxCreatedAt_ReturnsBeginUnchanged verifies
+// that when begin is already after the maximum createdAt it is not changed.
+func TestClampTimeRangeBegin_BeginAfterMaxCreatedAt_ReturnsBeginUnchanged(t *testing.T) {
+ createdAt := base
+ begin := base.Add(time.Hour) // already after createdAt
+ end := base.Add(2 * time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+ assert.Equal(t, begin, gotBegin)
+ assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_BeginEqualsMaxCreatedAt_ReturnsBeginUnchanged verifies
+// that begin equal to maxCreatedAt is accepted as-is without being marked empty.
+func TestClampTimeRangeBegin_BeginEqualsMaxCreatedAt_ReturnsBeginUnchanged(t *testing.T) {
+ createdAt := base.Add(30 * time.Minute)
+ begin := createdAt // exactly equal
+ end := base.Add(2 * time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+ assert.Equal(t, begin, gotBegin)
+ assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_ClampedBeginAfterEnd_ReturnsEmpty verifies that when
+// the clamped begin lands after the end, empty=true is returned so the caller
+// can short-circuit and return an empty query response.
+func TestClampTimeRangeBegin_ClampedBeginAfterEnd_ReturnsEmpty(t *testing.T) {
+ // End is far in the past; createdAt is recent → clamped begin > end.
+ createdAt := base.Add(time.Hour)
+ begin := base.Add(-time.Hour) // before createdAt
+ end := base.Add(-30 * time.Minute) // between begin and createdAt
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+ assert.Equal(t, createdAt, gotBegin)
+ assert.True(t, empty, "clamped begin > end must return empty=true")
+}
+
+// TestClampTimeRangeBegin_ClampedBeginEqualsEnd_NotEmpty verifies the boundary:
+// when the clamped begin is exactly equal to end the range is a single point
+// and empty is false (the caller should proceed with the query).
+func TestClampTimeRangeBegin_ClampedBeginEqualsEnd_NotEmpty(t *testing.T) {
+ createdAt := base.Add(30 * time.Minute)
+ begin := base // before createdAt
+ end := createdAt // exactly equal to createdAt after clamping
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+ assert.Equal(t, createdAt, gotBegin)
+ assert.False(t, empty, "clamped begin == end is a valid single-point range, not empty")
+}
+
+// TestClampTimeRangeBegin_ZeroEnd_NeverEmpty verifies that a zero (unbounded) end
+// never causes empty=true regardless of where begin lands after clamping.
+func TestClampTimeRangeBegin_ZeroEnd_NeverEmpty(t *testing.T) {
+ createdAt := base.Add(time.Hour)
+ begin := base
+ gotBegin, empty := clampTimeRangeBegin(begin, time.Time{}, []time.Time{createdAt})
+ assert.Equal(t, createdAt, gotBegin)
+ assert.False(t, empty, "zero end means unbounded — must never return empty=true")
+}
+
+// TestClampTimeRangeBegin_MultipleCreatedAts_UsesMaximum verifies that when
+// multiple createdAts are provided, begin is clamped to the largest one.
+func TestClampTimeRangeBegin_MultipleCreatedAts_UsesMaximum(t *testing.T) {
+ ca1 := base.Add(10 * time.Minute)
+ ca2 := base.Add(45 * time.Minute) // largest
+ ca3 := base.Add(20 * time.Minute)
+ begin := base
+ end := base.Add(2 * time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{ca1, ca2, ca3})
+ assert.Equal(t, ca2, gotBegin, "begin must be clamped to the maximum createdAt across all groups")
+ assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_MixedZeroAndNonZeroCreatedAts_IgnoresZeros verifies that
+// zero-value entries among the createdAts are ignored when computing the maximum.
+func TestClampTimeRangeBegin_MixedZeroAndNonZeroCreatedAts_IgnoresZeros(t *testing.T) {
+ ca := base.Add(20 * time.Minute)
+ begin := base
+ end := base.Add(time.Hour)
+ gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{{}, ca, {}})
+ assert.Equal(t, ca, gotBegin)
+ assert.False(t, empty)
+}
diff --git a/banyand/liaison/grpc/deletion.go b/banyand/liaison/grpc/deletion.go
index 60edfd033..4670b8ca2 100644
--- a/banyand/liaison/grpc/deletion.go
+++ b/banyand/liaison/grpc/deletion.go
@@ -82,7 +82,7 @@ func (m *groupDeletionTaskManager) initPropertyStorage(ctx context.Context) erro
if !errors.Is(getGroupErr, schema.ErrGRPCResourceNotFound) {
return fmt.Errorf("failed to get internal deletion task group: %w", getGroupErr)
}
- if createErr := m.schemaRegistry.GroupRegistry().CreateGroup(ctx, group); createErr != nil {
+ if _, createErr := m.schemaRegistry.GroupRegistry().CreateGroup(ctx, group); createErr != nil {
return fmt.Errorf("failed to create internal deletion task group: %w", createErr)
}
}
@@ -241,7 +241,7 @@ func (m *groupDeletionTaskManager) deleteIndexRuleBindings(
}
task.TotalCounts["index_rule_binding"] = int32(len(bindings))
for _, binding := range bindings {
- if _, deleteErr := m.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, binding.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, binding.GetMetadata()); deleteErr != nil {
return fmt.Errorf("index rule binding %s: %w", binding.GetMetadata().GetName(), deleteErr)
}
}
@@ -258,7 +258,7 @@ func (m *groupDeletionTaskManager) deleteIndexRules(
}
task.TotalCounts["index_rule"] = int32(len(indexRules))
for _, rule := range indexRules {
- if _, deleteErr := m.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, rule.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, rule.GetMetadata()); deleteErr != nil {
return fmt.Errorf("index rule %s: %w", rule.GetMetadata().GetName(), deleteErr)
}
}
@@ -275,7 +275,7 @@ func (m *groupDeletionTaskManager) deleteProperties(
}
task.TotalCounts["property"] = int32(len(properties))
for _, prop := range properties {
- if _, deleteErr := m.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, prop.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, prop.GetMetadata()); deleteErr != nil {
return fmt.Errorf("property %s: %w", prop.GetMetadata().GetName(), deleteErr)
}
}
@@ -292,7 +292,7 @@ func (m *groupDeletionTaskManager) deleteStreams(
}
task.TotalCounts["stream"] = int32(len(streams))
for _, stream := range streams {
- if _, deleteErr := m.schemaRegistry.StreamRegistry().DeleteStream(ctx, stream.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.StreamRegistry().DeleteStream(ctx, stream.GetMetadata()); deleteErr != nil {
return fmt.Errorf("stream %s: %w", stream.GetMetadata().GetName(), deleteErr)
}
}
@@ -309,7 +309,7 @@ func (m *groupDeletionTaskManager) deleteMeasures(
}
task.TotalCounts["measure"] = int32(len(measures))
for _, measure := range measures {
- if _, deleteErr := m.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, measure.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, measure.GetMetadata()); deleteErr != nil {
return fmt.Errorf("measure %s: %w", measure.GetMetadata().GetName(), deleteErr)
}
}
@@ -326,7 +326,7 @@ func (m *groupDeletionTaskManager) deleteTraces(
}
task.TotalCounts["trace"] = int32(len(traces))
for _, trace := range traces {
- if _, deleteErr := m.schemaRegistry.TraceRegistry().DeleteTrace(ctx, trace.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.TraceRegistry().DeleteTrace(ctx, trace.GetMetadata()); deleteErr != nil {
return fmt.Errorf("trace %s: %w", trace.GetMetadata().GetName(), deleteErr)
}
}
@@ -343,7 +343,7 @@ func (m *groupDeletionTaskManager) deleteTopNAggregations(
}
task.TotalCounts["topn_aggregation"] = int32(len(topNAggs))
for _, agg := range topNAggs {
- if _, deleteErr := m.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, agg.GetMetadata()); deleteErr != nil {
+ if _, _, deleteErr := m.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, agg.GetMetadata()); deleteErr != nil {
return fmt.Errorf("topN aggregation %s: %w", agg.GetMetadata().GetName(), deleteErr)
}
}
@@ -352,7 +352,7 @@ func (m *groupDeletionTaskManager) deleteTopNAggregations(
}
func (m *groupDeletionTaskManager) deleteGroup(ctx context.Context, group string) error {
- _, deleteErr := m.schemaRegistry.GroupRegistry().DeleteGroup(ctx, group)
+ _, _, deleteErr := m.schemaRegistry.GroupRegistry().DeleteGroup(ctx, group)
if deleteErr != nil && !errors.Is(deleteErr, schema.ErrGRPCResourceNotFound) {
return fmt.Errorf("failed to delete group %s: %w", group, deleteErr)
}
diff --git a/banyand/liaison/grpc/deletion_test.go b/banyand/liaison/grpc/deletion_test.go
index 88b810a7c..6cf47e957 100644
--- a/banyand/liaison/grpc/deletion_test.go
+++ b/banyand/liaison/grpc/deletion_test.go
@@ -63,16 +63,16 @@ func (s *stubIndexRuleBinding) ListIndexRuleBinding(_ context.Context, _ schema.
return nil, nil
}
-func (s *stubIndexRuleBinding) CreateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) error {
- return nil
+func (s *stubIndexRuleBinding) CreateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) (int64, error) {
+ return 0, nil
}
-func (s *stubIndexRuleBinding) UpdateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) error {
- return nil
+func (s *stubIndexRuleBinding) UpdateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) (int64, error) {
+ return 0, nil
}
-func (s *stubIndexRuleBinding) DeleteIndexRuleBinding(_ context.Context, _ *commonv1.Metadata) (bool, error) {
- return true, nil
+func (s *stubIndexRuleBinding) DeleteIndexRuleBinding(_ context.Context, _ *commonv1.Metadata) (bool, int64, error) {
+ return true, 0, nil
}
// stubIndexRule implements schema.IndexRule returning empty results.
@@ -86,16 +86,16 @@ func (s *stubIndexRule) ListIndexRule(_ context.Context, _ schema.ListOpt) ([]*d
return nil, nil
}
-func (s *stubIndexRule) CreateIndexRule(_ context.Context, _ *databasev1.IndexRule) error {
- return nil
+func (s *stubIndexRule) CreateIndexRule(_ context.Context, _ *databasev1.IndexRule) (int64, error) {
+ return 0, nil
}
-func (s *stubIndexRule) UpdateIndexRule(_ context.Context, _ *databasev1.IndexRule) error {
- return nil
+func (s *stubIndexRule) UpdateIndexRule(_ context.Context, _ *databasev1.IndexRule) (int64, error) {
+ return 0, nil
}
-func (s *stubIndexRule) DeleteIndexRule(_ context.Context, _ *commonv1.Metadata) (bool, error) {
- return true, nil
+func (s *stubIndexRule) DeleteIndexRule(_ context.Context, _ *commonv1.Metadata) (bool, int64, error) {
+ return true, 0, nil
}
func TestHasNonEmptyResources(t *testing.T) {
@@ -223,7 +223,7 @@ func TestDeletion(t *testing.T) {
}, nil)
mockRepo.EXPECT().DropGroup(gomock.Any(), commonv1.Catalog_CATALOG_STREAM, group).Return(nil)
mockGroup.EXPECT().DeleteGroup(gomock.Any(), group).DoAndReturn(
- func(_ context.Context, g string) (bool, error) {
+ func(_ context.Context, g string) (bool, int64, error) {
go func() {
time.Sleep(10 * time.Millisecond)
gr.OnDelete(schema.Metadata{
@@ -235,7 +235,7 @@ func TestDeletion(t *testing.T) {
},
})
}()
- return true, nil
+ return true, 0, nil
},
)
mockRepo.EXPECT().GroupRegistry().Return(mockGroup).Times(2)
diff --git a/banyand/liaison/grpc/discovery_test.go b/banyand/liaison/grpc/discovery_test.go
index 291b6786d..9842748de 100644
--- a/banyand/liaison/grpc/discovery_test.go
+++ b/banyand/liaison/grpc/discovery_test.go
@@ -25,9 +25,223 @@ import (
"github.com/stretchr/testify/require"
commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ "github.com/apache/skywalking-banyandb/banyand/metadata/schema"
"github.com/apache/skywalking-banyandb/pkg/logger"
)
+// entityRepo tombstone GC helpers follow.
+
+// minimalStream returns a Stream proto suitable for entityRepo event tests.
+// Group and name are fixed because every caller uses the same values; introducing
+// variation would only add noise without changing what's being asserted.
+func minimalStream(modRevision int64) *databasev1.Stream {
+ return &databasev1.Stream{
+ Metadata: &commonv1.Metadata{Group: "g", Name: "s", ModRevision: modRevision},
+ Entity: &databasev1.Entity{TagNames: []string{"svc"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{{Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING}},
+ }},
+ }
+}
+
+// minimalMeasure returns a Measure proto suitable for entityRepo event tests.
+func minimalMeasure(group, name string, modRevision int64) *databasev1.Measure {
+ return &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Group: group, Name: name, ModRevision: modRevision},
+ Entity: &databasev1.Entity{TagNames: []string{"svc"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{{Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING}},
+ }},
+ }
+}
+
+// minimalTrace returns a Trace proto suitable for entityRepo event tests.
+// tagNames is the ordered list of tag names; traceIDTagName is the one to index.
+func minimalTrace(group, name string, modRevision int64, traceIDTagName string, tagNames ...string) *databasev1.Trace {
+ tags := make([]*databasev1.TraceTagSpec, len(tagNames))
+ for i, n := range tagNames {
+ tags[i] = &databasev1.TraceTagSpec{Name: n}
+ }
+ return &databasev1.Trace{
+ Metadata: &commonv1.Metadata{Group: group, Name: name, ModRevision: modRevision},
+ TraceIdTagName: traceIDTagName,
+ Tags: tags,
+ }
+}
+
+func streamAddEvent(s *databasev1.Stream) schema.Metadata {
+ return schema.Metadata{TypeMeta: schema.TypeMeta{Kind: schema.KindStream}, Spec: s}
+}
+
+func measureAddEvent(m *databasev1.Measure) schema.Metadata {
+ return schema.Metadata{TypeMeta: schema.TypeMeta{Kind: schema.KindMeasure}, Spec: m}
+}
+
+func traceAddEvent(tr *databasev1.Trace) schema.Metadata {
+ return schema.Metadata{TypeMeta: schema.TypeMeta{Kind: schema.KindTrace}, Spec: tr}
+}
+
+// entityRepo tombstone GC unit tests follow.
+
+// TestEntityRepo_OnAddOrUpdate_Stream verifies that OnAddOrUpdate for a stream
+// populates entitiesMap with the correct ModRevision and streamMap with the spec.
+func TestEntityRepo_OnAddOrUpdate_Stream(t *testing.T) {
+ er := newEmptyEntityRepo()
+ s := minimalStream(42)
+ er.OnAddOrUpdate(streamAddEvent(s))
+
+ id := identity{group: "g", name: "s"}
+ loc, ok := er.getLocator(id)
+ require.True(t, ok, "entitiesMap must contain the stream locator after OnAddOrUpdate")
+ assert.Equal(t, int64(42), loc.ModRevision)
+
+ gotStream, streamOK := er.getStream(id)
+ require.True(t, streamOK, "streamMap must contain the stream after OnAddOrUpdate")
+ assert.Equal(t, "s", gotStream.GetMetadata().GetName())
+}
+
+// TestEntityRepo_OnAddOrUpdate_Measure verifies that OnAddOrUpdate for a measure
+// populates entitiesMap with the correct ModRevision and measureMap with the spec.
+func TestEntityRepo_OnAddOrUpdate_Measure(t *testing.T) {
+ er := newEmptyEntityRepo()
+ m := minimalMeasure("g", "m", 77)
+ er.OnAddOrUpdate(measureAddEvent(m))
+
+ id := identity{group: "g", name: "m"}
+ loc, ok := er.getLocator(id)
+ require.True(t, ok, "entitiesMap must contain the measure locator after OnAddOrUpdate")
+ assert.Equal(t, int64(77), loc.ModRevision)
+
+ gotMeasure, measureOK := er.getMeasure(id)
+ require.True(t, measureOK, "measureMap must contain the measure after OnAddOrUpdate")
+ assert.Equal(t, "m", gotMeasure.GetMetadata().GetName())
+}
+
+// TestEntityRepo_OnAddOrUpdate_Trace verifies that OnAddOrUpdate for a trace
+// populates traceMap and pre-computes traceIDIndex at the correct position.
+func TestEntityRepo_OnAddOrUpdate_Trace(t *testing.T) {
+ er := newEmptyEntityRepo()
+ // Tags: ["span_id", "trace_id", "svc"] — trace_id is at index 1.
+ tr := minimalTrace("g", "t", 55, "trace_id", "span_id", "trace_id", "svc")
+ er.OnAddOrUpdate(traceAddEvent(tr))
+
+ id := identity{group: "g", name: "t"}
+ gotTrace, traceOK := er.getTrace(id)
+ require.True(t, traceOK, "traceMap must contain the trace after OnAddOrUpdate")
+ assert.Equal(t, "trace_id", gotTrace.GetTraceIdTagName())
+
+ idx, idxOK := er.getTraceIDIndex(id)
+ require.True(t, idxOK)
+ assert.Equal(t, 1, idx, "traceIDIndex must point to the position of trace_id_tag_name in tags")
+}
+
+// TestEntityRepo_OnAddOrUpdate_Trace_MissingTraceID verifies that when
+// trace_id_tag_name does not appear in the tags list the stored index is -1.
+func TestEntityRepo_OnAddOrUpdate_Trace_MissingTraceID(t *testing.T) {
+ er := newEmptyEntityRepo()
+ tr := minimalTrace("g", "t", 10, "absent_tag", "span_id", "svc")
+ er.OnAddOrUpdate(traceAddEvent(tr))
+
+ id := identity{group: "g", name: "t"}
+ idx, _ := er.getTraceIDIndex(id)
+ assert.Equal(t, -1, idx, "traceIDIndex must be -1 when trace_id_tag_name is not found in tags")
+}
+
+// TestEntityRepo_OnDelete_Stream verifies that OnDelete for a stream removes
+// the locator from entitiesMap and the entry from streamMap (tombstone GC).
+func TestEntityRepo_OnDelete_Stream(t *testing.T) {
+ er := newEmptyEntityRepo()
+ s := minimalStream(42)
+ er.OnAddOrUpdate(streamAddEvent(s))
+
+ er.OnDelete(streamAddEvent(s))
+
+ id := identity{group: "g", name: "s"}
+ _, locOK := er.getLocator(id)
+ assert.False(t, locOK, "entitiesMap must be cleared for stream after OnDelete (tombstone GC)")
+
+ _, streamOK := er.getStream(id)
+ assert.False(t, streamOK, "streamMap must be cleared for stream after OnDelete (tombstone GC)")
+}
+
+// TestEntityRepo_OnDelete_Measure verifies that OnDelete for a measure removes
+// the locator from entitiesMap and the entry from measureMap (tombstone GC).
+func TestEntityRepo_OnDelete_Measure(t *testing.T) {
+ er := newEmptyEntityRepo()
+ m := minimalMeasure("g", "m", 77)
+ er.OnAddOrUpdate(measureAddEvent(m))
+
+ er.OnDelete(measureAddEvent(m))
+
+ id := identity{group: "g", name: "m"}
+ _, locOK := er.getLocator(id)
+ assert.False(t, locOK, "entitiesMap must be cleared for measure after OnDelete (tombstone GC)")
+
+ _, measureOK := er.getMeasure(id)
+ assert.False(t, measureOK, "measureMap must be cleared for measure after OnDelete (tombstone GC)")
+}
+
+// TestEntityRepo_OnDelete_Trace verifies that OnDelete for a trace removes
+// the entry from traceMap and traceIDIndexMap (tombstone GC).
+func TestEntityRepo_OnDelete_Trace(t *testing.T) {
+ er := newEmptyEntityRepo()
+ tr := minimalTrace("g", "t", 55, "trace_id", "trace_id")
+ er.OnAddOrUpdate(traceAddEvent(tr))
+
+ er.OnDelete(traceAddEvent(tr))
+
+ id := identity{group: "g", name: "t"}
+ _, traceOK := er.getTrace(id)
+ assert.False(t, traceOK, "traceMap must be cleared for trace after OnDelete (tombstone GC)")
+
+ idx, _ := er.getTraceIDIndex(id)
+ assert.Equal(t, -1, idx, "traceIDIndexMap must be cleared for trace after OnDelete (tombstone GC)")
+}
+
+// TestEntityRepo_AddDeleteAdd_Sequence verifies the full tombstone GC lifecycle:
+// add → present; delete → absent; add again with new revision → present with new revision.
+func TestEntityRepo_AddDeleteAdd_Sequence(t *testing.T) {
+ er := newEmptyEntityRepo()
+ s := minimalStream(10)
+ id := identity{group: "g", name: "s"}
+
+ er.OnAddOrUpdate(streamAddEvent(s))
+ _, ok := er.getLocator(id)
+ require.True(t, ok, "entity must be present after first OnAddOrUpdate")
+
+ er.OnDelete(streamAddEvent(s))
+ _, ok = er.getLocator(id)
+ require.False(t, ok, "entity must be absent after OnDelete (tombstone)")
+
+ s2 := minimalStream(20)
+ er.OnAddOrUpdate(streamAddEvent(s2))
+ loc, ok := er.getLocator(id)
+ require.True(t, ok, "entity must be present after second OnAddOrUpdate")
+ assert.Equal(t, int64(20), loc.ModRevision, "re-added entity must carry the new ModRevision")
+}
+
+// TestEntityRepo_OnDelete_UnknownKind_IsNoop verifies that OnDelete for an
+// unrecognized kind does not panic and leaves existing entries intact.
+func TestEntityRepo_OnDelete_UnknownKind_IsNoop(t *testing.T) {
+ er := newEmptyEntityRepo()
+ s := minimalStream(1)
+ er.OnAddOrUpdate(streamAddEvent(s))
+
+ assert.NotPanics(t, func() {
+ er.OnDelete(schema.Metadata{
+ TypeMeta: schema.TypeMeta{Kind: schema.KindGroup},
+ Spec: &commonv1.Group{Metadata: &commonv1.Metadata{Group: "g", Name: "s"}},
+ })
+ }, "OnDelete with unsupported kind must not panic")
+
+ id := identity{group: "g", name: "s"}
+ _, ok := er.getLocator(id)
+ assert.True(t, ok, "unsupported kind OnDelete must not remove the stream locator")
+}
+
func TestGroupRepo_AcquireAndRelease(t *testing.T) {
gr := &groupRepo{
log: logger.GetLogger("test"),
diff --git a/banyand/liaison/grpc/measure.go b/banyand/liaison/grpc/measure.go
index 8076a9c61..6017d8698 100644
--- a/banyand/liaison/grpc/measure.go
+++ b/banyand/liaison/grpc/measure.go
@@ -25,6 +25,7 @@ import (
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/api/common"
"github.com/apache/skywalking-banyandb/api/data"
@@ -189,17 +190,33 @@ func (ms *measureService) validateWriteRequest(writeRequest *measurev1.WriteRequ
}
if metadata.ModRevision > 0 {
- measureCache, existed := ms.entityRepo.getLocator(getID(metadata))
+ id := getID(metadata)
+ measureCache, existed := ms.entityRepo.getLocator(id)
if !existed {
ms.l.Error().Stringer("written", writeRequest).Msg("measure schema not found")
ms.sendReply(metadata, modelv1.Status_STATUS_NOT_FOUND, writeRequest.GetMessageId(), measure)
return modelv1.Status_STATUS_NOT_FOUND
}
- if metadata.ModRevision != measureCache.ModRevision {
+ clientRev := metadata.ModRevision
+ cacheRev := measureCache.ModRevision
+ if clientRev < cacheRev {
ms.l.Error().Stringer("written", writeRequest).Msg("the measure schema is expired")
ms.sendReply(metadata, modelv1.Status_STATUS_EXPIRED_SCHEMA, writeRequest.GetMessageId(), measure)
return modelv1.Status_STATUS_EXPIRED_SCHEMA
}
+ if clientRev > cacheRev {
+ reached := awaitRevisionReached(func() int64 {
+ loc, ok := ms.entityRepo.getLocator(id)
+ if !ok {
+ return 0
+ }
+ return loc.ModRevision
+ }, clientRev, ms.maxWaitDuration)
+ if !reached {
+ ms.sendReply(metadata, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, writeRequest.GetMessageId(), measure)
+ return modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+ }
+ }
}
return modelv1.Status_STATUS_SUCCEED
@@ -419,6 +436,44 @@ func (ms *measureService) Query(ctx context.Context, req *measurev1.QueryRequest
}
}()
}
+ gatedStatuses, shortCircuit := checkQueryGate(req.Groups, req.Name, req.GroupModRevisions,
+ func(name, group string) (int64, bool) {
+ loc, ok := ms.entityRepo.getLocator(identity{name: name, group: group})
+ return loc.ModRevision, ok
+ }, ms.maxWaitDuration)
+ if shortCircuit {
+ return &measurev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+ }
+ // Gate the clamp on the same opt-in trigger as the query gate (GroupModRevisions
+ // non-empty). Schema-aware clients that pass per-group revisions also accept
+ // pre-creation filtering; legacy clients that omit GroupModRevisions retain the
+ // pre-Step-1.6 behavior where data with timestamps preceding CreatedAt is still
+ // returned. This keeps backward compatibility for fixtures that write historical
+ // data into a freshly-created measure.
+ if req.TimeRange != nil && len(req.GroupModRevisions) > 0 {
+ createdAts := make([]time.Time, 0, len(req.Groups))
+ for _, group := range req.Groups {
+ measureEntity, measureOK := ms.entityRepo.getMeasure(identity{name: req.Name, group: group})
+ if measureOK && measureEntity.GetCreatedAt() != nil {
+ createdAts = append(createdAts, measureEntity.GetCreatedAt().AsTime())
+ }
+ }
+ endTime := time.Time{}
+ if req.TimeRange.GetEnd() != nil {
+ endTime = req.TimeRange.GetEnd().AsTime()
+ }
+ originalBegin := req.TimeRange.GetBegin().AsTime()
+ newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
+ if rangeEmpty {
+ if len(gatedStatuses) > 0 {
+ return &measurev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+ }
+ return emptyMeasureQueryResponse, nil
+ }
+ if newBegin != originalBegin {
+ req.TimeRange.Begin = timestamppb.New(newBegin)
+ }
+ }
feat, err := ms.broadcaster.Publish(ctx, data.TopicMeasureQuery, bus.NewMessage(bus.MessageID(now.UnixNano()), req))
if err != nil {
return nil, err
@@ -434,6 +489,9 @@ func (ms *measureService) Query(ctx context.Context, req *measurev1.QueryRequest
switch d := data.(type) {
case *measurev1.QueryResponse:
responseDataPointCount = len(d.DataPoints)
+ if len(gatedStatuses) > 0 {
+ d.GroupStatuses = gatedStatuses
+ }
return d, nil
case *common.Error:
return nil, errors.WithMessage(errQueryMsg, d.Error())
diff --git a/banyand/liaison/grpc/measure_write_test.go b/banyand/liaison/grpc/measure_write_test.go
new file mode 100644
index 000000000..e866e89cf
--- /dev/null
+++ b/banyand/liaison/grpc/measure_write_test.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func newTestMeasureService(er *entityRepo, maxWait time.Duration) *measureService {
+ return &measureService{
+ discoveryService: &discoveryService{entityRepo: er},
+ maxWaitDuration: maxWait,
+ l: logger.GetLogger("test"),
+ metrics: newBypassMetrics(),
+ }
+}
+
+func validMeasureWriteRequest() *measurev1.WriteRequest {
+ return &measurev1.WriteRequest{
+ DataPoint: &measurev1.DataPointValue{
+ Timestamp: timestamppb.New(time.Now().Truncate(time.Millisecond)),
+ },
+ }
+}
+
+// TestValidateWriteRequest_Measure_ClientLower_ReturnsExpiredSchema verifies that
+// a write with client ModRevision below the cached revision is rejected immediately
+// with STATUS_EXPIRED_SCHEMA.
+func TestValidateWriteRequest_Measure_ClientLower_ReturnsExpiredSchema(t *testing.T) {
+ id := identity{group: "g", name: "m"}
+ er := seededLocatorRepo(id)
+ svc := newTestMeasureService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 50}
+ st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, st)
+ require.Len(t, mock.replies, 1)
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Measure_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout
+// verifies that a write with client ModRevision above the cached revision waits up to
+// maxWaitDuration and then returns STATUS_SCHEMA_NOT_APPLIED when the cache stays stale.
+func TestValidateWriteRequest_Measure_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout(t *testing.T) {
+ id := identity{group: "g", name: "m"}
+ er := seededLocatorRepo(id)
+ svc := newTestMeasureService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 200}
+ st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, st)
+ require.Len(t, mock.replies, 1)
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Measure_ClientHigher_SucceedsWhenCacheCatchesUp verifies
+// that a write with client ModRevision ahead of the cache returns STATUS_SUCCEED
+// once the cache advances to the required revision within maxWaitDuration.
+func TestValidateWriteRequest_Measure_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+ id := identity{group: "g", name: "m"}
+ er := seededLocatorRepo(id)
+ svc := newTestMeasureService(er, 500*time.Millisecond)
+ mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+ advanceLocatorAfter(er, id, 200, 20*time.Millisecond)
+
+ meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 200}
+ st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies, "no error reply should be sent when validation succeeds")
+}
+
+// TestValidateWriteRequest_Measure_Equal_ReturnsSucceed verifies that a write with
+// client ModRevision equal to the cached revision is accepted immediately.
+func TestValidateWriteRequest_Measure_Equal_ReturnsSucceed(t *testing.T) {
+ id := identity{group: "g", name: "m"}
+ er := seededLocatorRepo(id)
+ svc := newTestMeasureService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 100}
+ st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies)
+}
+
+// TestValidateWriteRequest_Measure_ZeroRevision_SkipsCheck verifies that a write
+// with ModRevision == 0 skips the revision gate entirely and returns STATUS_SUCCEED,
+// even when no locator is registered for the schema.
+func TestValidateWriteRequest_Measure_ZeroRevision_SkipsCheck(t *testing.T) {
+ // Empty repo — if the gate ran it would return STATUS_NOT_FOUND.
+ er := newEmptyEntityRepo()
+ svc := newTestMeasureService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 0}
+ st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies)
+}
diff --git a/banyand/liaison/grpc/query_gate_test.go b/banyand/liaison/grpc/query_gate_test.go
new file mode 100644
index 000000000..5fadb6d7a
--- /dev/null
+++ b/banyand/liaison/grpc/query_gate_test.go
@@ -0,0 +1,197 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// staticLocatorRevision returns a getLocatorRevision function backed by a fixed map.
+// Groups present in revs return (rev, true); absent groups return (0, false).
+func staticLocatorRevision(revs map[string]int64) func(string, string) (int64, bool) {
+ return func(_, group string) (int64, bool) {
+ rev, ok := revs[group]
+ return rev, ok
+ }
+}
+
+// advancingLocatorRevision returns a getLocatorRevision function that starts at
+// initialRev for the given group and atomically advances to targetRev after delay.
+func advancingLocatorRevision(group string, initialRev, targetRev int64, delay time.Duration) func(string, string) (int64, bool) {
+ var mu sync.RWMutex
+ current := initialRev
+ go func() {
+ time.Sleep(delay)
+ mu.Lock()
+ current = targetRev
+ mu.Unlock()
+ }()
+ return func(_, g string) (int64, bool) {
+ if g != group {
+ return 0, false
+ }
+ mu.RLock()
+ defer mu.RUnlock()
+ return current, true
+ }
+}
+
+// TestCheckQueryGate_EmptyMap_SkipsGateEntirely verifies that a nil or empty
+// groupModRevisions map causes checkQueryGate to return (nil, false) without
+// consulting the locator at all.
+func TestCheckQueryGate_EmptyMap_SkipsGateEntirely(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1", "g2"},
+ "s",
+ nil,
+ staticLocatorRevision(map[string]int64{"g1": 100}),
+ 50*time.Millisecond,
+ )
+ assert.Nil(t, statuses)
+ assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ZeroRevision_SkipsGroup verifies that a group present in the
+// map with clientRev == 0 is skipped — it does not appear in the returned statuses.
+func TestCheckQueryGate_ZeroRevision_SkipsGroup(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1"},
+ "s",
+ map[string]int64{"g1": 0},
+ staticLocatorRevision(map[string]int64{"g1": 100}),
+ 50*time.Millisecond,
+ )
+ assert.Nil(t, statuses)
+ assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_Equal_ReturnsSucceedAndNoShortCircuit verifies that a client
+// revision equal to the cached revision is accepted immediately with STATUS_SUCCEED
+// and does not trigger short-circuit.
+func TestCheckQueryGate_Equal_ReturnsSucceedAndNoShortCircuit(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1"},
+ "s",
+ map[string]int64{"g1": 100},
+ staticLocatorRevision(map[string]int64{"g1": 100}),
+ 50*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g1"])
+ assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ClientLower_ReturnsExpiredSchemaAndShortCircuits verifies that
+// a client revision below the cached revision is rejected immediately with
+// STATUS_EXPIRED_SCHEMA and the short-circuit flag is set.
+func TestCheckQueryGate_ClientLower_ReturnsExpiredSchemaAndShortCircuits(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1"},
+ "s",
+ map[string]int64{"g1": 50},
+ staticLocatorRevision(map[string]int64{"g1": 100}),
+ 50*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, statuses["g1"])
+ assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ClientHigher_WaitsAndReturnsNotAppliedOnTimeout verifies that
+// a client revision above the cached revision causes checkQueryGate to wait up to
+// maxWait and then return STATUS_SCHEMA_NOT_APPLIED with shortCircuit=true when the
+// cache never catches up.
+func TestCheckQueryGate_ClientHigher_WaitsAndReturnsNotAppliedOnTimeout(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1"},
+ "s",
+ map[string]int64{"g1": 99999},
+ staticLocatorRevision(map[string]int64{"g1": 100}),
+ 50*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, statuses["g1"])
+ assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ClientHigher_SucceedsWhenCacheCatchesUp verifies that a client
+// revision above the cache produces STATUS_SUCCEED with shortCircuit=false once the
+// cache advances to the required revision within maxWait.
+func TestCheckQueryGate_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+ getter := advancingLocatorRevision("g1", 100, 200, 20*time.Millisecond)
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1"},
+ "s",
+ map[string]int64{"g1": 200},
+ getter,
+ 500*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g1"])
+ assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_GroupNotInLocator_ReturnsNotFoundAndShortCircuits verifies that
+// a group present in the request map but absent from the locator cache yields
+// STATUS_NOT_FOUND and triggers short-circuit.
+func TestCheckQueryGate_GroupNotInLocator_ReturnsNotFoundAndShortCircuits(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1"},
+ "s",
+ map[string]int64{"g1": 100},
+ staticLocatorRevision(map[string]int64{}), // g1 absent from locator cache
+ 50*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_NOT_FOUND, statuses["g1"])
+ assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_MixedGroups_ShortCircuitsWhenAnyGroupFails verifies that when
+// multiple groups are gated and at least one fails (stale revision), the result map
+// contains per-group statuses for all gated groups and shortCircuit is true.
+func TestCheckQueryGate_MixedGroups_ShortCircuitsWhenAnyGroupFails(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1", "g2"},
+ "s",
+ map[string]int64{"g1": 50, "g2": 100},
+ staticLocatorRevision(map[string]int64{"g1": 100, "g2": 100}),
+ 50*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, statuses["g1"])
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g2"])
+ assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_PartialCoverage_OnlyGatesGroupsInRevisionMap verifies that
+// groups listed in the groups slice but absent from groupModRevisions are not gated
+// and do not appear in the returned statuses map.
+func TestCheckQueryGate_PartialCoverage_OnlyGatesGroupsInRevisionMap(t *testing.T) {
+ statuses, shortCircuit := checkQueryGate(
+ []string{"g1", "g2"},
+ "s",
+ map[string]int64{"g1": 100}, // only g1 gated; g2 absent from map
+ staticLocatorRevision(map[string]int64{"g1": 100, "g2": 200}),
+ 50*time.Millisecond,
+ )
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g1"])
+ _, g2Present := statuses["g2"]
+ assert.False(t, g2Present, "g2 must not appear in statuses when absent from groupModRevisions")
+ assert.False(t, shortCircuit)
+}
diff --git a/banyand/liaison/grpc/registry.go b/banyand/liaison/grpc/registry.go
index 1e9af0c15..58f365213 100644
--- a/banyand/liaison/grpc/registry.go
+++ b/banyand/liaison/grpc/registry.go
@@ -87,13 +87,14 @@ func (rs *streamRegistryServer) Delete(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "stream", "delete")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "stream", "delete")
}()
- ok, err := rs.schemaRegistry.StreamRegistry().DeleteStream(ctx, req.GetMetadata())
+ ok, deleteTime, err := rs.schemaRegistry.StreamRegistry().DeleteStream(ctx, req.GetMetadata())
if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "stream", "delete")
return nil, err
}
return &databasev1.StreamRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
@@ -194,11 +195,12 @@ func (rs *indexRuleBindingRegistryServer) Create(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "indexRuleBinding", "create")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRuleBinding", "create")
}()
- if err := rs.schemaRegistry.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, req.GetIndexRuleBinding()); err != nil {
+ modRevision, err := rs.schemaRegistry.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, req.GetIndexRuleBinding())
+ if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "indexRuleBinding", "create")
return nil, err
}
- return &databasev1.IndexRuleBindingRegistryServiceCreateResponse{}, nil
+ return &databasev1.IndexRuleBindingRegistryServiceCreateResponse{ModRevision: modRevision}, nil
}
func (rs *indexRuleBindingRegistryServer) Update(ctx context.Context,
@@ -212,11 +214,12 @@ func (rs *indexRuleBindingRegistryServer) Update(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "indexRuleBinding", "update")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRuleBinding", "update")
}()
- if err := rs.schemaRegistry.IndexRuleBindingRegistry().UpdateIndexRuleBinding(ctx, req.GetIndexRuleBinding()); err != nil {
+ modRevision, err := rs.schemaRegistry.IndexRuleBindingRegistry().UpdateIndexRuleBinding(ctx, req.GetIndexRuleBinding())
+ if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "indexRuleBinding", "update")
return nil, err
}
- return &databasev1.IndexRuleBindingRegistryServiceUpdateResponse{}, nil
+ return &databasev1.IndexRuleBindingRegistryServiceUpdateResponse{ModRevision: modRevision}, nil
}
func (rs *indexRuleBindingRegistryServer) Delete(ctx context.Context,
@@ -230,13 +233,14 @@ func (rs *indexRuleBindingRegistryServer) Delete(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "indexRuleBinding", "delete")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRuleBinding", "delete")
}()
- ok, err := rs.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, req.GetMetadata())
+ ok, deleteTime, err := rs.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, req.GetMetadata())
if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "indexRuleBinding", "delete")
return nil, err
}
return &databasev1.IndexRuleBindingRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
@@ -325,11 +329,12 @@ func (rs *indexRuleRegistryServer) Create(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "indexRule", "create")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRule", "create")
}()
- if err := rs.schemaRegistry.IndexRuleRegistry().CreateIndexRule(ctx, req.GetIndexRule()); err != nil {
+ modRevision, err := rs.schemaRegistry.IndexRuleRegistry().CreateIndexRule(ctx, req.GetIndexRule())
+ if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "indexRule", "create")
return nil, err
}
- return &databasev1.IndexRuleRegistryServiceCreateResponse{}, nil
+ return &databasev1.IndexRuleRegistryServiceCreateResponse{ModRevision: modRevision}, nil
}
func (rs *indexRuleRegistryServer) Update(ctx context.Context,
@@ -343,11 +348,12 @@ func (rs *indexRuleRegistryServer) Update(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "indexRule", "update")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRule", "update")
}()
- if err := rs.schemaRegistry.IndexRuleRegistry().UpdateIndexRule(ctx, req.GetIndexRule()); err != nil {
+ modRevision, err := rs.schemaRegistry.IndexRuleRegistry().UpdateIndexRule(ctx, req.GetIndexRule())
+ if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "indexRule", "update")
return nil, err
}
- return &databasev1.IndexRuleRegistryServiceUpdateResponse{}, nil
+ return &databasev1.IndexRuleRegistryServiceUpdateResponse{ModRevision: modRevision}, nil
}
func (rs *indexRuleRegistryServer) Delete(ctx context.Context,
@@ -361,13 +367,14 @@ func (rs *indexRuleRegistryServer) Delete(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "indexRule", "delete")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRule", "delete")
}()
- ok, err := rs.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, req.GetMetadata())
+ ok, deleteTime, err := rs.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, req.GetMetadata())
if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "indexRule", "delete")
return nil, err
}
return &databasev1.IndexRuleRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
@@ -497,13 +504,14 @@ func (rs *measureRegistryServer) Delete(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "measure", "delete")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "measure", "delete")
}()
- ok, err := rs.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, req.GetMetadata())
+ ok, deleteTime, err := rs.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, req.GetMetadata())
if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "measure", "delete")
return nil, err
}
return &databasev1.MeasureRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
@@ -592,11 +600,14 @@ func (rs *groupRegistryServer) Create(ctx context.Context, req *databasev1.Group
rs.metrics.totalRegistryFinished.Inc(1, g, "group", "create")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "group", "create")
}()
- if err := rs.schemaRegistry.GroupRegistry().CreateGroup(ctx, req.GetGroup()); err != nil {
+ modRevision, err := rs.schemaRegistry.GroupRegistry().CreateGroup(ctx, req.GetGroup())
+ if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "group", "create")
return nil, err
}
- return &databasev1.GroupRegistryServiceCreateResponse{}, nil
+ return &databasev1.GroupRegistryServiceCreateResponse{
+ ModRevision: modRevision,
+ }, nil
}
func (rs *groupRegistryServer) Update(ctx context.Context, req *databasev1.GroupRegistryServiceUpdateRequest) (
@@ -609,11 +620,14 @@ func (rs *groupRegistryServer) Update(ctx context.Context, req *databasev1.Group
rs.metrics.totalRegistryFinished.Inc(1, g, "group", "update")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "group", "update")
}()
- if err := rs.schemaRegistry.GroupRegistry().UpdateGroup(ctx, req.GetGroup()); err != nil {
+ modRevision, err := rs.schemaRegistry.GroupRegistry().UpdateGroup(ctx, req.GetGroup())
+ if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "group", "update")
return nil, err
}
- return &databasev1.GroupRegistryServiceUpdateResponse{}, nil
+ return &databasev1.GroupRegistryServiceUpdateResponse{
+ ModRevision: modRevision,
+ }, nil
}
func (rs *groupRegistryServer) Delete(ctx context.Context, req *databasev1.GroupRegistryServiceDeleteRequest) (
@@ -877,11 +891,14 @@ func (ts *topNAggregationRegistryServer) Create(ctx context.Context,
ts.metrics.totalRegistryFinished.Inc(1, g, "topn_aggregation", "create")
ts.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "topn_aggregation", "create")
}()
- if err := ts.schemaRegistry.TopNAggregationRegistry().CreateTopNAggregation(ctx, req.GetTopNAggregation()); err != nil {
+ modRevision, err := ts.schemaRegistry.TopNAggregationRegistry().CreateTopNAggregation(ctx, req.GetTopNAggregation())
+ if err != nil {
ts.metrics.totalRegistryErr.Inc(1, g, "topn_aggregation", "create")
return nil, err
}
- return &databasev1.TopNAggregationRegistryServiceCreateResponse{}, nil
+ return &databasev1.TopNAggregationRegistryServiceCreateResponse{
+ ModRevision: modRevision,
+ }, nil
}
func (ts *topNAggregationRegistryServer) Update(ctx context.Context,
@@ -894,11 +911,14 @@ func (ts *topNAggregationRegistryServer) Update(ctx context.Context,
ts.metrics.totalRegistryFinished.Inc(1, g, "topn_aggregation", "update")
ts.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "topn_aggregation", "update")
}()
- if err := ts.schemaRegistry.TopNAggregationRegistry().UpdateTopNAggregation(ctx, req.GetTopNAggregation()); err != nil {
+ modRevision, err := ts.schemaRegistry.TopNAggregationRegistry().UpdateTopNAggregation(ctx, req.GetTopNAggregation())
+ if err != nil {
ts.metrics.totalRegistryErr.Inc(1, g, "topn_aggregation", "update")
return nil, err
}
- return &databasev1.TopNAggregationRegistryServiceUpdateResponse{}, nil
+ return &databasev1.TopNAggregationRegistryServiceUpdateResponse{
+ ModRevision: modRevision,
+ }, nil
}
func (ts *topNAggregationRegistryServer) Delete(ctx context.Context,
@@ -911,13 +931,14 @@ func (ts *topNAggregationRegistryServer) Delete(ctx context.Context,
ts.metrics.totalRegistryFinished.Inc(1, g, "topn_aggregation", "delete")
ts.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "topn_aggregation", "delete")
}()
- ok, err := ts.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, req.GetMetadata())
+ ok, deleteTime, err := ts.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, req.GetMetadata())
if err != nil {
ts.metrics.totalRegistryErr.Inc(1, g, "topn_aggregation", "delete")
return nil, err
}
return &databasev1.TopNAggregationRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
@@ -1036,13 +1057,14 @@ func (ps *propertyRegistryServer) Delete(ctx context.Context, req *databasev1.Pr
ps.metrics.totalRegistryFinished.Inc(1, g, "property", "delete")
ps.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "property", "delete")
}()
- ok, err := ps.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, req.GetMetadata())
+ ok, deleteTime, err := ps.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, req.GetMetadata())
if err != nil {
ps.metrics.totalRegistryErr.Inc(1, g, "property", "delete")
return nil, err
}
return &databasev1.PropertyRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
@@ -1167,13 +1189,14 @@ func (rs *traceRegistryServer) Delete(ctx context.Context,
rs.metrics.totalRegistryFinished.Inc(1, g, "trace", "delete")
rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "trace", "delete")
}()
- ok, err := rs.schemaRegistry.TraceRegistry().DeleteTrace(ctx, req.GetMetadata())
+ ok, deleteTime, err := rs.schemaRegistry.TraceRegistry().DeleteTrace(ctx, req.GetMetadata())
if err != nil {
rs.metrics.totalRegistryErr.Inc(1, g, "trace", "delete")
return nil, err
}
return &databasev1.TraceRegistryServiceDeleteResponse{
- Deleted: ok,
+ Deleted: ok,
+ DeleteTime: deleteTime,
}, nil
}
diff --git a/banyand/liaison/grpc/schema_revision.go b/banyand/liaison/grpc/schema_revision.go
new file mode 100644
index 000000000..ba902f6a6
--- /dev/null
+++ b/banyand/liaison/grpc/schema_revision.go
@@ -0,0 +1,139 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "time"
+
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// clampTimeRangeBegin advances begin to the maximum value in createdAts when begin
+// falls before it (Rule 7: query time-range clamp to schema.CreatedAt).
+// Returns the (possibly clamped) begin time and empty=true when the clamped begin
+// is already after end, which means no data can exist in the resulting range.
+// A zero end is treated as unbounded — empty is never true in that case.
+// createdAts entries that are zero are ignored.
+func clampTimeRangeBegin(begin, end time.Time, createdAts []time.Time) (time.Time, bool) {
+ var maxCreatedAt time.Time
+ for _, ca := range createdAts {
+ if ca.After(maxCreatedAt) {
+ maxCreatedAt = ca
+ }
+ }
+ if maxCreatedAt.IsZero() {
+ return begin, false
+ }
+ if begin.Before(maxCreatedAt) {
+ begin = maxCreatedAt
+ }
+ if !end.IsZero() && begin.After(end) {
+ return begin, true
+ }
+ return begin, false
+}
+
+// checkQueryGate evaluates the per-group ModRevision gate for a query request.
+// It returns the collected group statuses and a shortCircuit flag.
+// When shortCircuit is true the caller must not execute the query and should
+// return a response populated only with the returned group_statuses map.
+// When shortCircuit is false and the returned map is non-empty, all gated
+// groups passed (STATUS_SUCCEED) and the caller should attach the map to the
+// successful query response.
+// An empty groupModRevisions map skips the gate entirely (backward compat).
+//
+// maxWait is a single overall deadline shared across every "ahead" group rather
+// than a per-group budget, so a multi-group query with N gated groups cannot
+// exceed maxWait wall-clock time.
+func checkQueryGate(
+ groups []string,
+ name string,
+ groupModRevisions map[string]int64,
+ getLocatorRevision func(name, group string) (int64, bool),
+ maxWait time.Duration,
+) (map[string]modelv1.Status, bool) {
+ if len(groupModRevisions) == 0 {
+ return nil, false
+ }
+ deadline := time.Now().Add(maxWait)
+ groupStatuses := make(map[string]modelv1.Status)
+ for _, g := range groups {
+ clientRev, revOK := groupModRevisions[g]
+ if !revOK || clientRev == 0 {
+ continue
+ }
+ cacheRev, found := getLocatorRevision(name, g)
+ if !found {
+ groupStatuses[g] = modelv1.Status_STATUS_NOT_FOUND
+ continue
+ }
+ switch {
+ case clientRev < cacheRev:
+ groupStatuses[g] = modelv1.Status_STATUS_EXPIRED_SCHEMA
+ case clientRev > cacheRev:
+ currentGroup := g
+ reached := awaitRevisionReached(func() int64 {
+ rev, _ := getLocatorRevision(name, currentGroup)
+ return rev
+ }, clientRev, time.Until(deadline))
+ if reached {
+ groupStatuses[g] = modelv1.Status_STATUS_SUCCEED
+ } else {
+ groupStatuses[g] = modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+ }
+ default:
+ groupStatuses[g] = modelv1.Status_STATUS_SUCCEED
+ }
+ }
+ if len(groupStatuses) == 0 {
+ return nil, false
+ }
+ for _, st := range groupStatuses {
+ if st != modelv1.Status_STATUS_SUCCEED {
+ return groupStatuses, true
+ }
+ }
+ return groupStatuses, false
+}
+
+// awaitRevisionReached polls getRevision until the returned value is >= target or maxWait elapses.
+// Returns true when the target revision was observed, false on timeout.
+// The initial check is performed before any sleep, so a cache that is already at target never
+// sleeps. Backoff starts at 10 ms, is multiplied by 1.5 each iteration, and is capped at 1 s.
+// If maxWait <= 0 the function returns false immediately without polling.
+func awaitRevisionReached(getRevision func() int64, target int64, maxWait time.Duration) bool {
+ if getRevision() >= target {
+ return true
+ }
+ if maxWait <= 0 {
+ return false
+ }
+ retryInterval := 10 * time.Millisecond
+ deadline := time.Now().Add(maxWait)
+ for time.Now().Before(deadline) {
+ time.Sleep(retryInterval)
+ if getRevision() >= target {
+ return true
+ }
+ retryInterval = time.Duration(float64(retryInterval) * 1.5)
+ if retryInterval > time.Second {
+ retryInterval = time.Second
+ }
+ }
+ return false
+}
diff --git a/banyand/liaison/grpc/server.go b/banyand/liaison/grpc/server.go
index a73cdfd7b..e8251df27 100644
--- a/banyand/liaison/grpc/server.go
+++ b/banyand/liaison/grpc/server.go
@@ -43,6 +43,7 @@ import (
databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
"github.com/apache/skywalking-banyandb/banyand/internal/storage"
@@ -104,7 +105,8 @@ type server struct {
stopCh chan struct{}
*indexRuleRegistryServer
*measureRegistryServer
- streamSVC *streamService
+ streamSVC *streamService
+ barrierSVC *barrierService
*streamRegistryServer
measureSVC *measureService
bydbQLSVC *bydbQLService
@@ -179,6 +181,20 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
propertyServer: propertyService,
}
+ var barrierSVC *barrierService
+ if svc, svcOk := schemaRegistry.(metadata.Service); svcOk {
+ barrierSVC = newBarrierService(func() barrierCacheReader {
+ inner := svc.SchemaRegistry()
+ if inner == nil {
+ return nil
+ }
+ bc, bcOk := inner.(barrierCacheReader)
+ if !bcOk {
+ return nil
+ }
+ return bc
+ })
+ }
s := &server{
omr: omr,
streamSVC: streamSVC,
@@ -186,6 +202,7 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
traceSVC: traceSVC,
bydbQLSVC: bydbQLSVC,
groupRepo: gr,
+ barrierSVC: barrierSVC,
streamRegistryServer: &streamRegistryServer{
schemaRegistry: schemaRegistry,
},
@@ -489,6 +506,9 @@ func (s *server) Serve() run.StopNotify {
databasev1.RegisterTraceRegistryServiceServer(s.ser, s.traceRegistryServer)
databasev1.RegisterClusterStateServiceServer(s.ser, s)
databasev1.RegisterNodeQueryServiceServer(s.ser, s)
+ if s.barrierSVC != nil {
+ schemav1.RegisterSchemaBarrierServiceServer(s.ser, s.barrierSVC)
+ }
grpc_health_v1.RegisterHealthServer(s.ser, health.NewServer())
s.stopCh = make(chan struct{})
diff --git a/banyand/liaison/grpc/stream.go b/banyand/liaison/grpc/stream.go
index ba26b3ef4..4c4316915 100644
--- a/banyand/liaison/grpc/stream.go
+++ b/banyand/liaison/grpc/stream.go
@@ -25,6 +25,7 @@ import (
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/api/common"
"github.com/apache/skywalking-banyandb/api/data"
@@ -96,17 +97,33 @@ func (s *streamService) validateWriteRequest(writeEntity *streamv1.WriteRequest,
}
if metadata.ModRevision > 0 {
- streamCache, existed := s.entityRepo.getLocator(getID(metadata))
+ id := getID(metadata)
+ streamCache, existed := s.entityRepo.getLocator(id)
if !existed {
s.l.Error().Stringer("written", writeEntity).Msg("stream schema not found")
s.sendReply(metadata, modelv1.Status_STATUS_NOT_FOUND, writeEntity.GetMessageId(), stream)
return modelv1.Status_STATUS_NOT_FOUND
}
- if metadata.ModRevision != streamCache.ModRevision {
+ clientRev := metadata.ModRevision
+ cacheRev := streamCache.ModRevision
+ if clientRev < cacheRev {
s.l.Error().Stringer("written", writeEntity).Msg("the stream schema is expired")
s.sendReply(metadata, modelv1.Status_STATUS_EXPIRED_SCHEMA, writeEntity.GetMessageId(), stream)
return modelv1.Status_STATUS_EXPIRED_SCHEMA
}
+ if clientRev > cacheRev {
+ reached := awaitRevisionReached(func() int64 {
+ loc, ok := s.entityRepo.getLocator(id)
+ if !ok {
+ return 0
+ }
+ return loc.ModRevision
+ }, clientRev, s.maxWaitDuration)
+ if !reached {
+ s.sendReply(metadata, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, writeEntity.GetMessageId(), stream)
+ return modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+ }
+ }
}
return modelv1.Status_STATUS_SUCCEED
@@ -386,6 +403,40 @@ func (s *streamService) Query(ctx context.Context, req *streamv1.QueryRequest) (
}
}()
}
+ gatedStatuses, shortCircuit := checkQueryGate(req.Groups, req.Name, req.GroupModRevisions,
+ func(name, group string) (int64, bool) {
+ loc, ok := s.entityRepo.getLocator(identity{name: name, group: group})
+ return loc.ModRevision, ok
+ }, s.maxWaitDuration)
+ if shortCircuit {
+ return &streamv1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+ }
+ // See measure.go for rationale: gate the clamp on the same opt-in trigger as the
+ // query gate so legacy clients that omit GroupModRevisions are not affected.
+ if req.TimeRange != nil && len(req.GroupModRevisions) > 0 {
+ createdAts := make([]time.Time, 0, len(req.Groups))
+ for _, group := range req.Groups {
+ streamEntity, streamOK := s.entityRepo.getStream(identity{name: req.Name, group: group})
+ if streamOK && streamEntity.GetCreatedAt() != nil {
+ createdAts = append(createdAts, streamEntity.GetCreatedAt().AsTime())
+ }
+ }
+ endTime := time.Time{}
+ if req.TimeRange.GetEnd() != nil {
+ endTime = req.TimeRange.GetEnd().AsTime()
+ }
+ originalBegin := req.TimeRange.GetBegin().AsTime()
+ newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
+ if rangeEmpty {
+ if len(gatedStatuses) > 0 {
+ return &streamv1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+ }
+ return emptyStreamQueryResponse, nil
+ }
+ if newBegin != originalBegin {
+ req.TimeRange.Begin = timestamppb.New(newBegin)
+ }
+ }
message := bus.NewMessage(bus.MessageID(now.UnixNano()), req)
feat, errQuery := s.broadcaster.Publish(ctx, data.TopicStreamQuery, message)
if errQuery != nil {
@@ -402,6 +453,9 @@ func (s *streamService) Query(ctx context.Context, req *streamv1.QueryRequest) (
switch d := data.(type) {
case *streamv1.QueryResponse:
responseElementCount = len(d.Elements)
+ if len(gatedStatuses) > 0 {
+ d.GroupStatuses = gatedStatuses
+ }
return d, nil
case *common.Error:
return nil, errors.WithMessage(errQueryMsg, d.Error())
diff --git a/banyand/liaison/grpc/stream_write_test.go b/banyand/liaison/grpc/stream_write_test.go
new file mode 100644
index 000000000..31af53b73
--- /dev/null
+++ b/banyand/liaison/grpc/stream_write_test.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func newTestStreamService(er *entityRepo, maxWait time.Duration) *streamService {
+ return &streamService{
+ discoveryService: &discoveryService{entityRepo: er},
+ maxWaitDuration: maxWait,
+ l: logger.GetLogger("test"),
+ metrics: newBypassMetrics(),
+ }
+}
+
+func validStreamWriteRequest() *streamv1.WriteRequest {
+ return &streamv1.WriteRequest{
+ Element: &streamv1.ElementValue{
+ Timestamp: timestamppb.New(time.Now().Truncate(time.Millisecond)),
+ },
+ }
+}
+
+// TestValidateWriteRequest_Stream_ClientLower_ReturnsExpiredSchema verifies that
+// a write with client ModRevision below the cached revision is rejected immediately
+// with STATUS_EXPIRED_SCHEMA.
+func TestValidateWriteRequest_Stream_ClientLower_ReturnsExpiredSchema(t *testing.T) {
+ id := identity{group: "g", name: "s"}
+ er := seededLocatorRepo(id)
+ svc := newTestStreamService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 50}
+ st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, st)
+ require.Len(t, mock.replies, 1)
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Stream_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout
+// verifies that a write with client ModRevision above the cached revision waits up to
+// maxWaitDuration and then returns STATUS_SCHEMA_NOT_APPLIED when the cache stays stale.
+func TestValidateWriteRequest_Stream_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout(t *testing.T) {
+ id := identity{group: "g", name: "s"}
+ er := seededLocatorRepo(id)
+ svc := newTestStreamService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 200}
+ st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, st)
+ require.Len(t, mock.replies, 1)
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Stream_ClientHigher_SucceedsWhenCacheCatchesUp verifies
+// that a write with client ModRevision ahead of the cache returns STATUS_SUCCEED
+// once the cache advances to the required revision within maxWaitDuration.
+func TestValidateWriteRequest_Stream_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+ id := identity{group: "g", name: "s"}
+ er := seededLocatorRepo(id)
+ svc := newTestStreamService(er, 500*time.Millisecond)
+ mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+ advanceLocatorAfter(er, id, 200, 20*time.Millisecond)
+
+ meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 200}
+ st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies, "no error reply should be sent when validation succeeds")
+}
+
+// TestValidateWriteRequest_Stream_Equal_ReturnsSucceed verifies that a write with
+// client ModRevision equal to the cached revision is accepted immediately.
+func TestValidateWriteRequest_Stream_Equal_ReturnsSucceed(t *testing.T) {
+ id := identity{group: "g", name: "s"}
+ er := seededLocatorRepo(id)
+ svc := newTestStreamService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 100}
+ st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies)
+}
+
+// TestValidateWriteRequest_Stream_ZeroRevision_SkipsCheck verifies that a write
+// with ModRevision == 0 skips the revision gate entirely and returns STATUS_SUCCEED,
+// even when no locator is registered for the schema.
+func TestValidateWriteRequest_Stream_ZeroRevision_SkipsCheck(t *testing.T) {
+ // Empty repo — if the gate ran it would return STATUS_NOT_FOUND.
+ er := newEmptyEntityRepo()
+ svc := newTestStreamService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 0}
+ st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies)
+}
diff --git a/banyand/liaison/grpc/trace.go b/banyand/liaison/grpc/trace.go
index 3143d6418..5d3164590 100644
--- a/banyand/liaison/grpc/trace.go
+++ b/banyand/liaison/grpc/trace.go
@@ -25,6 +25,7 @@ import (
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/api/common"
"github.com/apache/skywalking-banyandb/api/data"
@@ -144,11 +145,26 @@ func (s *traceService) validateWriteRequest(writeEntity *tracev1.WriteRequest,
}
if metadata.ModRevision > 0 {
- if metadata.ModRevision != traceEntity.GetMetadata().GetModRevision() {
+ clientRev := metadata.ModRevision
+ cacheRev := traceEntity.GetMetadata().GetModRevision()
+ if clientRev < cacheRev {
s.l.Error().Stringer("written", writeEntity).Msg("the trace schema is expired")
s.sendReply(metadata, modelv1.Status_STATUS_EXPIRED_SCHEMA, writeEntity.GetVersion(), stream)
return modelv1.Status_STATUS_EXPIRED_SCHEMA
}
+ if clientRev > cacheRev {
+ reached := awaitRevisionReached(func() int64 {
+ e, ok := s.entityRepo.getTrace(id)
+ if !ok {
+ return 0
+ }
+ return e.GetMetadata().GetModRevision()
+ }, clientRev, s.maxWaitDuration)
+ if !reached {
+ s.sendReply(metadata, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, writeEntity.GetVersion(), stream)
+ return modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+ }
+ }
}
return modelv1.Status_STATUS_SUCCEED
@@ -417,6 +433,36 @@ func (s *traceService) Write(stream tracev1.TraceService_WriteServer) error {
var emptyTraceQueryResponse = &tracev1.QueryResponse{Traces: make([]*tracev1.Trace, 0)}
+// applyClamp rewrites req.TimeRange.Begin to schema.CreatedAt for schema-aware
+// queries (those carrying GroupModRevisions). Returns true when the clamped
+// range is empty, in which case the caller should short-circuit with an empty
+// response. See measure.go for the rationale on the GroupModRevisions opt-in.
+func (s *traceService) applyClamp(req *tracev1.QueryRequest) bool {
+ if req.TimeRange == nil || len(req.GroupModRevisions) == 0 {
+ return false
+ }
+ createdAts := make([]time.Time, 0, len(req.Groups))
+ for _, group := range req.Groups {
+ traceEntity, traceOK := s.entityRepo.getTrace(identity{name: req.Name, group: group})
+ if traceOK && traceEntity.GetCreatedAt() != nil {
+ createdAts = append(createdAts, traceEntity.GetCreatedAt().AsTime())
+ }
+ }
+ endTime := time.Time{}
+ if req.TimeRange.GetEnd() != nil {
+ endTime = req.TimeRange.GetEnd().AsTime()
+ }
+ originalBegin := req.TimeRange.GetBegin().AsTime()
+ newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
+ if rangeEmpty {
+ return true
+ }
+ if newBegin != originalBegin {
+ req.TimeRange.Begin = timestamppb.New(newBegin)
+ }
+ return false
+}
+
func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (resp *tracev1.QueryResponse, err error) {
for _, g := range req.Groups {
if acquireErr := s.groupRepo.acquireRequest(g); acquireErr != nil {
@@ -475,6 +521,20 @@ func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (re
}
}()
}
+ gatedStatuses, shortCircuit := checkQueryGate(req.Groups, req.Name, req.GroupModRevisions,
+ func(name, group string) (int64, bool) {
+ loc, ok := s.entityRepo.getLocator(identity{name: name, group: group})
+ return loc.ModRevision, ok
+ }, s.maxWaitDuration)
+ if shortCircuit {
+ return &tracev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+ }
+ if rangeEmpty := s.applyClamp(req); rangeEmpty {
+ if len(gatedStatuses) > 0 {
+ return &tracev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+ }
+ return emptyTraceQueryResponse, nil
+ }
message := bus.NewMessage(bus.MessageID(now.UnixNano()), req)
var future bus.Future
future, err = s.broadcaster.Publish(ctx, data.TopicTraceQuery, message)
@@ -500,10 +560,14 @@ func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (re
traces = append(traces, trace)
}
responseTraceCount = len(traces)
- return &tracev1.QueryResponse{
+ queryResp := &tracev1.QueryResponse{
Traces: traces,
TraceQueryResult: d.TraceQueryResult,
- }, nil
+ }
+ if len(gatedStatuses) > 0 {
+ queryResp.GroupStatuses = gatedStatuses
+ }
+ return queryResp, nil
case *common.Error:
return nil, errors.WithMessage(errQueryMsg, d.Error())
}
diff --git a/banyand/liaison/grpc/trace_write_test.go b/banyand/liaison/grpc/trace_write_test.go
new file mode 100644
index 000000000..989ea298c
--- /dev/null
+++ b/banyand/liaison/grpc/trace_write_test.go
@@ -0,0 +1,138 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func newTestTraceService(er *entityRepo, maxWait time.Duration) *traceService {
+ return &traceService{
+ discoveryService: &discoveryService{entityRepo: er},
+ maxWaitDuration: maxWait,
+ l: logger.GetLogger("test"),
+ metrics: newBypassMetrics(),
+ }
+}
+
+// validTraceWriteRequest returns a WriteRequest whose tags include one timestamp tag,
+// satisfying the foundTimestamp check in validateWriteRequest.
+func validTraceWriteRequest() *tracev1.WriteRequest {
+ return &tracev1.WriteRequest{
+ Tags: []*modelv1.TagValue{{
+ Value: &modelv1.TagValue_Timestamp{
+ Timestamp: timestamppb.New(time.Now().Truncate(time.Millisecond)),
+ },
+ }},
+ }
+}
+
+// TestValidateWriteRequest_Trace_ClientLower_ReturnsExpiredSchema verifies that
+// a write with client ModRevision below the cached revision is rejected immediately
+// with STATUS_EXPIRED_SCHEMA.
+func TestValidateWriteRequest_Trace_ClientLower_ReturnsExpiredSchema(t *testing.T) {
+ id := identity{group: "g", name: "t"}
+ er := seededTraceRepo(id, 100)
+ svc := newTestTraceService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 50}
+ st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, st)
+ require.Len(t, mock.replies, 1)
+ assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Trace_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout
+// verifies that a write with client ModRevision above the cached revision waits up to
+// maxWaitDuration and then returns STATUS_SCHEMA_NOT_APPLIED when the cache stays stale.
+func TestValidateWriteRequest_Trace_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout(t *testing.T) {
+ id := identity{group: "g", name: "t"}
+ er := seededTraceRepo(id, 100)
+ svc := newTestTraceService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 200}
+ st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, st)
+ require.Len(t, mock.replies, 1)
+ assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Trace_ClientHigher_SucceedsWhenCacheCatchesUp verifies
+// that a write with client ModRevision ahead of the trace cache returns STATUS_SUCCEED
+// once the trace entry advances to the required revision within maxWaitDuration.
+func TestValidateWriteRequest_Trace_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+ id := identity{group: "g", name: "t"}
+ er := seededTraceRepo(id, 100)
+ svc := newTestTraceService(er, 500*time.Millisecond)
+ mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+ advanceTraceRevAfter(er, id, 200, 20*time.Millisecond)
+
+ meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 200}
+ st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies, "no error reply should be sent when validation succeeds")
+}
+
+// TestValidateWriteRequest_Trace_Equal_ReturnsSucceed verifies that a write with
+// client ModRevision equal to the cached trace revision is accepted immediately.
+func TestValidateWriteRequest_Trace_Equal_ReturnsSucceed(t *testing.T) {
+ id := identity{group: "g", name: "t"}
+ er := seededTraceRepo(id, 100)
+ svc := newTestTraceService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 100}
+ st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies)
+}
+
+// TestValidateWriteRequest_Trace_ZeroRevision_SkipsCheck verifies that a write
+// with ModRevision == 0 skips the revision gate and returns STATUS_SUCCEED.
+// The trace entity must still be present because the trace path always resolves the
+// entity before the revision check.
+func TestValidateWriteRequest_Trace_ZeroRevision_SkipsCheck(t *testing.T) {
+ id := identity{group: "g", name: "t"}
+ // Seed the trace entity so getTrace(id) finds it; the revision value does not matter.
+ er := seededTraceRepo(id, 999)
+ svc := newTestTraceService(er, 50*time.Millisecond)
+ mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+ meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 0}
+ st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+ assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+ assert.Empty(t, mock.replies)
+}
diff --git a/banyand/liaison/grpc/write_helpers_test.go b/banyand/liaison/grpc/write_helpers_test.go
new file mode 100644
index 000000000..bc789a507
--- /dev/null
+++ b/banyand/liaison/grpc/write_helpers_test.go
@@ -0,0 +1,118 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "context"
+ "io"
+ "sync"
+ "time"
+
+ grpcmd "google.golang.org/grpc/metadata"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ "github.com/apache/skywalking-banyandb/banyand/observability"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+ "github.com/apache/skywalking-banyandb/pkg/partition"
+)
+
+// mockBidiServer captures Send calls emitted by service handlers under test.
+// It satisfies grpc.BidiStreamingServer[Req, Res] (= grpc.ServerStream + Recv + Send).
+type mockBidiServer[Req any, Res any] struct {
+ replies []*Res
+ mu sync.Mutex
+}
+
+func (s *mockBidiServer[Req, Res]) Send(resp *Res) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.replies = append(s.replies, resp)
+ return nil
+}
+
+func (s *mockBidiServer[Req, Res]) Recv() (*Req, error) { return nil, io.EOF }
+func (s *mockBidiServer[Req, Res]) Context() context.Context { return context.Background() }
+func (s *mockBidiServer[Req, Res]) SetHeader(grpcmd.MD) error { return nil }
+func (s *mockBidiServer[Req, Res]) SendHeader(grpcmd.MD) error { return nil }
+func (s *mockBidiServer[Req, Res]) SetTrailer(grpcmd.MD) {}
+func (s *mockBidiServer[Req, Res]) SendMsg(any) error { return nil }
+func (s *mockBidiServer[Req, Res]) RecvMsg(any) error { return nil }
+
+// newBypassMetrics returns a metrics instance backed by a no-op registry.
+func newBypassMetrics() *metrics {
+ factory := observability.NewBypassRegistry().With(liaisonGrpcScope)
+ return newMetrics(factory)
+}
+
+// newEmptyEntityRepo constructs an entityRepo with all maps initialized but empty.
+func newEmptyEntityRepo() *entityRepo {
+ return &entityRepo{
+ log: logger.GetLogger("test"),
+ entitiesMap: make(map[identity]partition.Locator),
+ measureMap: make(map[identity]*databasev1.Measure),
+ streamMap: make(map[identity]*databasev1.Stream),
+ traceMap: make(map[identity]*databasev1.Trace),
+ traceIDIndexMap: make(map[identity]int),
+ }
+}
+
+// seededLocatorRepoRev is the baseline ModRevision every test uses for the seeded
+// locator. Picking 100 leaves headroom for tests that simulate stale (<100) and
+// future (>100) client revisions against this baseline.
+const seededLocatorRepoRev int64 = 100
+
+// seededLocatorRepo returns an entityRepo with a single locator entry at the
+// fixed seededLocatorRepoRev baseline. Tests that need a different revision
+// should mutate er.entitiesMap directly.
+func seededLocatorRepo(id identity) *entityRepo {
+ er := newEmptyEntityRepo()
+ er.entitiesMap[id] = partition.Locator{ModRevision: seededLocatorRepoRev}
+ return er
+}
+
+// seededTraceRepo returns an entityRepo with a single trace entry at rev.
+func seededTraceRepo(id identity, rev int64) *entityRepo {
+ er := newEmptyEntityRepo()
+ er.traceMap[id] = &databasev1.Trace{
+ Metadata: &commonv1.Metadata{Group: id.group, Name: id.name, ModRevision: rev},
+ }
+ return er
+}
+
+// advanceLocatorAfter advances the locator revision for id in er to newRev after delay.
+func advanceLocatorAfter(er *entityRepo, id identity, newRev int64, delay time.Duration) {
+ go func() {
+ time.Sleep(delay)
+ er.RWMutex.Lock()
+ er.entitiesMap[id] = partition.Locator{ModRevision: newRev}
+ er.RWMutex.Unlock()
+ }()
+}
+
+// advanceTraceRevAfter replaces the trace entry for id with one at newRev after delay.
+func advanceTraceRevAfter(er *entityRepo, id identity, newRev int64, delay time.Duration) {
+ go func() {
+ time.Sleep(delay)
+ er.RWMutex.Lock()
+ er.traceMap[id] = &databasev1.Trace{
+ Metadata: &commonv1.Metadata{Group: id.group, Name: id.name, ModRevision: newRev},
+ }
+ er.RWMutex.Unlock()
+ }()
+}
diff --git a/banyand/measure/metadata.go b/banyand/measure/metadata.go
index f14b87416..29ff21ebc 100644
--- a/banyand/measure/metadata.go
+++ b/banyand/measure/metadata.go
@@ -301,9 +301,10 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
case schema.KindMeasure:
m := metadata.Spec.(*databasev1.Measure)
sr.SendMetadataEvent(resourceSchema.MetadataEvent{
- Typ: resourceSchema.EventDelete,
- Kind: resourceSchema.EventKindResource,
- Metadata: m,
+ Typ: resourceSchema.EventDelete,
+ Kind: resourceSchema.EventKindResource,
+ DeleteRevision: m.GetMetadata().GetModRevision(),
+ Metadata: m,
})
sr.stopSteamingManager(m.GetMetadata())
case schema.KindIndexRuleBinding:
diff --git a/banyand/measure/metadata_test.go b/banyand/measure/metadata_test.go
index 0c366bfa1..ecb647581 100644
--- a/banyand/measure/metadata_test.go
+++ b/banyand/measure/metadata_test.go
@@ -60,7 +60,7 @@ var _ = Describe("Metadata", func() {
Context("Manage group", func() {
It("should close the group", func() {
- deleted, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "sw_metric")
+ deleted, _, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "sw_metric")
Expect(err).ShouldNot(HaveOccurred())
Expect(deleted).Should(BeTrue())
Eventually(func() bool {
@@ -75,7 +75,8 @@ var _ = Describe("Metadata", func() {
Expect(groupSchema).ShouldNot(BeNil())
groupSchema.ResourceOpts.ShardNum = 4
- Expect(svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)).Should(Succeed())
+ _, updateGroupErr := svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)
+ Expect(updateGroupErr).ShouldNot(HaveOccurred())
Eventually(func() bool {
group, ok := svcs.measure.LoadGroup("sw_metric")
@@ -98,7 +99,7 @@ var _ = Describe("Metadata", func() {
}).WithTimeout(flags.EventuallyTimeout).Should(BeTrue())
})
It("should close the measure", func() {
- deleted, err := svcs.metadataService.MeasureRegistry().DeleteMeasure(context.TODO(), &commonv1.Metadata{
+ deleted, _, err := svcs.metadataService.MeasureRegistry().DeleteMeasure(context.TODO(), &commonv1.Metadata{
Name: "service_cpm_minute",
Group: "sw_metric",
})
@@ -208,8 +209,8 @@ var _ = Describe("Metadata", func() {
Tags: []string{"new_tag"},
Type: databasev1.IndexRule_TYPE_INVERTED,
}
- err := svcs.metadataService.IndexRuleRegistry().CreateIndexRule(context.TODO(), indexRule)
- Expect(err).ShouldNot(HaveOccurred())
+ _, createIRErr := svcs.metadataService.IndexRuleRegistry().CreateIndexRule(context.TODO(), indexRule)
+ Expect(createIRErr).ShouldNot(HaveOccurred())
indexRuleBinding := &databasev1.IndexRuleBinding{
Metadata: &commonv1.Metadata{
Name: "service_cpm_minute_new_tag",
@@ -223,8 +224,8 @@ var _ = Describe("Metadata", func() {
BeginAt: timestamppb.New(timestamp.NowMilli().Add(-time.Hour)),
ExpireAt: timestamppb.New(timestamp.NowMilli().Add(time.Hour)),
}
- err = svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(context.TODO(), indexRuleBinding)
- Expect(err).ShouldNot(HaveOccurred())
+ _, createIRBErr := svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(context.TODO(), indexRuleBinding)
+ Expect(createIRBErr).ShouldNot(HaveOccurred())
Eventually(func() bool {
val, err := svcs.measure.Measure(&commonv1.Metadata{
@@ -735,7 +736,7 @@ func setupSchemaChangeMeasure(svcs *services, measureName string, opts measureSe
return &measureSchemaChangeEnv{
cleanup: func() {
- _, _ = svcs.metadataService.MeasureRegistry().DeleteMeasure(ctx, &commonv1.Metadata{
+ _, _, _ = svcs.metadataService.MeasureRegistry().DeleteMeasure(ctx, &commonv1.Metadata{
Name: measureName,
Group: schemaChangeGroupName,
})
diff --git a/banyand/measure/metrics.go b/banyand/measure/metrics.go
index ad38f3a5f..9e4e00dc3 100644
--- a/banyand/measure/metrics.go
+++ b/banyand/measure/metrics.go
@@ -453,9 +453,9 @@ func (s *standalone) createNativeObservabilityGroup(ctx context.Context) error {
},
},
}
- if err := s.metadata.GroupRegistry().CreateGroup(ctx, g); err != nil &&
- !errors.Is(err, schema.ErrGRPCAlreadyExists) {
- return errors.WithMessage(err, "failed to create native observability group")
+ if _, createGroupErr := s.metadata.GroupRegistry().CreateGroup(ctx, g); createGroupErr != nil &&
+ !errors.Is(createGroupErr, schema.ErrGRPCAlreadyExists) {
+ return errors.WithMessage(createGroupErr, "failed to create native observability group")
}
return nil
}
diff --git a/banyand/measure/svc_data.go b/banyand/measure/svc_data.go
index 065ff220f..e4f776f9b 100644
--- a/banyand/measure/svc_data.go
+++ b/banyand/measure/svc_data.go
@@ -384,9 +384,9 @@ func (s *dataSVC) createDataNativeObservabilityGroup(ctx context.Context) error
},
},
}
- if err := s.metadata.GroupRegistry().CreateGroup(ctx, g); err != nil &&
- !errors.Is(err, schema.ErrGRPCAlreadyExists) {
- return errors.WithMessage(err, "failed to create native observability group")
+ if _, createGroupErr := s.metadata.GroupRegistry().CreateGroup(ctx, g); createGroupErr != nil &&
+ !errors.Is(createGroupErr, schema.ErrGRPCAlreadyExists) {
+ return errors.WithMessage(createGroupErr, "failed to create native observability group")
}
return nil
}
diff --git a/banyand/metadata/schema/property/cache.go b/banyand/metadata/schema/property/cache.go
index 2cb96ec66..c594c25da 100644
--- a/banyand/metadata/schema/property/cache.go
+++ b/banyand/metadata/schema/property/cache.go
@@ -19,6 +19,7 @@ package property
import (
"sync"
+ "time"
"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
)
@@ -28,23 +29,50 @@ type cacheEntry struct {
group string
name string
latestUpdateAt int64
+ modRevision int64
kind schema.Kind
}
+// DefaultMaxTombstoneEntries bounds the size of the tombstone index. Each entry is
+// roughly a propID string plus an int64 deleteTime — at ~100 bytes apiece the default
+// caps memory at ~10MB. The cap is defensive: under pathological churn (e.g. rapid
+// create/delete loops) the GC loop alone would let the index grow to retention × rate,
+// which can exceed available memory before retention elapses.
+const DefaultMaxTombstoneEntries = 100_000
+
type schemaCache struct {
entries map[string]*cacheEntry
+ tombstoneIndex map[string]int64 // propID → deleteTime (unix nano)
latestUpdateAt int64
- mu sync.RWMutex
+ // notifiedModRevision is the monotonic watermark advanced only after all downstream
+ // schema event handlers (e.g. groupRepo, entityRepo, pkg/schema.schemaRepo) have been
+ // notified for a given mod_revision. The barrier reads this watermark so callers can
+ // rely on "AwaitRevisionApplied(R) returning true" meaning every downstream cache has
+ // processed all events up to R — not just the property cache.
+ notifiedModRevision int64
+ // maxTombstones bounds tombstoneIndex size; 0 means unlimited. When set, Delete
+ // evicts the oldest tombstone before recording a new one once the cap is reached.
+ // Eviction sacrifices retention for the smallest fraction of entries — preferable
+ // to OOM under a runaway delete workload.
+ maxTombstones int
+ mu sync.RWMutex
}
func newSchemaCache() *schemaCache {
+ return newSchemaCacheWithLimit(DefaultMaxTombstoneEntries)
+}
+
+func newSchemaCacheWithLimit(maxTombstones int) *schemaCache {
return &schemaCache{
- entries: make(map[string]*cacheEntry),
+ entries: make(map[string]*cacheEntry),
+ tombstoneIndex: make(map[string]int64),
+ maxTombstones: maxTombstones,
}
}
// Update inserts or updates an entry. Returns true if the entry was changed.
// Only accepts if entry.latestUpdateAt > existing.latestUpdateAt or not exist.
+// A live entry supersedes any tombstone for the same propID.
func (c *schemaCache) Update(propID string, entry *cacheEntry) bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -53,6 +81,7 @@ func (c *schemaCache) Update(propID string, entry *cacheEntry) bool {
return false
}
c.entries[propID] = entry
+ delete(c.tombstoneIndex, propID)
if entry.latestUpdateAt > c.latestUpdateAt {
c.latestUpdateAt = entry.latestUpdateAt
}
@@ -60,6 +89,10 @@ func (c *schemaCache) Update(propID string, entry *cacheEntry) bool {
}
// Delete removes an entry. Only deletes if revision >= entry.latestUpdateAt.
+// When an entry is removed, the propID is recorded in tombstoneIndex so the
+// GC loop can later purge it from the client cache after tombstoneRetention elapses.
+// If maxTombstones is set and the index is at capacity, the oldest tombstone
+// is evicted before the new one is recorded so the index never grows unbounded.
func (c *schemaCache) Delete(propID string, revision int64) bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -71,12 +104,63 @@ func (c *schemaCache) Delete(propID string, revision int64) bool {
return false
}
delete(c.entries, propID)
+ if revision > 0 {
+ if _, alreadyTracked := c.tombstoneIndex[propID]; !alreadyTracked {
+ c.evictOldestTombstoneIfFull()
+ }
+ c.tombstoneIndex[propID] = revision
+ }
if revision > c.latestUpdateAt {
c.latestUpdateAt = revision
}
return true
}
+// evictOldestTombstoneIfFull removes the tombstone with the smallest deleteTime
+// when the index has reached maxTombstones. Caller must hold c.mu.
+func (c *schemaCache) evictOldestTombstoneIfFull() {
+ if c.maxTombstones <= 0 || len(c.tombstoneIndex) < c.maxTombstones {
+ return
+ }
+ var oldestID string
+ var oldestTime int64
+ first := true
+ for pid, t := range c.tombstoneIndex {
+ if first || t < oldestTime {
+ oldestID = pid
+ oldestTime = t
+ first = false
+ }
+ }
+ if oldestID != "" {
+ delete(c.tombstoneIndex, oldestID)
+ }
+}
+
+// TombstoneEntries returns propIDs of tombstone entries whose deleteTime is older
+// than retention and are not superseded by a live entry in the cache.
+func (c *schemaCache) TombstoneEntries(retention time.Duration) []string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ cutoff := time.Now().Add(-retention).UnixNano()
+ var expired []string
+ for propID, deleteTime := range c.tombstoneIndex {
+ if deleteTime <= cutoff {
+ if _, live := c.entries[propID]; !live {
+ expired = append(expired, propID)
+ }
+ }
+ }
+ return expired
+}
+
+// RemoveTombstone removes a propID from the tombstone index.
+func (c *schemaCache) RemoveTombstone(propID string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ delete(c.tombstoneIndex, propID)
+}
+
// GetMaxRevision returns the global max revision.
func (c *schemaCache) GetMaxRevision() int64 {
c.mu.RLock()
@@ -84,6 +168,60 @@ func (c *schemaCache) GetMaxRevision() int64 {
return c.latestUpdateAt
}
+// GetMaxModRevision returns the monotonic watermark of the highest mod_revision
+// fully processed by every downstream schema event handler. Events with
+// mod_revision <= this value are guaranteed observable in all handler caches.
+func (c *schemaCache) GetMaxModRevision() int64 {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.notifiedModRevision
+}
+
+// GetKeyModRevision returns the modRevision for the given propID, but only once
+// that entry's mod_revision has been fully notified to every handler. An entry
+// present in the cache map but not yet notified downstream reports (0, false)
+// so barrier callers correctly wait for handler-side coherence.
+func (c *schemaCache) GetKeyModRevision(propID string) (int64, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ entry, ok := c.entries[propID]
+ if !ok {
+ return 0, false
+ }
+ if entry.modRevision > c.notifiedModRevision {
+ return 0, false
+ }
+ return entry.modRevision, true
+}
+
+// AdvanceNotified pushes the downstream-handler watermark forward monotonically.
+// Callers must invoke this only after notifyHandlers returns for the given revision,
+// so downstream caches (groupRepo, entityRepo, pkg/schema.schemaRepo, …) are coherent
+// with the property cache at the moment the watermark advances.
+func (c *schemaCache) AdvanceNotified(rev int64) {
+ if rev <= 0 {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if rev > c.notifiedModRevision {
+ c.notifiedModRevision = rev
+ }
+}
+
+// GetModRevisionByKey returns the modRevision for the first cached entry that matches
+// kind, group, and name. The second return value is false when no matching entry exists.
+func (c *schemaCache) GetModRevisionByKey(kind schema.Kind, group, name string) (int64, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ for _, entry := range c.entries {
+ if entry.kind == kind && entry.group == group && entry.name == name {
+ return entry.modRevision, true
+ }
+ }
+ return 0, false
+}
+
// Get returns a copy of the entry for the given propID, or nil if not found.
func (c *schemaCache) Get(propID string) *cacheEntry {
c.mu.RLock()
diff --git a/banyand/metadata/schema/property/cache_test.go b/banyand/metadata/schema/property/cache_test.go
index 322c0a3bc..91cc57c9c 100644
--- a/banyand/metadata/schema/property/cache_test.go
+++ b/banyand/metadata/schema/property/cache_test.go
@@ -207,6 +207,61 @@ func TestSchemaCache_DeleteLastEntryRemovesKind(t *testing.T) {
assert.Empty(t, c.GetEntriesByKind(schema.KindStream))
}
+// TestSchemaCache_TombstoneCap_EvictsOldestWhenFull verifies that Delete drops the
+// oldest tombstone (lowest deleteTime) once tombstoneIndex reaches maxTombstones,
+// so the index never grows beyond the cap.
+func TestSchemaCache_TombstoneCap_EvictsOldestWhenFull(t *testing.T) {
+ c := newSchemaCacheWithLimit(2)
+ c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 10, kind: schema.KindStream})
+ c.Update("stream_g1/s2", &cacheEntry{group: "g1", name: "s2", latestUpdateAt: 20, kind: schema.KindStream})
+ c.Update("stream_g1/s3", &cacheEntry{group: "g1", name: "s3", latestUpdateAt: 30, kind: schema.KindStream})
+
+ require.True(t, c.Delete("stream_g1/s1", 100), "first delete should record a tombstone")
+ require.True(t, c.Delete("stream_g1/s2", 200), "second delete fills the cap")
+ require.True(t, c.Delete("stream_g1/s3", 300), "third delete must evict the oldest before recording")
+
+ assert.Len(t, c.tombstoneIndex, 2, "tombstone index must respect maxTombstones")
+ _, s1Tracked := c.tombstoneIndex["stream_g1/s1"]
+ assert.False(t, s1Tracked, "oldest tombstone (s1, deleteTime=100) must have been evicted")
+ _, s2Tracked := c.tombstoneIndex["stream_g1/s2"]
+ _, s3Tracked := c.tombstoneIndex["stream_g1/s3"]
+ assert.True(t, s2Tracked, "newer tombstones must be retained")
+ assert.True(t, s3Tracked, "newer tombstones must be retained")
+}
+
+// TestSchemaCache_TombstoneCap_Disabled_NoEviction verifies that maxTombstones=0
+// disables the cap entirely so the index grows without eviction.
+func TestSchemaCache_TombstoneCap_Disabled_NoEviction(t *testing.T) {
+ c := newSchemaCacheWithLimit(0)
+ for i := 0; i < 5; i++ {
+ propID := "stream_g1/s" + string(rune('a'+i))
+ c.Update(propID, &cacheEntry{group: "g1", name: "s", latestUpdateAt: int64(i + 1), kind: schema.KindStream})
+ require.True(t, c.Delete(propID, int64(100*(i+1))))
+ }
+ assert.Len(t, c.tombstoneIndex, 5, "with cap disabled all tombstones must persist")
+}
+
+// TestSchemaCache_TombstoneCap_RedeleteSameKey_NoEviction verifies that deleting an
+// already-tombstoned propID does not consume a slot — it updates the existing entry
+// rather than appending, so eviction does not fire.
+func TestSchemaCache_TombstoneCap_RedeleteSameKey_NoEviction(t *testing.T) {
+ c := newSchemaCacheWithLimit(2)
+ c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 10, kind: schema.KindStream})
+ c.Update("stream_g1/s2", &cacheEntry{group: "g1", name: "s2", latestUpdateAt: 20, kind: schema.KindStream})
+
+ require.True(t, c.Delete("stream_g1/s1", 100))
+ require.True(t, c.Delete("stream_g1/s2", 200))
+
+ // Re-create then re-delete s1 — the tombstone slot is reused, not freshly allocated.
+ c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 30, kind: schema.KindStream})
+ require.True(t, c.Delete("stream_g1/s1", 300))
+
+ assert.Len(t, c.tombstoneIndex, 2, "redelete of an existing tombstone must not evict")
+ _, s2Tracked := c.tombstoneIndex["stream_g1/s2"]
+ assert.True(t, s2Tracked, "s2 must be retained — eviction should not have fired")
+ assert.Equal(t, int64(300), c.tombstoneIndex["stream_g1/s1"], "s1 deleteTime must reflect the latest delete")
+}
+
func TestSchemaCache_GetCachedKindsConsistent(t *testing.T) {
c := newSchemaCache()
c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 10, kind: schema.KindStream})
diff --git a/banyand/metadata/schema/property/client.go b/banyand/metadata/schema/property/client.go
index 098eddbd7..50c6cf911 100644
--- a/banyand/metadata/schema/property/client.go
+++ b/banyand/metadata/schema/property/client.go
@@ -64,6 +64,10 @@ var _ schema.Registry = (*SchemaRegistry)(nil)
var errNoActiveServers = fmt.Errorf("no active schema servers available")
+// DefaultTombstoneRetention is the default duration that tombstone entries are
+// retained in the client cache before the GC loop removes them.
+const DefaultTombstoneRetention = 7 * 24 * time.Hour
+
// ClientConfig holds configuration for the property-based schema registry client.
type ClientConfig struct {
OMR observability.MetricsRegistry
@@ -76,6 +80,15 @@ type ClientConfig struct {
WatchMaxBackoff time.Duration
InitWaitTime time.Duration
HealthCheckInterval time.Duration
+ // TombstoneRetention is how long tombstone entries are kept in the client cache
+ // before the GC loop purges them. Defaults to DefaultTombstoneRetention (7 days).
+ // Should match the schema server's tombstone-retention setting.
+ TombstoneRetention time.Duration
+ // MaxTombstoneEntries caps the in-memory tombstone index. Defaults to
+ // DefaultMaxTombstoneEntries (100k). When the cap is reached, the oldest
+ // tombstone is evicted to admit a new delete — defends against OOM under
+ // pathological churn that exceeds the GC interval.
+ MaxTombstoneEntries int
FullReconcileEvery uint64
MaxRecvMsgSize int
TLSEnabled bool
@@ -120,6 +133,7 @@ type SchemaRegistry struct {
syncInterval time.Duration
syncTimeout time.Duration
watchMaxBackoff time.Duration
+ tombstoneRetention time.Duration
fullReconcileEvery uint64
syncRound uint64
mux sync.RWMutex
@@ -174,17 +188,26 @@ func NewSchemaRegistryClient(cfg *ClientConfig) (*SchemaRegistry, error) {
if watchMaxBackoff == 0 {
watchMaxBackoff = defaultWatchMaxBackoff
}
+ tombstoneRetention := cfg.TombstoneRetention
+ if tombstoneRetention <= 0 {
+ tombstoneRetention = DefaultTombstoneRetention
+ }
+ maxTombstones := cfg.MaxTombstoneEntries
+ if maxTombstones <= 0 {
+ maxTombstones = DefaultMaxTombstoneEntries
+ }
reg := &SchemaRegistry{
connMgr: connMgr,
closer: run.NewCloser(1),
l: l,
- cache: newSchemaCache(),
+ cache: newSchemaCacheWithLimit(maxTombstones),
caCertReloader: caCertReloader,
handlers: make(map[schema.Kind][]schema.EventHandler),
watchSessions: make(map[string]*watchSession),
syncInterval: syncInterval,
syncTimeout: syncTimeout,
watchMaxBackoff: watchMaxBackoff,
+ tombstoneRetention: tombstoneRetention,
fullReconcileEvery: fullReconcileEvery,
}
handler.registry = reg
@@ -355,11 +378,14 @@ func (r *SchemaRegistry) querySchemasFromClient(ctx context.Context,
return results, nil
}
-func (r *SchemaRegistry) queryAndRepairSchemas(ctx context.Context,
+// collectSchemas queries all active nodes for the given property query and returns the best
+// schema per propID together with the set of nodes that responded. It does NOT repair
+// inconsistencies — call repairInconsistentNodes separately when repair is desired.
+func (r *SchemaRegistry) collectSchemas(ctx context.Context,
query *propertyv1.QueryRequest,
-) (map[string]*propInfo, error) {
- propMap := make(map[string]*propInfo)
- respondedNodes := make(map[string]bool)
+) (propMap map[string]*propInfo, respondedNodes map[string]bool, err error) {
+ propMap = make(map[string]*propInfo)
+ respondedNodes = make(map[string]bool)
var mu sync.Mutex
broadcastErr := r.broadcastAll(func(currentNode string, c *schemaClient) error {
schemas, queryErr := r.querySchemasFromClient(ctx, c.management, query)
@@ -391,10 +417,20 @@ func (r *SchemaRegistry) queryAndRepairSchemas(ctx context.Context,
}
return nil
})
- r.repairInconsistentNodes(ctx, respondedNodes, propMap)
if broadcastErr != nil && len(propMap) == 0 {
- return nil, broadcastErr
+ err = broadcastErr
}
+ return
+}
+
+func (r *SchemaRegistry) queryAndRepairSchemas(ctx context.Context,
+ query *propertyv1.QueryRequest,
+) (map[string]*propInfo, error) {
+ propMap, respondedNodes, collectErr := r.collectSchemas(ctx, query)
+ if collectErr != nil {
+ return nil, collectErr
+ }
+ r.repairInconsistentNodes(ctx, respondedNodes, propMap)
return propMap, nil
}
@@ -468,8 +504,9 @@ func (r *SchemaRegistry) listSchemas(ctx context.Context, kind schema.Kind,
return result, nil
}
-func (r *SchemaRegistry) broadcastDelete(ctx context.Context, kind schema.Kind, group, name string) (bool, error) {
- req := buildDeleteRequest(kind, group, name)
+func (r *SchemaRegistry) broadcastDelete(ctx context.Context, kind schema.Kind, group, name string) (bool, int64, error) {
+ deleteTime := time.Now().UnixNano()
+ req := buildDeleteRequest(kind, group, name, deleteTime)
var found atomic.Bool
writeErr := r.broadcastAll(func(_ string, c *schemaClient) error {
resp, rpcErr := c.management.DeleteSchema(ctx, req)
@@ -481,7 +518,10 @@ func (r *SchemaRegistry) broadcastDelete(ctx context.Context, kind schema.Kind,
}
return nil
})
- return found.Load(), writeErr
+ if writeErr != nil {
+ return found.Load(), 0, writeErr
+ }
+ return found.Load(), deleteTime, nil
}
func getResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
@@ -552,6 +592,43 @@ func (r *SchemaRegistry) validateGroup(ctx context.Context, kind schema.Kind, gr
}
}
+// getSchemaWithDeleteTime returns the best stored property for (kind, group, name) together with
+// its delete time in unix nanoseconds (non-zero when the entry is a tombstone).
+// It does NOT trigger repairs so it is safe to call inside createResource before broadcastInsert.
+func (r *SchemaRegistry) getSchemaWithDeleteTime(ctx context.Context, kind schema.Kind, group, name string) (*propertyv1.Property, int64, error) {
+ query := buildSchemaQuery(kind, group, name, 0)
+ propMap, _, collectErr := r.collectSchemas(ctx, query)
+ if collectErr != nil {
+ return nil, 0, collectErr
+ }
+ propID := query.Ids[0]
+ info := propMap[propID]
+ if info == nil || info.best == nil {
+ return nil, 0, nil
+ }
+ return info.best.property, info.best.deleteTime, nil
+}
+
+// checkTombstoneInvariant rejects a Create when a tombstone exists for the same (group, name)
+// and spec.UpdatedAt <= tombstone.deleteTime.
+func (r *SchemaRegistry) checkTombstoneInvariant(ctx context.Context, kind schema.Kind, group, name string, spec proto.Message) error {
+ _, deleteTime, getErr := r.getSchemaWithDeleteTime(ctx, kind, group, name)
+ if getErr != nil {
+ return getErr
+ }
+ if deleteTime == 0 {
+ return nil
+ }
+ updatedAt, updErr := getUpdatedAtFromSpec(kind, spec)
+ if updErr != nil || updatedAt == nil {
+ return nil
+ }
+ if updatedAt.AsTime().UnixNano() <= deleteTime {
+ return status.Error(codes.InvalidArgument, "updated_at_before_tombstone")
+ }
+ return nil
+}
+
func createResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
kind schema.Kind, spec T,
) error {
@@ -562,6 +639,15 @@ func createResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
if validateErr := r.validateGroup(ctx, kind, metadata.GetGroup()); validateErr != nil {
return validateErr
}
+ if tombstoneErr := r.checkTombstoneInvariant(ctx, kind, metadata.GetGroup(), metadata.GetName(), spec); tombstoneErr != nil {
+ return tombstoneErr
+ }
+ // Stamp created_at = UpdatedAt when not already set by the caller.
+ if getCreatedAtFromSpec(kind, spec) == nil {
+ if updatedAt, updErr := getUpdatedAtFromSpec(kind, spec); updErr == nil && updatedAt != nil {
+ setCreatedAtOnSpec(kind, spec, updatedAt)
+ }
+ }
prop, convErr := SchemaToProperty(kind, spec)
if convErr != nil {
return convErr
@@ -599,6 +685,10 @@ func updateResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
return validateErr
}
}
+ // Preserve created_at from the stored schema; the caller must not override it.
+ if prevCreatedAt := getCreatedAtFromSpec(kind, prev); prevCreatedAt != nil {
+ setCreatedAtOnSpec(kind, spec, prevCreatedAt)
+ }
if checker, checkerOk := schema.CheckerMap[kind]; checkerOk && checker(prev, spec) {
return nil
}
@@ -650,7 +740,7 @@ func (r *SchemaRegistry) UpdateStream(ctx context.Context, stream *databasev1.St
}
// DeleteStream deletes a stream schema.
-func (r *SchemaRegistry) DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindStream, metadata.GetGroup(), metadata.GetName())
}
@@ -702,7 +792,7 @@ func (r *SchemaRegistry) UpdateMeasure(ctx context.Context, measure *databasev1.
}
// DeleteMeasure deletes a measure schema.
-func (r *SchemaRegistry) DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindMeasure, metadata.GetGroup(), metadata.GetName())
}
@@ -759,7 +849,7 @@ func (r *SchemaRegistry) UpdateTrace(ctx context.Context, trace *databasev1.Trac
}
// DeleteTrace deletes a trace schema.
-func (r *SchemaRegistry) DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindTrace, metadata.GetGroup(), metadata.GetName())
}
@@ -774,32 +864,32 @@ func (r *SchemaRegistry) ListGroup(ctx context.Context) ([]*commonv1.Group, erro
}
// CreateGroup creates a group schema.
-func (r *SchemaRegistry) CreateGroup(ctx context.Context, group *commonv1.Group) error {
+func (r *SchemaRegistry) CreateGroup(ctx context.Context, group *commonv1.Group) (int64, error) {
if validateErr := validate.Group(group); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
group.Metadata.ModRevision = now
group.UpdatedAt = timestamppb.Now()
- return createResource(ctx, r, schema.KindGroup, group)
+ return now, createResource(ctx, r, schema.KindGroup, group)
}
// UpdateGroup updates a group schema.
-func (r *SchemaRegistry) UpdateGroup(ctx context.Context, group *commonv1.Group) error {
+func (r *SchemaRegistry) UpdateGroup(ctx context.Context, group *commonv1.Group) (int64, error) {
if validateErr := validate.Group(group); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
group.Metadata.ModRevision = now
group.UpdatedAt = timestamppb.Now()
- return updateResource(ctx, r, schema.KindGroup, group)
+ return now, updateResource(ctx, r, schema.KindGroup, group)
}
// DeleteGroup deletes a group and all its resources.
-func (r *SchemaRegistry) DeleteGroup(ctx context.Context, group string) (bool, error) {
+func (r *SchemaRegistry) DeleteGroup(ctx context.Context, group string) (bool, int64, error) {
_, groupErr := r.GetGroup(ctx, group)
if groupErr != nil {
- return false, fmt.Errorf("%s: %w", group, groupErr)
+ return false, 0, fmt.Errorf("%s: %w", group, groupErr)
}
var deleteErr error
for _, kind := range schema.AllKinds() {
@@ -816,17 +906,19 @@ func (r *SchemaRegistry) DeleteGroup(ctx context.Context, group string) (bool, e
if parsed.Name == "" {
continue
}
- _, broadcastErr := r.broadcastDelete(ctx, kind, group, parsed.Name)
- if broadcastErr != nil {
+ if _, _, broadcastErr := r.broadcastDelete(ctx, kind, group, parsed.Name); broadcastErr != nil {
deleteErr = multierr.Append(deleteErr, broadcastErr)
}
}
}
- _, broadcastErr := r.broadcastDelete(ctx, schema.KindGroup, "", group)
+ _, deleteTime, broadcastErr := r.broadcastDelete(ctx, schema.KindGroup, "", group)
if broadcastErr != nil {
deleteErr = multierr.Append(deleteErr, broadcastErr)
}
- return deleteErr == nil, deleteErr
+ if deleteErr != nil {
+ return false, 0, deleteErr
+ }
+ return true, deleteTime, nil
}
// GetIndexRule retrieves an index rule schema.
@@ -840,39 +932,39 @@ func (r *SchemaRegistry) ListIndexRule(ctx context.Context, opt schema.ListOpt)
}
// CreateIndexRule creates an index rule schema.
-func (r *SchemaRegistry) CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error {
+func (r *SchemaRegistry) CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error) {
if indexRule.Metadata.Id == 0 {
indexRule.Metadata.Id = generateCRC32ID(indexRule.Metadata.Group, indexRule.Metadata.Name)
}
if validateErr := validate.IndexRule(indexRule); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
indexRule.Metadata.ModRevision = now
indexRule.UpdatedAt = timestamppb.Now()
- return createResource(ctx, r, schema.KindIndexRule, indexRule)
+ return now, createResource(ctx, r, schema.KindIndexRule, indexRule)
}
// UpdateIndexRule updates an index rule schema.
-func (r *SchemaRegistry) UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error {
+func (r *SchemaRegistry) UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error) {
if indexRule.Metadata.Id == 0 {
existing, getErr := r.GetIndexRule(ctx, indexRule.Metadata)
if getErr != nil {
- return getErr
+ return 0, getErr
}
indexRule.Metadata.Id = existing.Metadata.Id
}
if validateErr := validate.IndexRule(indexRule); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
indexRule.Metadata.ModRevision = now
indexRule.UpdatedAt = timestamppb.Now()
- return updateResource(ctx, r, schema.KindIndexRule, indexRule)
+ return now, updateResource(ctx, r, schema.KindIndexRule, indexRule)
}
// DeleteIndexRule deletes an index rule schema.
-func (r *SchemaRegistry) DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindIndexRule, metadata.GetGroup(), metadata.GetName())
}
@@ -887,29 +979,29 @@ func (r *SchemaRegistry) ListIndexRuleBinding(ctx context.Context, opt schema.Li
}
// CreateIndexRuleBinding creates an index rule binding schema.
-func (r *SchemaRegistry) CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error {
+func (r *SchemaRegistry) CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error) {
if validateErr := validate.IndexRuleBinding(indexRuleBinding); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
indexRuleBinding.Metadata.ModRevision = now
indexRuleBinding.UpdatedAt = timestamppb.Now()
- return createResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
+ return now, createResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
}
// UpdateIndexRuleBinding updates an index rule binding schema.
-func (r *SchemaRegistry) UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error {
+func (r *SchemaRegistry) UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error) {
if validateErr := validate.IndexRuleBinding(indexRuleBinding); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
indexRuleBinding.Metadata.ModRevision = now
indexRuleBinding.UpdatedAt = timestamppb.Now()
- return updateResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
+ return now, updateResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
}
// DeleteIndexRuleBinding deletes an index rule binding schema.
-func (r *SchemaRegistry) DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindIndexRuleBinding, metadata.GetGroup(), metadata.GetName())
}
@@ -924,29 +1016,29 @@ func (r *SchemaRegistry) ListTopNAggregation(ctx context.Context, opt schema.Lis
}
// CreateTopNAggregation creates a TopN aggregation schema.
-func (r *SchemaRegistry) CreateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) error {
+func (r *SchemaRegistry) CreateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) (int64, error) {
if validateErr := validate.TopNAggregation(topN); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
topN.Metadata.ModRevision = now
topN.UpdatedAt = timestamppb.Now()
- return createResource(ctx, r, schema.KindTopNAggregation, topN)
+ return now, createResource(ctx, r, schema.KindTopNAggregation, topN)
}
// UpdateTopNAggregation updates a TopN aggregation schema.
-func (r *SchemaRegistry) UpdateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) error {
+func (r *SchemaRegistry) UpdateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) (int64, error) {
if validateErr := validate.TopNAggregation(topN); validateErr != nil {
- return validateErr
+ return 0, validateErr
}
now := time.Now().UnixNano()
topN.Metadata.ModRevision = now
topN.UpdatedAt = timestamppb.Now()
- return updateResource(ctx, r, schema.KindTopNAggregation, topN)
+ return now, updateResource(ctx, r, schema.KindTopNAggregation, topN)
}
// DeleteTopNAggregation deletes a TopN aggregation schema.
-func (r *SchemaRegistry) DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindTopNAggregation, metadata.GetGroup(), metadata.GetName())
}
@@ -981,7 +1073,7 @@ func (r *SchemaRegistry) UpdateProperty(ctx context.Context, property *databasev
}
// DeleteProperty deletes a property schema.
-func (r *SchemaRegistry) DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
return r.broadcastDelete(ctx, schema.KindProperty, metadata.GetGroup(), metadata.GetName())
}
@@ -1064,6 +1156,7 @@ func (r *SchemaRegistry) Start(ctx context.Context) error {
// registered. This explicit replay ensures handlers see every entry.
r.notifyHandlersFromCache()
go r.syncLoop(ctx)
+ go r.tombstoneGCLoop(ctx)
return nil
}
@@ -1093,6 +1186,44 @@ func (r *SchemaRegistry) syncLoop(ctx context.Context) {
}
}
+func (r *SchemaRegistry) tombstoneGCLoop(ctx context.Context) {
+ if !r.closer.AddRunning() {
+ return
+ }
+ defer r.closer.Done()
+ // Aim to fire ~10 times per retention window so an expired tombstone is purged
+ // well within retention. Cap at 1 minute so production retention (e.g. 7d) still
+ // reacts within bounded wall-clock time. Floor at 100ms so very short retentions
+ // (e.g. integration tests at 2s) actually exercise the GC path without thrashing.
+ interval := r.tombstoneRetention / 10
+ if interval > time.Minute {
+ interval = time.Minute
+ }
+ if interval < 100*time.Millisecond {
+ interval = 100 * time.Millisecond
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-r.closer.CloseNotify():
+ return
+ case <-ticker.C:
+ r.runTombstoneGC()
+ }
+ }
+}
+
+func (r *SchemaRegistry) runTombstoneGC() {
+ expired := r.cache.TombstoneEntries(r.tombstoneRetention)
+ for _, propID := range expired {
+ r.cache.RemoveTombstone(propID)
+ r.l.Debug().Str("propID", propID).Msg("tombstone GC: removed expired tombstone from cache")
+ }
+}
+
func (r *SchemaRegistry) launchWatch(nodeName string, client *schemaClient) {
r.watchMu.Lock()
defer r.watchMu.Unlock()
@@ -1293,6 +1424,9 @@ func (r *SchemaRegistry) handleWatchEvent(resp *schemav1.WatchSchemasResponse) {
return
}
r.notifyHandlers(kind, md, true)
+ // Advance the barrier watermark past the delete event's mod_revision after handlers
+ // have processed it, keeping the barrier coherent with downstream caches.
+ r.cache.AdvanceNotified(prop.GetMetadata().GetModRevision())
}
case schemav1.SchemaEventType_SCHEMA_EVENT_TYPE_REPLAY_DONE, schemav1.SchemaEventType_SCHEMA_EVENT_TYPE_UNSPECIFIED:
// handled by processWatchSession, not expected here
@@ -1471,6 +1605,7 @@ func (r *SchemaRegistry) processInitialResourceFromProperty(kind schema.Kind, pr
group: parsed.Group,
name: parsed.Name,
latestUpdateAt: parsed.UpdatedAt,
+ modRevision: prop.GetMetadata().GetModRevision(),
kind: kind,
}
updated := r.cache.Update(propID, entry)
@@ -1485,6 +1620,11 @@ func (r *SchemaRegistry) processInitialResourceFromProperty(kind schema.Kind, pr
},
Spec: spec,
}, false)
+ // Advance the barrier watermark only after every handler (groupRepo, entityRepo,
+ // pkg/schema.schemaRepo, etc.) has been notified. Without this gate, the barrier
+ // could return applied=true while downstream caches still lag, letting a client
+ // issue a query that hits "group not found".
+ r.cache.AdvanceNotified(entry.modRevision)
}
}
@@ -1500,6 +1640,9 @@ func (r *SchemaRegistry) handleDeletion(kind schema.Kind, propID string, entry *
},
Spec: entry.spec,
}, true)
+ // Advance the barrier watermark past the deleted entry's mod_revision so callers
+ // awaiting that revision see the delete once handlers have processed it.
+ r.cache.AdvanceNotified(entry.modRevision)
}
}
@@ -1521,6 +1664,7 @@ func (r *SchemaRegistry) notifyHandlers(kind schema.Kind, md schema.Metadata, is
func (r *SchemaRegistry) notifyHandlersFromCache() {
entries := r.cache.GetAllEntries()
r.l.Debug().Int("entryCount", len(entries)).Msg("notifyHandlersFromCache: replaying cached entries to handlers")
+ var maxRev int64
for _, entry := range entries {
r.notifyHandlers(entry.kind, schema.Metadata{
TypeMeta: schema.TypeMeta{
@@ -1530,6 +1674,16 @@ func (r *SchemaRegistry) notifyHandlersFromCache() {
},
Spec: entry.spec,
}, false)
+ if entry.modRevision > maxRev {
+ maxRev = entry.modRevision
+ }
+ }
+ // Advance the barrier watermark to cover every replayed entry. Without this, a server
+ // restart that hydrates the cache from the property store before handlers register
+ // leaves notifiedModRevision at zero — AwaitRevisionApplied(R) would block for any
+ // pre-restart revision even though handlers have processed it.
+ if maxRev > 0 {
+ r.cache.AdvanceNotified(maxRev)
}
}
@@ -1538,6 +1692,34 @@ func (r *SchemaRegistry) ActiveNodeNames() []string {
return r.connMgr.ActiveNames()
}
+// GetMaxModRevision returns the maximum modRevision across all cached entries.
+func (r *SchemaRegistry) GetMaxModRevision() int64 {
+ return r.cache.GetMaxModRevision()
+}
+
+// GetKeyModRevision returns the modRevision for the given propID.
+// The second return value is false when the propID is not in the live-entry map.
+func (r *SchemaRegistry) GetKeyModRevision(propID string) (int64, bool) {
+ return r.cache.GetKeyModRevision(propID)
+}
+
+// LatestModRevision returns the highest modRevision seen across all cached entries.
+func (r *SchemaRegistry) LatestModRevision() int64 {
+ return r.cache.GetMaxModRevision()
+}
+
+// ResourceRevision returns the modRevision for the cached entry matching (kind, group, name).
+// The second return value is false when no such entry is present in the local cache.
+func (r *SchemaRegistry) ResourceRevision(kind schema.Kind, group, name string) (int64, bool) {
+ return r.cache.GetModRevisionByKey(kind, group, name)
+}
+
+// IsAbsent returns true when the given (kind, group, name) triple is not present in the local cache.
+func (r *SchemaRegistry) IsAbsent(kind schema.Kind, group, name string) bool {
+ _, found := r.cache.GetModRevisionByKey(kind, group, name)
+ return !found
+}
+
// Close stops all watchers and gracefully stops ConnManager.
func (r *SchemaRegistry) Close() error {
if r.caCertReloader != nil {
diff --git a/banyand/metadata/schema/property/client_test.go b/banyand/metadata/schema/property/client_test.go
index ae36d44f4..37894f0bf 100644
--- a/banyand/metadata/schema/property/client_test.go
+++ b/banyand/metadata/schema/property/client_test.go
@@ -330,7 +330,8 @@ func testPropertyGroup(name string) *commonv1.Group {
func createTestGroup(t *testing.T, reg *property.SchemaRegistry) {
t.Helper()
- require.NoError(t, reg.CreateGroup(context.Background(), testGroup()))
+ _, createGroupErr := reg.CreateGroup(context.Background(), testGroup())
+ require.NoError(t, createGroupErr)
}
func TestGroupCRUD(t *testing.T) {
@@ -338,7 +339,8 @@ func TestGroupCRUD(t *testing.T) {
reg := newTestRegistry(t, addr)
ctx := context.Background()
g := testGroup()
- require.NoError(t, reg.CreateGroup(ctx, g))
+ _, createGroupErr := reg.CreateGroup(ctx, g)
+ require.NoError(t, createGroupErr)
got, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
assert.Equal(t, "test-group", got.GetMetadata().GetName())
@@ -346,11 +348,12 @@ func TestGroupCRUD(t *testing.T) {
require.NoError(t, listErr)
assert.Len(t, groups, 1)
got.ResourceOpts.ShardNum = 4
- require.NoError(t, reg.UpdateGroup(ctx, got))
+ _, updateGroupErr := reg.UpdateGroup(ctx, got)
+ require.NoError(t, updateGroupErr)
updated, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
assert.Equal(t, uint32(4), updated.GetResourceOpts().GetShardNum())
- deleted, deleteErr := reg.DeleteGroup(ctx, "test-group")
+ deleted, _, deleteErr := reg.DeleteGroup(ctx, "test-group")
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetGroup(ctx, "test-group")
@@ -384,7 +387,7 @@ func TestStreamCRUD(t *testing.T) {
updated, getErr := reg.GetStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
require.NoError(t, getErr)
assert.Len(t, updated.GetTagFamilies()[0].GetTags(), 2)
- deleted, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
+ deleted, _, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
@@ -406,7 +409,8 @@ func TestGroupUpdateNoOpSkipsBroadcast(t *testing.T) {
require.NotZero(t, storedModRev)
time.Sleep(2 * time.Millisecond)
- require.NoError(t, reg.UpdateGroup(ctx, got))
+ _, updateNoOpErr := reg.UpdateGroup(ctx, got)
+ require.NoError(t, updateNoOpErr)
afterNoOp, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
@@ -415,7 +419,8 @@ func TestGroupUpdateNoOpSkipsBroadcast(t *testing.T) {
afterNoOp.ResourceOpts.ShardNum = 4
time.Sleep(2 * time.Millisecond)
- require.NoError(t, reg.UpdateGroup(ctx, afterNoOp))
+ _, updateRealErr := reg.UpdateGroup(ctx, afterNoOp)
+ require.NoError(t, updateRealErr)
afterRealChange, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
assert.Greater(t, afterRealChange.GetMetadata().GetModRevision(), storedModRev,
@@ -496,7 +501,7 @@ func TestMeasureCRUD(t *testing.T) {
updated, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
require.NoError(t, getErr)
assert.Len(t, updated.GetFields(), 2)
- deleted, deleteErr := reg.DeleteMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ deleted, _, deleteErr := reg.DeleteMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
@@ -527,7 +532,7 @@ func TestTraceCRUD(t *testing.T) {
updated, getErr := reg.GetTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
require.NoError(t, getErr)
assert.Len(t, updated.GetTags(), 4)
- deleted, deleteErr := reg.DeleteTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
+ deleted, _, deleteErr := reg.DeleteTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
@@ -540,7 +545,8 @@ func TestIndexRuleCRUD(t *testing.T) {
ctx := context.Background()
createTestGroup(t, reg)
ir := testIndexRule("test-group")
- require.NoError(t, reg.CreateIndexRule(ctx, ir))
+ _, createIRErr := reg.CreateIndexRule(ctx, ir)
+ require.NoError(t, createIRErr)
got, getErr := reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
require.NoError(t, getErr)
assert.Equal(t, "test-index-rule", got.GetMetadata().GetName())
@@ -548,11 +554,12 @@ func TestIndexRuleCRUD(t *testing.T) {
require.NoError(t, listErr)
assert.Len(t, rules, 1)
got.Tags = append(got.Tags, "endpoint")
- require.NoError(t, reg.UpdateIndexRule(ctx, got))
+ _, updateIRErr := reg.UpdateIndexRule(ctx, got)
+ require.NoError(t, updateIRErr)
updated, getErr := reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
require.NoError(t, getErr)
assert.Len(t, updated.GetTags(), 2)
- deleted, deleteErr := reg.DeleteIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
+ deleted, _, deleteErr := reg.DeleteIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
@@ -566,7 +573,8 @@ func TestIndexRuleCRUD_AutoGenerateCRC32ID(t *testing.T) {
createTestGroup(t, reg)
ir := testIndexRule("test-group")
ir.Metadata.Id = 0
- require.NoError(t, reg.CreateIndexRule(ctx, ir))
+ _, createIRCRCErr := reg.CreateIndexRule(ctx, ir)
+ require.NoError(t, createIRCRCErr)
got, getErr := reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
require.NoError(t, getErr)
assert.Greater(t, got.GetMetadata().GetId(), uint32(0))
@@ -578,7 +586,8 @@ func TestIndexRuleBindingCRUD(t *testing.T) {
ctx := context.Background()
createTestGroup(t, reg)
irb := testIndexRuleBinding("test-group")
- require.NoError(t, reg.CreateIndexRuleBinding(ctx, irb))
+ _, createIRBErr := reg.CreateIndexRuleBinding(ctx, irb)
+ require.NoError(t, createIRBErr)
got, getErr := reg.GetIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
require.NoError(t, getErr)
assert.Equal(t, "test-index-rule-binding", got.GetMetadata().GetName())
@@ -586,11 +595,12 @@ func TestIndexRuleBindingCRUD(t *testing.T) {
require.NoError(t, listErr)
assert.Len(t, bindings, 1)
got.Rules = append(got.Rules, "another-rule")
- require.NoError(t, reg.UpdateIndexRuleBinding(ctx, got))
+ _, updateIRBErr := reg.UpdateIndexRuleBinding(ctx, got)
+ require.NoError(t, updateIRBErr)
updated, getErr := reg.GetIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
require.NoError(t, getErr)
assert.Len(t, updated.GetRules(), 2)
- deleted, deleteErr := reg.DeleteIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
+ deleted, _, deleteErr := reg.DeleteIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
@@ -603,7 +613,8 @@ func TestTopNAggregationCRUD(t *testing.T) {
ctx := context.Background()
createTestGroup(t, reg)
topn := testTopNAggregation("test-group")
- require.NoError(t, reg.CreateTopNAggregation(ctx, topn))
+ _, createTopNErr := reg.CreateTopNAggregation(ctx, topn)
+ require.NoError(t, createTopNErr)
got, getErr := reg.GetTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
require.NoError(t, getErr)
assert.Equal(t, "test-topn", got.GetMetadata().GetName())
@@ -611,14 +622,15 @@ func TestTopNAggregationCRUD(t *testing.T) {
require.NoError(t, listErr)
assert.Len(t, topns, 1)
got.CountersNumber = 20
- require.NoError(t, reg.UpdateTopNAggregation(ctx, got))
+ _, updateTopNErr := reg.UpdateTopNAggregation(ctx, got)
+ require.NoError(t, updateTopNErr)
updated, getErr := reg.GetTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
require.NoError(t, getErr)
assert.Equal(t, int32(20), updated.GetCountersNumber())
aggrs, aggrErr := reg.TopNAggregations(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
require.NoError(t, aggrErr)
assert.Len(t, aggrs, 1)
- deleted, deleteErr := reg.DeleteTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
+ deleted, _, deleteErr := reg.DeleteTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
@@ -629,7 +641,8 @@ func TestPropertyCRUD(t *testing.T) {
addr := startTestSchemaServer(t)
reg := newTestRegistry(t, addr)
ctx := context.Background()
- require.NoError(t, reg.CreateGroup(ctx, testPropertyGroup("test-prop-group")))
+ _, createGroupErr := reg.CreateGroup(ctx, testPropertyGroup("test-prop-group"))
+ require.NoError(t, createGroupErr)
p := testProperty("test-prop-group")
require.NoError(t, reg.CreateProperty(ctx, p))
got, getErr := reg.GetProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
@@ -639,7 +652,7 @@ func TestPropertyCRUD(t *testing.T) {
require.NoError(t, listErr)
assert.Len(t, props, 1)
require.NoError(t, reg.UpdateProperty(ctx, got))
- deleted, deleteErr := reg.DeleteProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
+ deleted, _, deleteErr := reg.DeleteProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
require.NoError(t, deleteErr)
assert.True(t, deleted)
_, getErr = reg.GetProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
@@ -762,7 +775,7 @@ func TestMultiNodeBroadcastWrite(t *testing.T) {
})
require.NoError(t, streamErr)
assertHasSchemas(t, stream2)
- _, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
+ _, _, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
require.NoError(t, deleteErr)
_, getErr := reg.GetStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
require.Error(t, getErr)
@@ -967,7 +980,8 @@ func TestOnAddOrUpdate_IgnoresNonMetaRoleNode(t *testing.T) {
reg.OnAddOrUpdate(schema.Metadata{Spec: noAddrNode})
assert.Len(t, reg.ActiveNodeNames(), 1, "meta node without address should not be registered")
// Registry should work via the initial meta node.
- require.NoError(t, reg.CreateGroup(ctx, testGroup()))
+ _, createGroupErr0 := reg.CreateGroup(ctx, testGroup())
+ require.NoError(t, createGroupErr0)
got, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
assert.Equal(t, "test-group", got.GetMetadata().GetName())
@@ -1028,7 +1042,8 @@ func TestOnAddOrUpdate_RepairOldDataToNewNode(t *testing.T) {
handler := &testEventHandler{}
reg.RegisterHandler("test-handler", schema.KindGroup|schema.KindStream, handler)
ctx := context.Background()
- require.NoError(t, reg.CreateGroup(ctx, testGroup()))
+ _, createGroupErr1 := reg.CreateGroup(ctx, testGroup())
+ require.NoError(t, createGroupErr1)
s := testStream()
_, createErr := reg.CreateStream(ctx, s)
require.NoError(t, createErr)
@@ -1069,7 +1084,8 @@ func TestOnDelete_RemoveNode(t *testing.T) {
require.NoError(t, regErr)
t.Cleanup(func() { _ = reg.Close() })
ctx := context.Background()
- require.NoError(t, reg.CreateGroup(ctx, testGroup()))
+ _, createGroupErr2 := reg.CreateGroup(ctx, testGroup())
+ require.NoError(t, createGroupErr2)
s := testStream()
_, createErr := reg.CreateStream(ctx, s)
require.NoError(t, createErr)
@@ -1232,7 +1248,8 @@ func TestTLS_GroupCRUD(t *testing.T) {
reg := newTestRegistryTLS(t, addr)
ctx := context.Background()
g := testGroup()
- require.NoError(t, reg.CreateGroup(ctx, g))
+ _, createGroupTLSErr := reg.CreateGroup(ctx, g)
+ require.NoError(t, createGroupTLSErr)
got, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
assert.Equal(t, "test-group", got.GetMetadata().GetName())
@@ -1240,12 +1257,13 @@ func TestTLS_GroupCRUD(t *testing.T) {
require.NoError(t, listErr)
assert.Len(t, groups, 1)
got.ResourceOpts.ShardNum = 4
- require.NoError(t, reg.UpdateGroup(ctx, got))
+ _, updateGroupTLSErr := reg.UpdateGroup(ctx, got)
+ require.NoError(t, updateGroupTLSErr)
updated, getErr := reg.GetGroup(ctx, "test-group")
require.NoError(t, getErr)
assert.Equal(t, uint32(4), updated.GetResourceOpts().GetShardNum())
- deleted, deleteErr := reg.DeleteGroup(ctx, "test-group")
- require.NoError(t, deleteErr)
+ deleted, _, deleteGroupTLSErr := reg.DeleteGroup(ctx, "test-group")
+ require.NoError(t, deleteGroupTLSErr)
assert.True(t, deleted)
_, getErr = reg.GetGroup(ctx, "test-group")
require.Error(t, getErr)
diff --git a/banyand/metadata/schema/property/converter.go b/banyand/metadata/schema/property/converter.go
index af6987731..66326987c 100644
--- a/banyand/metadata/schema/property/converter.go
+++ b/banyand/metadata/schema/property/converter.go
@@ -41,6 +41,8 @@ const (
TagKeySource = "source"
TagKeyKind = "kind"
TagKeyUpdatedAt = "updated_at"
+ // TagKeyCreatedAt is the tag key for the first-appearance timestamp; survives updates unchanged.
+ TagKeyCreatedAt = "created_at"
)
// BuildPropertyID returns the property ID for a schema resource.
@@ -80,19 +82,29 @@ func SchemaToProperty(kind schema.Kind, spec proto.Message) (*propertyv1.Propert
if updatedAt != nil {
updatedAtNano = updatedAt.AsTime().UnixNano()
}
+ tags := []*modelv1.Tag{
+ {Key: TagKeyGroup, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: group}}}},
+ {Key: TagKeyName, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: name}}}},
+ {Key: TagKeySource, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: string(data)}}}},
+ {Key: TagKeyKind, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: kind.String()}}}},
+ {Key: TagKeyUpdatedAt, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: updatedAtNano}}}},
+ }
+ // Only persist the created_at tag when it has been stamped (non-zero).
+ if createdAt := getCreatedAtFromSpec(kind, spec); createdAt != nil {
+ if createdAtNano := createdAt.AsTime().UnixNano(); createdAtNano != 0 {
+ tags = append(tags, &modelv1.Tag{
+ Key: TagKeyCreatedAt,
+ Value: &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: createdAtNano}}},
+ })
+ }
+ }
return &propertyv1.Property{
Metadata: &commonv1.Metadata{
Name: kind.String(),
ModRevision: metadata.GetModRevision(),
},
- Id: BuildPropertyID(kind, metadata),
- Tags: []*modelv1.Tag{
- {Key: TagKeyGroup, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: group}}}},
- {Key: TagKeyName, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: name}}}},
- {Key: TagKeySource, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: string(data)}}}},
- {Key: TagKeyKind, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: kind.String()}}}},
- {Key: TagKeyUpdatedAt, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: updatedAtNano}}}},
- },
+ Id: BuildPropertyID(kind, metadata),
+ Tags: tags,
UpdatedAt: updatedAt,
}, nil
}
@@ -144,6 +156,7 @@ type ParsedTags struct {
Source string
Kind string
UpdatedAt int64
+ CreatedAt int64
}
// ParseTags extracts all known tag values in a single pass.
@@ -161,6 +174,8 @@ func ParseTags(tags []*modelv1.Tag) ParsedTags {
pt.Kind = tag.GetValue().GetStr().GetValue()
case TagKeyUpdatedAt:
pt.UpdatedAt = tag.GetValue().GetInt().GetValue()
+ case TagKeyCreatedAt:
+ pt.CreatedAt = tag.GetValue().GetInt().GetValue()
}
}
return pt
@@ -276,8 +291,12 @@ func ParsePropID(propID string) (kind schema.Kind, group, name string, err error
return kind, rest[:slashIdx], rest[slashIdx+1:], nil
}
-// buildDeleteRequest builds a DeleteSchema request.
-func buildDeleteRequest(kind schema.Kind, group, name string) *schemav1.DeleteSchemaRequest {
+// buildDeleteRequest builds a DeleteSchema request. The caller supplies deleteTime
+// (unix nanos) so the value persisted as the tombstone on the server matches the
+// value broadcastDelete returns to its caller — otherwise two separate time.Now()
+// snapshots drift, breaking the design contract that delete_time is the
+// authoritative tombstone timestamp.
+func buildDeleteRequest(kind schema.Kind, group, name string, deleteTime int64) *schemav1.DeleteSchemaRequest {
metadata := &commonv1.Metadata{Group: group, Name: name}
return &schemav1.DeleteSchemaRequest{
Delete: &propertyv1.DeleteRequest{
@@ -285,7 +304,7 @@ func buildDeleteRequest(kind schema.Kind, group, name string) *schemav1.DeleteSc
Name: kind.String(),
Id: BuildPropertyID(kind, metadata),
},
- UpdateAt: timestamppb.New(time.Now()),
+ UpdateAt: timestamppb.New(time.Unix(0, deleteTime)),
}
}
@@ -335,6 +354,87 @@ func getUpdatedAtFromSpec(kind schema.Kind, spec proto.Message) (*timestamppb.Ti
return timestamppb.Now(), nil
}
+// getCreatedAtFromSpec returns the created_at timestamp from a schema spec, or nil if not present.
+func getCreatedAtFromSpec(kind schema.Kind, spec proto.Message) *timestamppb.Timestamp {
+ switch kind {
+ case schema.KindGroup:
+ if g, ok := spec.(*commonv1.Group); ok {
+ return g.GetCreatedAt()
+ }
+ case schema.KindStream:
+ if s, ok := spec.(*databasev1.Stream); ok {
+ return s.GetCreatedAt()
+ }
+ case schema.KindMeasure:
+ if m, ok := spec.(*databasev1.Measure); ok {
+ return m.GetCreatedAt()
+ }
+ case schema.KindTrace:
+ if t, ok := spec.(*databasev1.Trace); ok {
+ return t.GetCreatedAt()
+ }
+ case schema.KindIndexRule:
+ if ir, ok := spec.(*databasev1.IndexRule); ok {
+ return ir.GetCreatedAt()
+ }
+ case schema.KindIndexRuleBinding:
+ if irb, ok := spec.(*databasev1.IndexRuleBinding); ok {
+ return irb.GetCreatedAt()
+ }
+ case schema.KindTopNAggregation:
+ if t, ok := spec.(*databasev1.TopNAggregation); ok {
+ return t.GetCreatedAt()
+ }
+ case schema.KindProperty:
+ if p, ok := spec.(*databasev1.Property); ok {
+ return p.GetCreatedAt()
+ }
+ case schema.KindNode, schema.KindMask:
+ // Node and Mask do not have a CreatedAt field.
+ }
+ return nil
+}
+
+// setCreatedAtOnSpec sets the created_at timestamp on a schema spec in-place.
+func setCreatedAtOnSpec(kind schema.Kind, spec proto.Message, ts *timestamppb.Timestamp) {
+ switch kind {
+ case schema.KindGroup:
+ if g, ok := spec.(*commonv1.Group); ok {
+ g.CreatedAt = ts
+ }
+ case schema.KindStream:
+ if s, ok := spec.(*databasev1.Stream); ok {
+ s.CreatedAt = ts
+ }
+ case schema.KindMeasure:
+ if m, ok := spec.(*databasev1.Measure); ok {
+ m.CreatedAt = ts
+ }
+ case schema.KindTrace:
+ if t, ok := spec.(*databasev1.Trace); ok {
+ t.CreatedAt = ts
+ }
+ case schema.KindIndexRule:
+ if ir, ok := spec.(*databasev1.IndexRule); ok {
+ ir.CreatedAt = ts
+ }
+ case schema.KindIndexRuleBinding:
+ if irb, ok := spec.(*databasev1.IndexRuleBinding); ok {
+ irb.CreatedAt = ts
+ }
+ case schema.KindTopNAggregation:
+ if t, ok := spec.(*databasev1.TopNAggregation); ok {
+ t.CreatedAt = ts
+ }
+ case schema.KindProperty:
+ if p, ok := spec.(*databasev1.Property); ok {
+ p.CreatedAt = ts
+ }
+ case schema.KindNode, schema.KindMask:
+ // Node and Mask do not have a CreatedAt field.
+ }
+}
+
func getMetadataFromSpec(kind schema.Kind, spec proto.Message) (*commonv1.Metadata, error) {
switch kind {
case schema.KindGroup:
diff --git a/banyand/metadata/schema/property/converter_test.go b/banyand/metadata/schema/property/converter_test.go
index 5c14d9832..c8ee3f650 100644
--- a/banyand/metadata/schema/property/converter_test.go
+++ b/banyand/metadata/schema/property/converter_test.go
@@ -444,11 +444,14 @@ func TestParsePropID(t *testing.T) {
}
func TestBuildDeleteRequest(t *testing.T) {
- req := buildDeleteRequest(schema.KindMeasure, "g1", "m1")
+ deleteTime := int64(1_700_000_000_000_000_000)
+ req := buildDeleteRequest(schema.KindMeasure, "g1", "m1", deleteTime)
assert.Equal(t, schema.SchemaGroup, req.GetDelete().GetGroup())
assert.Equal(t, "measure", req.GetDelete().GetName())
assert.Equal(t, "measure_g1/m1", req.GetDelete().GetId())
assert.NotNil(t, req.GetUpdateAt())
+ assert.Equal(t, deleteTime, req.GetUpdateAt().AsTime().UnixNano(),
+ "UpdateAt must reflect the caller-supplied deleteTime so the persisted tombstone matches the returned value")
}
func TestNewMessageForKind_AllKinds(t *testing.T) {
diff --git a/banyand/metadata/schema/property/created_at_test.go b/banyand/metadata/schema/property/created_at_test.go
new file mode 100644
index 000000000..c2b2d3aeb
--- /dev/null
+++ b/banyand/metadata/schema/property/created_at_test.go
@@ -0,0 +1,123 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package property_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+)
+
+// TestCreate_StampsCreatedAt verifies that CreateMeasure stamps created_at equal to
+// updated_at when the caller does not supply a created_at value (Step 1.3 / A3).
+func TestCreate_StampsCreatedAt(t *testing.T) {
+ addr := startTestSchemaServer(t)
+ reg := newTestRegistry(t, addr)
+ ctx := context.Background()
+ createTestGroup(t, reg)
+
+ _, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.NoError(t, createErr)
+
+ got, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, getErr)
+ require.NotNil(t, got.GetCreatedAt(), "created_at must be set after CreateMeasure")
+ require.NotNil(t, got.GetUpdatedAt(), "updated_at must be set after CreateMeasure")
+ assert.Equal(t, got.GetUpdatedAt().AsTime().UnixNano(), got.GetCreatedAt().AsTime().UnixNano(),
+ "created_at must equal updated_at on initial create")
+}
+
+// TestUpdate_PreservesCreatedAt verifies that UpdateMeasure preserves the original
+// created_at while advancing updated_at (Step 1.3 / A3).
+func TestUpdate_PreservesCreatedAt(t *testing.T) {
+ addr := startTestSchemaServer(t)
+ reg := newTestRegistry(t, addr)
+ ctx := context.Background()
+ createTestGroup(t, reg)
+
+ _, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.NoError(t, createErr)
+
+ initial, getInitialErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, getInitialErr)
+ createdAt := initial.GetCreatedAt()
+ require.NotNil(t, createdAt, "created_at must be set after CreateMeasure")
+
+ // Advance time so the new updated_at is strictly greater than created_at.
+ time.Sleep(2 * time.Millisecond)
+
+ // Add a new field — a real change that must pass through the equality checker.
+ initial.Fields = append(initial.Fields, &databasev1.FieldSpec{
+ Name: "count",
+ FieldType: databasev1.FieldType_FIELD_TYPE_INT,
+ CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+ EncodingMethod: databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+ })
+ _, updateErr := reg.UpdateMeasure(ctx, initial)
+ require.NoError(t, updateErr)
+
+ updated, getUpdatedErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, getUpdatedErr)
+ require.NotNil(t, updated.GetCreatedAt(), "created_at must still be set after UpdateMeasure")
+ assert.Equal(t, createdAt.AsTime().UnixNano(), updated.GetCreatedAt().AsTime().UnixNano(),
+ "created_at must be unchanged by UpdateMeasure")
+ assert.Greater(t, updated.GetUpdatedAt().AsTime().UnixNano(), updated.GetCreatedAt().AsTime().UnixNano(),
+ "updated_at must advance beyond created_at after an update")
+}
+
+// TestValidateMeasureUpdate_EntityChangeFails verifies that UpdateMeasure rejects
+// an entity change and returns an error containing "entity", while leaving the
+// stored schema's ModRevision unchanged (Step 1.3 / validate).
+func TestValidateMeasureUpdate_EntityChangeFails(t *testing.T) {
+ addr := startTestSchemaServer(t)
+ reg := newTestRegistry(t, addr)
+ ctx := context.Background()
+ createTestGroup(t, reg)
+
+ _, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.NoError(t, createErr)
+
+ got, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, getErr)
+ originalModRev := got.GetMetadata().GetModRevision()
+
+ // Add "endpoint" to the tag family so validate.Measure accepts the new entity,
+ // but validateMeasureUpdate rejects it (entity definition changed).
+ got.TagFamilies[0].Tags = append(got.TagFamilies[0].Tags, &databasev1.TagSpec{
+ Name: "endpoint",
+ Type: databasev1.TagType_TAG_TYPE_STRING,
+ })
+ got.Entity = &databasev1.Entity{TagNames: []string{"service_name", "endpoint"}}
+ _, updateErr := reg.UpdateMeasure(ctx, got)
+ require.Error(t, updateErr, "UpdateMeasure must fail when entity changes")
+ assert.Contains(t, updateErr.Error(), "entity", "error must mention 'entity'")
+
+ // The stored schema must be completely unchanged.
+ after, getAfterErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, getAfterErr)
+ assert.Equal(t, originalModRev, after.GetMetadata().GetModRevision(),
+ "ModRevision must be unchanged after a rejected entity-change update")
+ assert.Equal(t, []string{"service_name"}, after.GetEntity().GetTagNames(),
+ "entity must be unchanged after a rejected update")
+}
diff --git a/banyand/metadata/schema/property/tombstone_invariant_test.go b/banyand/metadata/schema/property/tombstone_invariant_test.go
new file mode 100644
index 000000000..7c755cc6c
--- /dev/null
+++ b/banyand/metadata/schema/property/tombstone_invariant_test.go
@@ -0,0 +1,103 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package property_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+ "github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+ "github.com/apache/skywalking-banyandb/banyand/metadata/schema/property"
+)
+
+// TestCreate_AfterTombstone_Reject_WhenLeq verifies that CreateMeasure returns
+// codes.InvalidArgument when a tombstone exists with a delete_time that is greater
+// than or equal to the create's updated_at (Step 1.3 / A4).
+//
+// Strategy: inject a far-future delete_time via the raw schema management RPC
+// so that time.Now() is always before the tombstone, forcing the rejection.
+func TestCreate_AfterTombstone_Reject_WhenLeq(t *testing.T) {
+ addr := startTestSchemaServer(t)
+ reg := newTestRegistry(t, addr)
+ rawMgmt := rawMgmtClient(t, addr)
+ ctx := context.Background()
+ createTestGroup(t, reg)
+
+ _, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.NoError(t, createErr)
+
+ // Delete the measure with a far-future UpdateAt so any realtime CreateMeasure
+ // (which stamps UpdatedAt = time.Now()) will be before the tombstone.
+ farFuture := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)
+ _, deleteSchemaErr := rawMgmt.DeleteSchema(ctx, &schemav1.DeleteSchemaRequest{
+ Delete: &propertyv1.DeleteRequest{
+ Group: schema.SchemaGroup,
+ Name: schema.KindMeasure.String(),
+ Id: property.BuildPropertyID(schema.KindMeasure, &commonv1.Metadata{Group: "test-group", Name: "test-measure"}),
+ },
+ UpdateAt: timestamppb.New(farFuture),
+ })
+ require.NoError(t, deleteSchemaErr)
+
+ // Attempt to re-create: CreateMeasure stamps UpdatedAt = time.Now() < farFuture → rejected.
+ _, recreateErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.Error(t, recreateErr, "CreateMeasure must fail when updated_at <= tombstone delete_time")
+ st, ok := status.FromError(recreateErr)
+ require.True(t, ok, "error must be a gRPC status error")
+ assert.Equal(t, codes.InvalidArgument, st.Code())
+ assert.Contains(t, st.Message(), "updated_at_before_tombstone")
+}
+
+// TestCreate_AfterTombstone_Accept_WhenGreater verifies that re-creating a schema
+// after deletion succeeds when the new updated_at is after the tombstone's delete_time
+// (Step 1.3 / A4).
+func TestCreate_AfterTombstone_Accept_WhenGreater(t *testing.T) {
+ addr := startTestSchemaServer(t)
+ reg := newTestRegistry(t, addr)
+ ctx := context.Background()
+ createTestGroup(t, reg)
+
+ _, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.NoError(t, createErr)
+
+ // Normal deletion — tombstone delete_time ≈ time.Now().
+ deleted, _, deleteErr := reg.DeleteMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, deleteErr)
+ assert.True(t, deleted)
+
+ // Small gap ensures the new UpdatedAt (time.Now()) is strictly > deleteTime.
+ time.Sleep(2 * time.Millisecond)
+
+ // Re-create: CreateMeasure stamps UpdatedAt = time.Now() > deleteTime → accepted.
+ _, recreateErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+ require.NoError(t, recreateErr, "re-create after delete must succeed when updated_at > tombstone delete_time")
+
+ got, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+ require.NoError(t, getErr)
+ assert.Equal(t, "test-measure", got.GetMetadata().GetName())
+}
diff --git a/banyand/metadata/schema/schema.go b/banyand/metadata/schema/schema.go
index e52088265..93a4430b2 100644
--- a/banyand/metadata/schema/schema.go
+++ b/banyand/metadata/schema/schema.go
@@ -119,25 +119,25 @@ type Stream interface {
ListStream(ctx context.Context, opt ListOpt) ([]*databasev1.Stream, error)
CreateStream(ctx context.Context, stream *databasev1.Stream) (int64, error)
UpdateStream(ctx context.Context, stream *databasev1.Stream) (int64, error)
- DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
}
// IndexRule allows CRUD index rule schemas in a group.
type IndexRule interface {
GetIndexRule(ctx context.Context, metadata *commonv1.Metadata) (*databasev1.IndexRule, error)
ListIndexRule(ctx context.Context, opt ListOpt) ([]*databasev1.IndexRule, error)
- CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error
- UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error
- DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error)
+ UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error)
+ DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
}
// IndexRuleBinding allows CRUD index rule binding schemas in a group.
type IndexRuleBinding interface {
GetIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (*databasev1.IndexRuleBinding, error)
ListIndexRuleBinding(ctx context.Context, opt ListOpt) ([]*databasev1.IndexRuleBinding, error)
- CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error
- UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error
- DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error)
+ UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error)
+ DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
}
// Measure allows CRUD measure schemas in a group.
@@ -148,7 +148,7 @@ type Measure interface {
ListMeasure(ctx context.Context, opt ListOpt) ([]*databasev1.Measure, error)
CreateMeasure(ctx context.Context, measure *databasev1.Measure) (int64, error)
UpdateMeasure(ctx context.Context, measure *databasev1.Measure) (int64, error)
- DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
TopNAggregations(ctx context.Context, metadata *commonv1.Metadata) ([]*databasev1.TopNAggregation, error)
}
@@ -160,7 +160,7 @@ type Trace interface {
ListTrace(ctx context.Context, opt ListOpt) ([]*databasev1.Trace, error)
CreateTrace(ctx context.Context, trace *databasev1.Trace) (int64, error)
UpdateTrace(ctx context.Context, trace *databasev1.Trace) (int64, error)
- DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
}
// Group allows CRUD groups which is namespaces of resources.
@@ -170,9 +170,9 @@ type Group interface {
GetGroup(ctx context.Context, group string) (*commonv1.Group, error)
ListGroup(ctx context.Context) ([]*commonv1.Group, error)
// DeleteGroup delete all items belonging to the group
- DeleteGroup(ctx context.Context, group string) (bool, error)
- CreateGroup(ctx context.Context, group *commonv1.Group) error
- UpdateGroup(ctx context.Context, group *commonv1.Group) error
+ DeleteGroup(ctx context.Context, group string) (bool, int64, error)
+ CreateGroup(ctx context.Context, group *commonv1.Group) (int64, error)
+ UpdateGroup(ctx context.Context, group *commonv1.Group) (int64, error)
}
// TopNAggregation allows CRUD top-n aggregation schemas in a group.
@@ -181,9 +181,9 @@ type Group interface {
type TopNAggregation interface {
GetTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (*databasev1.TopNAggregation, error)
ListTopNAggregation(ctx context.Context, opt ListOpt) ([]*databasev1.TopNAggregation, error)
- CreateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) error
- UpdateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) error
- DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ CreateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) (int64, error)
+ UpdateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) (int64, error)
+ DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
}
// Node allows CRUD node schemas in a group.
@@ -202,5 +202,5 @@ type Property interface {
ListProperty(ctx context.Context, opt ListOpt) ([]*databasev1.Property, error)
CreateProperty(ctx context.Context, property *databasev1.Property) error
UpdateProperty(ctx context.Context, property *databasev1.Property) error
- DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+ DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
}
diff --git a/banyand/metadata/schema/schemaserver/service.go b/banyand/metadata/schema/schemaserver/service.go
index 016985e4a..d720aa961 100644
--- a/banyand/metadata/schema/schemaserver/service.go
+++ b/banyand/metadata/schema/schemaserver/service.go
@@ -97,6 +97,7 @@ type server struct {
flushTimeout time.Duration
snapshotSeq uint64
expireTimeout time.Duration
+ tombstoneRetention time.Duration
repairQuickBuildTreeTime time.Duration
maxRecvMsgSize run.Bytes
maxFileSnapshotNum int
@@ -148,6 +149,8 @@ func (s *server) FlagSet() *run.FlagSet {
flagS.Uint32Var(&s.port, "schema-server-grpc-port", 17916, "the port of schema server")
flagS.DurationVar(&s.flushTimeout, "schema-server-flush-timeout", 5*time.Second, "memory flush interval")
flagS.DurationVar(&s.expireTimeout, "schema-server-expire-delete-timeout", time.Hour*24*7, "soft-delete expiration")
+ flagS.DurationVar(&s.tombstoneRetention, "schema-server-tombstone-retention", time.Hour*24*7,
+ "schema tombstone retention duration; tombstones older than this value are physically excluded from queries")
flagS.BoolVar(&s.tls, "schema-server-tls", false, "connection uses TLS if true")
flagS.StringVar(&s.certFile, "schema-server-cert-file", "", "the TLS cert file")
flagS.StringVar(&s.keyFile, "schema-server-key-file", "", "the TLS key file")
@@ -169,6 +172,10 @@ func (s *server) Validate() error {
if s.port == 0 {
s.port = 17920
}
+ // Short retention values (<1h) are unusual in production but required by integration tests
+ // that verify the physical-removal path of tombstone GC. Accept the value as given so
+ // operators (and the test harness) retain control; silent clamping previously hid the
+ // §4.7.2 test case.
s.addr = net.JoinHostPort(s.host, strconv.FormatUint(uint64(s.port), 10))
if s.addr == ":" {
return errors.New("no address")
@@ -221,11 +228,15 @@ func (s *server) PreRun(_ context.Context) error {
s.snapshotDir = filepath.Join(s.root, snapshot.SchemaPropertyCatalogName, "snapshots")
repairDir := filepath.Join(s.root, snapshot.SchemaPropertyCatalogName, "repair")
+ expireToDelete := s.expireTimeout
+ if s.tombstoneRetention > 0 {
+ expireToDelete = s.tombstoneRetention
+ }
cfg := db.Config{
Location: dataDir,
MetricsScopeName: "schema_property",
FlushInterval: s.flushTimeout,
- ExpireToDeleteDuration: s.expireTimeout,
+ ExpireToDeleteDuration: expireToDelete,
Repair: db.RepairConfig{
Enabled: true,
Location: repairDir,
diff --git a/banyand/property/db/shard.go b/banyand/property/db/shard.go
index 6e8410eac..a0482c8f7 100644
--- a/banyand/property/db/shard.go
+++ b/banyand/property/db/shard.go
@@ -429,6 +429,12 @@ func (s *shard) prepareForMerge(src []*roaring.Bitmap, segments []segment.Segmen
return src, nil
}
for segID, seg := range segments {
+ // bluge's drop-set entries point at segment-owned bitmaps that concurrent searches
+ // read via Snapshot.PostingsIterator. Mutating the original aliases would race against
+ // roaring.AndNot in those readers. We take a private copy at most once per segment —
+ // only on the first expired doc — so segments with no expirations cost nothing and
+ // segments with many expirations clone exactly once instead of per-docID.
+ privateOwned := false
var docID uint64
for ; docID < seg.Count(); docID++ {
var deleteTime int64
@@ -446,8 +452,13 @@ func (s *shard) prepareForMerge(src []*roaring.Bitmap, segments []segment.Segmen
continue
}
- if src[segID] == nil {
- src[segID] = roaring.New()
+ if !privateOwned {
+ if src[segID] == nil {
+ src[segID] = roaring.New()
+ } else {
+ src[segID] = src[segID].Clone()
+ }
+ privateOwned = true
}
src[segID].Add(uint32(docID))
diff --git a/banyand/property/gossip/trace.go b/banyand/property/gossip/trace.go
index 627eeae30..93780864a 100644
--- a/banyand/property/gossip/trace.go
+++ b/banyand/property/gossip/trace.go
@@ -170,7 +170,8 @@ func (s *service) initInternalTraceGroup(ctx context.Context) error {
},
},
}
- return s.metadata.GroupRegistry().CreateGroup(ctx, g)
+ _, createErr := s.metadata.GroupRegistry().CreateGroup(ctx, g)
+ return createErr
}
func (s *service) savingTracingSpans() (err error) {
diff --git a/banyand/stream/metadata.go b/banyand/stream/metadata.go
index 269104023..a2e452044 100644
--- a/banyand/stream/metadata.go
+++ b/banyand/stream/metadata.go
@@ -204,10 +204,12 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
Metadata: g,
})
case schema.KindStream:
+ streamSpec := metadata.Spec.(*databasev1.Stream)
sr.SendMetadataEvent(resourceSchema.MetadataEvent{
- Typ: resourceSchema.EventDelete,
- Kind: resourceSchema.EventKindResource,
- Metadata: metadata.Spec.(*databasev1.Stream),
+ Typ: resourceSchema.EventDelete,
+ Kind: resourceSchema.EventKindResource,
+ DeleteRevision: streamSpec.GetMetadata().GetModRevision(),
+ Metadata: streamSpec,
})
case schema.KindIndexRuleBinding:
if binding, ok := metadata.Spec.(*databasev1.IndexRuleBinding); ok {
diff --git a/banyand/stream/metadata_test.go b/banyand/stream/metadata_test.go
index d5f3e96c5..a0f0fc1b6 100644
--- a/banyand/stream/metadata_test.go
+++ b/banyand/stream/metadata_test.go
@@ -59,7 +59,7 @@ var _ = Describe("Metadata", func() {
Context("Manage group", func() {
It("should close the group", func() {
- deleted, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "default")
+ deleted, _, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "default")
Expect(err).ShouldNot(HaveOccurred())
Expect(deleted).Should(BeTrue())
Eventually(func() bool {
@@ -74,7 +74,8 @@ var _ = Describe("Metadata", func() {
Expect(groupSchema).ShouldNot(BeNil())
groupSchema.ResourceOpts.ShardNum = 4
- Expect(svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)).Should(Succeed())
+ _, updateGroupErr := svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)
+ Expect(updateGroupErr).ShouldNot(HaveOccurred())
Eventually(func() bool {
group, ok := svcs.stream.LoadGroup("default")
@@ -97,7 +98,7 @@ var _ = Describe("Metadata", func() {
}).WithTimeout(flags.EventuallyTimeout).Should(BeTrue())
})
It("should close the stream", func() {
- deleted, err := svcs.metadataService.StreamRegistry().DeleteStream(context.TODO(), &commonv1.Metadata{
+ deleted, _, err := svcs.metadataService.StreamRegistry().DeleteStream(context.TODO(), &commonv1.Metadata{
Name: "sw",
Group: "default",
})
@@ -354,7 +355,7 @@ var _ = Describe("Schema Change", func() {
groupCounter++
groupName = "test-schema-change-" + strconv.Itoa(groupCounter)
- err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
+ _, err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
Metadata: &commonv1.Metadata{
Name: groupName,
},
@@ -379,7 +380,7 @@ var _ = Describe("Schema Change", func() {
})
AfterEach(func() {
- _, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
+ _, _, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
deferFn()
Eventually(gleak.Goroutines, flags.EventuallyTimeout).ShouldNot(gleak.HaveLeaked(goods))
})
@@ -679,7 +680,7 @@ func setupSchemaChangeStream(svcs *services, streamName, groupName string, opts
return &schemaChangeEnv{
cleanup: func() {
- _, _ = svcs.metadataService.StreamRegistry().DeleteStream(ctx, &commonv1.Metadata{
+ _, _, _ = svcs.metadataService.StreamRegistry().DeleteStream(ctx, &commonv1.Metadata{
Name: streamName,
Group: groupName,
})
diff --git a/banyand/trace/metadata.go b/banyand/trace/metadata.go
index 3e0249e4c..8c3322f35 100644
--- a/banyand/trace/metadata.go
+++ b/banyand/trace/metadata.go
@@ -213,10 +213,12 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
Metadata: g,
})
case schema.KindTrace:
+ traceSpec := metadata.Spec.(*databasev1.Trace)
sr.SendMetadataEvent(resourceSchema.MetadataEvent{
- Typ: resourceSchema.EventDelete,
- Kind: resourceSchema.EventKindResource,
- Metadata: metadata.Spec.(*databasev1.Trace),
+ Typ: resourceSchema.EventDelete,
+ Kind: resourceSchema.EventKindResource,
+ DeleteRevision: traceSpec.GetMetadata().GetModRevision(),
+ Metadata: traceSpec,
})
case schema.KindIndexRuleBinding:
if binding, ok := metadata.Spec.(*databasev1.IndexRuleBinding); ok {
diff --git a/banyand/trace/metadata_test.go b/banyand/trace/metadata_test.go
index d01f774c7..e605d5de5 100644
--- a/banyand/trace/metadata_test.go
+++ b/banyand/trace/metadata_test.go
@@ -64,7 +64,7 @@ var _ = Describe("Metadata", func() {
BeforeEach(func() {
groupCounter++
groupName = "test-schema-change-" + strconv.Itoa(groupCounter)
- err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
+ _, err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
Metadata: &commonv1.Metadata{
Name: groupName,
},
@@ -89,7 +89,7 @@ var _ = Describe("Metadata", func() {
})
AfterEach(func() {
- _, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
+ _, _, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
})
It("deleting reserved tag should fail", func() {
@@ -496,8 +496,8 @@ func setupSchemaChangeTrace(svcs *services, traceName, groupName string, opts tr
Tags: []string{"duration"},
Type: databasev1.IndexRule_TYPE_TREE,
}
- err = svcs.metadataService.IndexRuleRegistry().CreateIndexRule(ctx, indexRule)
- Expect(err).ShouldNot(HaveOccurred())
+ _, createIRErr := svcs.metadataService.IndexRuleRegistry().CreateIndexRule(ctx, indexRule)
+ Expect(createIRErr).ShouldNot(HaveOccurred())
// Create index rule binding
indexRuleBindingName := traceName + "-index-rule-binding"
@@ -514,8 +514,8 @@ func setupSchemaChangeTrace(svcs *services, traceName, groupName string, opts tr
BeginAt: timestamppb.Now(),
ExpireAt: timestamppb.New(time.Now().Add(24 * time.Hour)),
}
- err = svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, indexRuleBinding)
- Expect(err).ShouldNot(HaveOccurred())
+ _, createIRBErr := svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, indexRuleBinding)
+ Expect(createIRBErr).ShouldNot(HaveOccurred())
Eventually(func() bool {
_, err := svcs.trace.Trace(&commonv1.Metadata{
@@ -527,15 +527,15 @@ func setupSchemaChangeTrace(svcs *services, traceName, groupName string, opts tr
return &schemaChangeEnv{
cleanup: func() {
- _, _ = svcs.metadataService.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, &commonv1.Metadata{
+ _, _, _ = svcs.metadataService.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, &commonv1.Metadata{
Name: indexRuleBindingName,
Group: groupName,
})
- _, _ = svcs.metadataService.IndexRuleRegistry().DeleteIndexRule(ctx, &commonv1.Metadata{
+ _, _, _ = svcs.metadataService.IndexRuleRegistry().DeleteIndexRule(ctx, &commonv1.Metadata{
Name: indexRuleName,
Group: groupName,
})
- _, _ = svcs.metadataService.TraceRegistry().DeleteTrace(ctx, &commonv1.Metadata{
+ _, _, _ = svcs.metadataService.TraceRegistry().DeleteTrace(ctx, &commonv1.Metadata{
Name: traceName,
Group: groupName,
})
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 44696cff7..eea4435c6 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -37,6 +37,9 @@
- [LogicalExpression.LogicalOp](#banyandb-model-v1-LogicalExpression-LogicalOp)
- [Sort](#banyandb-model-v1-Sort)
+- [banyandb/model/v1/write.proto](#banyandb_model_v1_write-proto)
+ - [Status](#banyandb-model-v1-Status)
+
- [banyandb/measure/v1/query.proto](#banyandb_measure_v1_query-proto)
- [DataPoint](#banyandb-measure-v1-DataPoint)
- [DataPoint.Field](#banyandb-measure-v1-DataPoint-Field)
@@ -47,8 +50,10 @@
- [QueryRequest.Aggregation](#banyandb-measure-v1-QueryRequest-Aggregation)
- [QueryRequest.FieldProjection](#banyandb-measure-v1-QueryRequest-FieldProjection)
- [QueryRequest.GroupBy](#banyandb-measure-v1-QueryRequest-GroupBy)
+ - [QueryRequest.GroupModRevisionsEntry](#banyandb-measure-v1-QueryRequest-GroupModRevisionsEntry)
- [QueryRequest.Top](#banyandb-measure-v1-QueryRequest-Top)
- [QueryResponse](#banyandb-measure-v1-QueryResponse)
+ - [QueryResponse.GroupStatusesEntry](#banyandb-measure-v1-QueryResponse-GroupStatusesEntry)
- [banyandb/measure/v1/topn.proto](#banyandb_measure_v1_topn-proto)
- [TopNList](#banyandb-measure-v1-TopNList)
@@ -90,13 +95,17 @@
- [banyandb/stream/v1/query.proto](#banyandb_stream_v1_query-proto)
- [Element](#banyandb-stream-v1-Element)
- [QueryRequest](#banyandb-stream-v1-QueryRequest)
+ - [QueryRequest.GroupModRevisionsEntry](#banyandb-stream-v1-QueryRequest-GroupModRevisionsEntry)
- [QueryResponse](#banyandb-stream-v1-QueryResponse)
+ - [QueryResponse.GroupStatusesEntry](#banyandb-stream-v1-QueryResponse-GroupStatusesEntry)
- [banyandb/trace/v1/query.proto](#banyandb_trace_v1_query-proto)
- [InternalQueryResponse](#banyandb-trace-v1-InternalQueryResponse)
- [InternalTrace](#banyandb-trace-v1-InternalTrace)
- [QueryRequest](#banyandb-trace-v1-QueryRequest)
+ - [QueryRequest.GroupModRevisionsEntry](#banyandb-trace-v1-QueryRequest-GroupModRevisionsEntry)
- [QueryResponse](#banyandb-trace-v1-QueryResponse)
+ - [QueryResponse.GroupStatusesEntry](#banyandb-trace-v1-QueryResponse-GroupStatusesEntry)
- [Span](#banyandb-trace-v1-Span)
- [Trace](#banyandb-trace-v1-Trace)
@@ -107,8 +116,28 @@
- [banyandb/bydbql/v1/rpc.proto](#banyandb_bydbql_v1_rpc-proto)
- [BydbQLService](#banyandb-bydbql-v1-BydbQLService)
-- [banyandb/model/v1/write.proto](#banyandb_model_v1_write-proto)
- - [Status](#banyandb-model-v1-Status)
+- [banyandb/schema/v1/barrier.proto](#banyandb_schema_v1_barrier-proto)
+ - [AwaitRevisionAppliedRequest](#banyandb-schema-v1-AwaitRevisionAppliedRequest)
+ - [AwaitRevisionAppliedResponse](#banyandb-schema-v1-AwaitRevisionAppliedResponse)
+ - [AwaitSchemaAppliedRequest](#banyandb-schema-v1-AwaitSchemaAppliedRequest)
+ - [AwaitSchemaAppliedResponse](#banyandb-schema-v1-AwaitSchemaAppliedResponse)
+ - [AwaitSchemaDeletedRequest](#banyandb-schema-v1-AwaitSchemaDeletedRequest)
+ - [AwaitSchemaDeletedResponse](#banyandb-schema-v1-AwaitSchemaDeletedResponse)
+ - [NodeLaggard](#banyandb-schema-v1-NodeLaggard)
+ - [SchemaKey](#banyandb-schema-v1-SchemaKey)
+
+ - [SchemaBarrierService](#banyandb-schema-v1-SchemaBarrierService)
+
+- [banyandb/cluster/v1/node_schema_status.proto](#banyandb_cluster_v1_node_schema_status-proto)
+ - [GetAbsentKeysRequest](#banyandb-cluster-v1-GetAbsentKeysRequest)
+ - [GetAbsentKeysResponse](#banyandb-cluster-v1-GetAbsentKeysResponse)
+ - [GetKeyRevisionsRequest](#banyandb-cluster-v1-GetKeyRevisionsRequest)
+ - [GetKeyRevisionsResponse](#banyandb-cluster-v1-GetKeyRevisionsResponse)
+ - [GetMaxRevisionRequest](#banyandb-cluster-v1-GetMaxRevisionRequest)
+ - [GetMaxRevisionResponse](#banyandb-cluster-v1-GetMaxRevisionResponse)
+ - [KeyRevision](#banyandb-cluster-v1-KeyRevision)
+
+ - [NodeSchemaStatusService](#banyandb-cluster-v1-NodeSchemaStatusService)
- [banyandb/cluster/v1/rpc.proto](#banyandb_cluster_v1_rpc-proto)
- [FileInfo](#banyandb-cluster-v1-FileInfo)
@@ -899,6 +928,43 @@ Each item in a string array is seen as a token instead of a query expression.
+<a name="banyandb_model_v1_write-proto"></a>
+<p align="right"><a href="#top">Top</a></p>
+
+## banyandb/model/v1/write.proto
+
+
+
+
+
+<a name="banyandb-model-v1-Status"></a>
+
+### Status
+Status is the response status for write
+
+| Name | Number | Description |
+| ---- | ------ | ----------- |
+| STATUS_UNSPECIFIED | 0 | |
+| STATUS_SUCCEED | 1 | |
+| STATUS_INVALID_TIMESTAMP | 2 | |
+| STATUS_NOT_FOUND | 3 | |
+| STATUS_EXPIRED_SCHEMA | 4 | |
+| STATUS_INTERNAL_ERROR | 5 | |
+| STATUS_DISK_FULL | 6 | |
+| STATUS_VERSION_UNSUPPORTED | 7 | Client version not supported |
+| STATUS_VERSION_DEPRECATED | 8 | Client version deprecated but still supported |
+| STATUS_METADATA_REQUIRED | 9 | Metadata is required for the first request |
+| STATUS_SCHEMA_NOT_APPLIED | 10 | Client's ModRevision is ahead of the server cache; the server waited and timed out. |
+
+
+
+
+
+
+
+
+
+
<a name="banyandb_measure_v1_query-proto"></a>
<p align="right"><a href="#top">Top</a></p>
@@ -1015,6 +1081,7 @@ QueryRequest is the request contract for query.
| trace | [bool](#bool) | | trace is used to enable trace for the query |
| stages | [string](#string) | repeated | stages is used to specify the stage of the data points in the lifecycle |
| rewrite_agg_top_n_result | [bool](#bool) | | rewrite_agg_top_n_result will rewrite agg result to raw data |
+| group_mod_revisions | [QueryRequest.GroupModRevisionsEntry](#banyandb-measure-v1-QueryRequest-GroupModRevisionsEntry) | repeated | group_mod_revisions gates the query per group. Keys match entries in `groups`; values are the client's known mod_revision for that group. Empty map or value 0 means "don't gate". A group not listed in the map is not gated. |
@@ -1068,6 +1135,22 @@ QueryRequest is the request contract for query.
+<a name="banyandb-measure-v1-QueryRequest-GroupModRevisionsEntry"></a>
+
+### QueryRequest.GroupModRevisionsEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) | | |
+| value | [int64](#int64) | | |
+
+
+
+
+
+
<a name="banyandb-measure-v1-QueryRequest-Top"></a>
### QueryRequest.Top
@@ -1095,6 +1178,23 @@ QueryResponse is the response for a query to the Query module.
| ----- | ---- | ----- | ----------- |
| data_points | [DataPoint](#banyandb-measure-v1-DataPoint) | repeated | data_points are the actual data returned |
| trace | [banyandb.common.v1.Trace](#banyandb-common-v1-Trace) | | trace contains the trace information of the query when trace is enabled |
+| group_statuses | [QueryResponse.GroupStatusesEntry](#banyandb-measure-v1-QueryResponse-GroupStatusesEntry) | repeated | group_statuses reports the per-group gate outcome. Populated even when the query short-circuits because any group failed the gate. |
+
+
+
+
+
+
+<a name="banyandb-measure-v1-QueryResponse-GroupStatusesEntry"></a>
+
+### QueryResponse.GroupStatusesEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) | | |
+| value | [banyandb.model.v1.Status](#banyandb-model-v1-Status) | | |
@@ -1218,6 +1318,7 @@ Group is an internal object for Group management
| catalog | [Catalog](#banyandb-common-v1-Catalog) | | catalog denotes which type of data the group contains |
| resource_opts | [ResourceOpts](#banyandb-common-v1-ResourceOpts) | | resourceOpts indicates the structure of the underlying kv storage |
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when resources of the group are updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -1652,6 +1753,23 @@ QueryRequest is the request contract for query.
| projection | [banyandb.model.v1.TagProjection](#banyandb-model-v1-TagProjection) | | projection can be used to select the key names of the element in the response |
| trace | [bool](#bool) | | trace is used to enable trace for the query |
| stages | [string](#string) | repeated | stage is used to specify the stage of the query in the lifecycle |
+| group_mod_revisions | [QueryRequest.GroupModRevisionsEntry](#banyandb-stream-v1-QueryRequest-GroupModRevisionsEntry) | repeated | group_mod_revisions gates the query per group. Keys match entries in `groups`; values are the client's known mod_revision for that group. Empty map or value 0 means "don't gate". A group not listed in the map is not gated. |
+
+
+
+
+
+
+<a name="banyandb-stream-v1-QueryRequest-GroupModRevisionsEntry"></a>
+
+### QueryRequest.GroupModRevisionsEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) | | |
+| value | [int64](#int64) | | |
@@ -1668,6 +1786,23 @@ QueryResponse is the response for a query to the Query module.
| ----- | ---- | ----- | ----------- |
| elements | [Element](#banyandb-stream-v1-Element) | repeated | elements are the actual data returned |
| trace | [banyandb.common.v1.Trace](#banyandb-common-v1-Trace) | | trace contains the trace information of the query when trace is enabled |
+| group_statuses | [QueryResponse.GroupStatusesEntry](#banyandb-stream-v1-QueryResponse-GroupStatusesEntry) | repeated | group_statuses reports the per-group gate outcome. Populated even when the query short-circuits because any group failed the gate. |
+
+
+
+
+
+
+<a name="banyandb-stream-v1-QueryResponse-GroupStatusesEntry"></a>
+
+### QueryResponse.GroupStatusesEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) | | |
+| value | [banyandb.model.v1.Status](#banyandb-model-v1-Status) | | |
@@ -1741,6 +1876,23 @@ QueryRequest is the request contract for query.
| tag_projection | [string](#string) | repeated | projection can be used to select the names of the tags in the response |
| trace | [bool](#bool) | | trace is used to enable trace for the query |
| stages | [string](#string) | repeated | stage is used to specify the stage of the query in the lifecycle |
+| group_mod_revisions | [QueryRequest.GroupModRevisionsEntry](#banyandb-trace-v1-QueryRequest-GroupModRevisionsEntry) | repeated | group_mod_revisions gates the query per group. Keys match entries in `groups`; values are the client's known mod_revision for that group. Empty map or value 0 means "don't gate". A group not listed in the map is not gated. |
+
+
+
+
+
+
+<a name="banyandb-trace-v1-QueryRequest-GroupModRevisionsEntry"></a>
+
+### QueryRequest.GroupModRevisionsEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) | | |
+| value | [int64](#int64) | | |
@@ -1757,6 +1909,23 @@ QueryResponse is the response of a query.
| ----- | ---- | ----- | ----------- |
| traces | [Trace](#banyandb-trace-v1-Trace) | repeated | traces is a list of traces that match the query, with spans grouped by trace ID. |
| trace_query_result | [banyandb.common.v1.Trace](#banyandb-common-v1-Trace) | | trace_query_result contains the trace of the query execution if tracing is enabled. |
+| group_statuses | [QueryResponse.GroupStatusesEntry](#banyandb-trace-v1-QueryResponse-GroupStatusesEntry) | repeated | group_statuses reports the per-group gate outcome. Populated even when the query short-circuits because any group failed the gate. |
+
+
+
+
+
+
+<a name="banyandb-trace-v1-QueryResponse-GroupStatusesEntry"></a>
+
+### QueryResponse.GroupStatusesEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) | | |
+| value | [banyandb.model.v1.Status](#banyandb-model-v1-Status) | | |
@@ -1881,32 +2050,293 @@ BydbQLService provides query interface for BanyanDB Query Language (BydbQL)
-<a name="banyandb_model_v1_write-proto"></a>
+<a name="banyandb_schema_v1_barrier-proto"></a>
<p align="right"><a href="#top">Top</a></p>
-## banyandb/model/v1/write.proto
+## banyandb/schema/v1/barrier.proto
+
+
+
+<a name="banyandb-schema-v1-AwaitRevisionAppliedRequest"></a>
+
+### AwaitRevisionAppliedRequest
+AwaitRevisionAppliedRequest carries the minimum mod_revision the caller is
+waiting for and a wall-clock budget for the wait.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| min_revision | [int64](#int64) | | |
+| timeout | [google.protobuf.Duration](#google-protobuf-Duration) | | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitRevisionAppliedResponse"></a>
+
+### AwaitRevisionAppliedResponse
+AwaitRevisionAppliedResponse reports whether every node reached the target
+revision before the timeout, and lists laggards otherwise.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| applied | [bool](#bool) | | |
+| laggards | [NodeLaggard](#banyandb-schema-v1-NodeLaggard) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaAppliedRequest"></a>
+
+### AwaitSchemaAppliedRequest
+AwaitSchemaAppliedRequest pairs each key with a per-key minimum revision.
+keys is capped at 10000 server-side; exceeding the cap returns
+InvalidArgument. A min_revisions entry of 0 means "just present, any
+revision".
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+| min_revisions | [int64](#int64) | repeated | |
+| timeout | [google.protobuf.Duration](#google-protobuf-Duration) | | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaAppliedResponse"></a>
+
+### AwaitSchemaAppliedResponse
+AwaitSchemaAppliedResponse reports whether every requested key is present at
+or above its target revision on every node.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| applied | [bool](#bool) | | |
+| laggards | [NodeLaggard](#banyandb-schema-v1-NodeLaggard) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaDeletedRequest"></a>
+
+### AwaitSchemaDeletedRequest
+AwaitSchemaDeletedRequest names the keys that must disappear from every
+node's cache before the call returns.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+| timeout | [google.protobuf.Duration](#google-protobuf-Duration) | | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaDeletedResponse"></a>
+
+### AwaitSchemaDeletedResponse
+AwaitSchemaDeletedResponse reports whether every node has removed the
+requested keys.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| applied | [bool](#bool) | | |
+| laggards | [NodeLaggard](#banyandb-schema-v1-NodeLaggard) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-NodeLaggard"></a>
+
+### NodeLaggard
+NodeLaggard reports a single data node that has not caught up to the
+requested schema state. missing_keys is populated by AwaitSchemaApplied
+responses; still_present_keys is populated by AwaitSchemaDeleted responses.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| node | [string](#string) | | |
+| current_mod_revision | [int64](#int64) | | |
+| missing_keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+| still_present_keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-SchemaKey"></a>
+
+### SchemaKey
+SchemaKey identifies a schema resource by kind and name. Valid kind values:
+"measure", "stream", "trace", "property", "index_rule",
+"index_rule_binding", "group", "top_n_aggregation".
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| kind | [string](#string) | | |
+| group | [string](#string) | | |
+| name | [string](#string) | | |
+
+
+
+
+
+
+
+
+<a name="banyandb-schema-v1-SchemaBarrierService"></a>
+
+### SchemaBarrierService
+SchemaBarrierService lets clients block until every data node in the cluster
+has caught up to a target schema state. The standalone implementation lands
+in Step 1.8; the distributed liaison fan-out lands in Steps 1.15-1.17.
+
+| Method Name | Request Type | Response Type | Description |
+| ----------- | ------------ | ------------- | ------------|
+| AwaitRevisionApplied | [AwaitRevisionAppliedRequest](#banyandb-schema-v1-AwaitRevisionAppliedRequest) | [AwaitRevisionAppliedResponse](#banyandb-schema-v1-AwaitRevisionAppliedResponse) | AwaitRevisionApplied blocks until every data node's local schema cache has observed mod_revision >= min_revision, or the timeout elapses. |
+| AwaitSchemaApplied | [AwaitSchemaAppliedRequest](#banyandb-schema-v1-AwaitSchemaAppliedRequest) | [AwaitSchemaAppliedResponse](#banyandb-schema-v1-AwaitSchemaAppliedResponse) | AwaitSchemaApplied blocks until every data node reports all requested keys present at or above the per-key mod_revision. |
+| AwaitSchemaDeleted | [AwaitSchemaDeletedRequest](#banyandb-schema-v1-AwaitSchemaDeletedRequest) | [AwaitSchemaDeletedResponse](#banyandb-schema-v1-AwaitSchemaDeletedResponse) | AwaitSchemaDeleted blocks until every data node has removed all requested keys from its cache. |
+
+
+
+
+
+<a name="banyandb_cluster_v1_node_schema_status-proto"></a>
+<p align="right"><a href="#top">Top</a></p>
+
+## banyandb/cluster/v1/node_schema_status.proto
+
+
+
+<a name="banyandb-cluster-v1-GetAbsentKeysRequest"></a>
+
+### GetAbsentKeysRequest
+GetAbsentKeysRequest names the keys whose absence the caller wants to
+confirm.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetAbsentKeysResponse"></a>
+
+### GetAbsentKeysResponse
+GetAbsentKeysResponse partitions the requested keys into absent and
+still-present subsets.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| absent_keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+| still_present_keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetKeyRevisionsRequest"></a>
+
+### GetKeyRevisionsRequest
+GetKeyRevisionsRequest names the keys whose per-key revisions the caller
+wants to inspect.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetKeyRevisionsResponse"></a>
+
+### GetKeyRevisionsResponse
+GetKeyRevisionsResponse lists per-key revisions in the same order the
+caller supplied keys.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| revisions | [KeyRevision](#banyandb-cluster-v1-KeyRevision) | repeated | |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetMaxRevisionRequest"></a>
+
+### GetMaxRevisionRequest
+GetMaxRevisionRequest carries no parameters; the node returns its current
+max mod_revision.
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetMaxRevisionResponse"></a>
+
+### GetMaxRevisionResponse
+GetMaxRevisionResponse holds the node's current max mod_revision.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| max_mod_revision | [int64](#int64) | | |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-KeyRevision"></a>
+
+### KeyRevision
+KeyRevision pairs a SchemaKey with the node's local mod_revision and
+presence flag.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | | |
+| mod_revision | [int64](#int64) | | |
+| present | [bool](#bool) | | |
-<a name="banyandb-model-v1-Status"></a>
-### Status
-Status is the response status for write
-| Name | Number | Description |
-| ---- | ------ | ----------- |
-| STATUS_UNSPECIFIED | 0 | |
-| STATUS_SUCCEED | 1 | |
-| STATUS_INVALID_TIMESTAMP | 2 | |
-| STATUS_NOT_FOUND | 3 | |
-| STATUS_EXPIRED_SCHEMA | 4 | |
-| STATUS_INTERNAL_ERROR | 5 | |
-| STATUS_DISK_FULL | 6 | |
-| STATUS_VERSION_UNSUPPORTED | 7 | Client version not supported |
-| STATUS_VERSION_DEPRECATED | 8 | Client version deprecated but still supported |
-| STATUS_METADATA_REQUIRED | 9 | Metadata is required for the first request |
@@ -1916,6 +2346,22 @@ Status is the response status for write
+<a name="banyandb-cluster-v1-NodeSchemaStatusService"></a>
+
+### NodeSchemaStatusService
+NodeSchemaStatusService is exposed by every data node so the liaison can
+inspect each node's local schema cache when satisfying a SchemaBarrierService
+request. The implementation lands in Step 2.1.
+
+| Method Name | Request Type | Response Type | Description |
+| ----------- | ------------ | ------------- | ------------|
+| GetMaxRevision | [GetMaxRevisionRequest](#banyandb-cluster-v1-GetMaxRevisionRequest) | [GetMaxRevisionResponse](#banyandb-cluster-v1-GetMaxRevisionResponse) | GetMaxRevision returns the highest mod_revision currently observed by the node's schema cache. |
+| GetKeyRevisions | [GetKeyRevisionsRequest](#banyandb-cluster-v1-GetKeyRevisionsRequest) | [GetKeyRevisionsResponse](#banyandb-cluster-v1-GetKeyRevisionsResponse) | GetKeyRevisions returns the per-key mod_revision observed by the node, and a presence flag for each key. |
+| GetAbsentKeys | [GetAbsentKeysRequest](#banyandb-cluster-v1-GetAbsentKeysRequest) | [GetAbsentKeysResponse](#banyandb-cluster-v1-GetAbsentKeysResponse) | GetAbsentKeys partitions the requested keys into those the node has already removed and those that are still present. |
+
+
+
+
<a name="banyandb_cluster_v1_rpc-proto"></a>
<p align="right"><a href="#top">Top</a></p>
@@ -2450,6 +2896,7 @@ IndexRule should bind to a subject through an IndexRuleBinding to generate prope
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the IndexRule is updated |
| analyzer | [string](#string) | | analyzer analyzes tag value to support the full-text searching for TYPE_INVERTED indices. available analyzers are: - "standard" provides grammar based tokenization - "simple" breaks text into tokens at any non-letter character, such as numbers, spaces, hyphens and apostrophes, discards non-letter characters, and changes uppercase to lowercase. - "keyword" is a “noop” analyzer which returns the entire input string as a single tok [...]
| no_sort | [bool](#bool) | | no_sort indicates whether the index is not for sorting. |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2472,6 +2919,7 @@ to control how to generate time series indices.
| begin_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | begin_at_nanoseconds is the timestamp, after which the binding will be active |
| expire_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | expire_at_nanoseconds it the timestamp, after which the binding will be inactive expire_at_nanoseconds must be larger than begin_at_nanoseconds |
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the IndexRuleBinding is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2494,6 +2942,7 @@ Measure intends to store data point
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the measure is updated |
| index_mode | [bool](#bool) | | index_mode specifies whether the data should be stored exclusively in the index, meaning it will not be stored in the data storage system. |
| sharding_key | [ShardingKey](#banyandb-database-v1-ShardingKey) | | sharding_key determines the distribution of TopN-related data. |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2511,6 +2960,7 @@ Property stores the user defined data
| metadata | [banyandb.common.v1.Metadata](#banyandb-common-v1-Metadata) | | metadata is the identity of a property |
| tags | [TagSpec](#banyandb-database-v1-TagSpec) | repeated | tag stores the content of a property |
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the property is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2544,6 +2994,7 @@ Stream intends to store streaming data, for example, traces or logs
| tag_families | [TagFamilySpec](#banyandb-database-v1-TagFamilySpec) | repeated | tag_families |
| entity | [Entity](#banyandb-database-v1-Entity) | | entity indicates how to generate a series and shard a stream |
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the stream is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2615,6 +3066,7 @@ TopNAggregation generates offline TopN statistics for a measure's TopN appro
| counters_number | [int32](#int32) | | counters_number sets the number of counters to be tracked. The default value is 1000 |
| lru_size | [int32](#int32) | | lru_size defines how much entry is allowed to be maintained in the memory |
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the measure is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2638,6 +3090,7 @@ while the group of a Trace corresponds to a physical directory.
| timestamp_tag_name | [string](#string) | | timestamp_tag_name is the name of the tag that stores the timestamp. |
| updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | updated_at indicates when the trace resource is updated. |
| span_id_tag_name | [string](#string) | | span_id_tag_name is the name of the tag that stores the span ID. |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | created_at is the first-appearance timestamp; survives updates unchanged. |
@@ -2904,6 +3357,11 @@ GroupDeletionTask represents the status of a group deletion operation.
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -2935,6 +3393,8 @@ GroupRegistryServiceDeleteResponse is the response for deleting a group.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| schema_info | [SchemaInfo](#banyandb-database-v1-SchemaInfo) | | schema_info contains the schema resources that would be deleted (populated in dry-run mode). |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -3110,6 +3570,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -3135,6 +3600,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -3163,6 +3633,8 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -3281,6 +3753,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -3306,6 +3783,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -3334,6 +3816,8 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -3452,6 +3936,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -3545,6 +4034,8 @@ LiaisonInfo contains information about pending operations in liaison.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -3726,6 +4217,8 @@ LiaisonInfo contains information about pending operations in liaison.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -4081,6 +4574,8 @@ ShardInfo contains information about a specific shard.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -4229,6 +4724,11 @@ ShardInfo contains information about a specific shard.
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -4257,6 +4757,8 @@ ShardInfo contains information about a specific shard.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
@@ -4375,6 +4877,11 @@ ShardInfo contains information about a specific shard.
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
@@ -4433,6 +4940,8 @@ ShardInfo contains information about a specific shard.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| deleted | [bool](#bool) | | |
+| delete_time | [int64](#int64) | | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) | | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
diff --git a/docs/interacting/schema-consistency/barriers.md b/docs/interacting/schema-consistency/barriers.md
new file mode 100644
index 000000000..13fc1cbbb
--- /dev/null
+++ b/docs/interacting/schema-consistency/barriers.md
@@ -0,0 +1,194 @@
+# Schema Barrier RPCs
+
+`SchemaBarrierService` (proto package `banyandb.schema.v1`) lets a client block synchronously until the cluster's schema state matches a target. All three RPCs share the same shape: a request carrying the target plus a timeout, and a response carrying `bool applied` and a list of `NodeLaggard` entries when the call did not converge in time.
+
+This page is a reference. For end-to-end workflows that combine barriers with the write and query gates, see [Scenarios](scenarios.md).
+
+## SchemaKey
+
+Several requests reference schemas by `SchemaKey`:
+
+```protobuf
+message SchemaKey {
+ string kind = 1; // "measure", "stream", "trace", "property",
+ // "index_rule", "index_rule_binding", "group", "top_n_aggregation"
+ string group = 2; // group name; empty for kind="group"
+ string name = 3; // resource name; empty for kind="group" if referring to the group itself
+}
+```
+
+For a Group key, set `kind = "group"`, `name = "<group-name>"`, `group = ""`.
+
+## NodeLaggard
+
+When `applied == false`, the response carries one entry per data node that has not converged:
+
+```protobuf
+message NodeLaggard {
+ string node = 1;
+ int64 current_mod_revision = 2; // populated by AwaitRevisionApplied
+ repeated SchemaKey missing_keys = 3; // populated by AwaitSchemaApplied
+ repeated SchemaKey still_present_keys = 4; // populated by AwaitSchemaDeleted
+}
+```
+
+In standalone mode there is one `NodeLaggard` entry at most. In cluster mode (Phase 2) the list reports every laggard node.
+
+## AwaitRevisionApplied
+
+Block until every data node's cache observes `mod_revision >= min_revision`.
+
+```protobuf
+rpc AwaitRevisionApplied(AwaitRevisionAppliedRequest)
+ returns (AwaitRevisionAppliedResponse);
+
+message AwaitRevisionAppliedRequest {
+ int64 min_revision = 1;
+ google.protobuf.Duration timeout = 2;
+}
+
+message AwaitRevisionAppliedResponse {
+ bool applied = 1;
+ repeated NodeLaggard laggards = 2;
+}
+```
+
+### Semantics
+
+- The server returns as soon as the watermark reaches `min_revision`. Polling is internal; the client gets one synchronous answer.
+- On timeout the response has `applied = false` and lists every laggard node with its `current_mod_revision`. The call **never** returns indefinitely.
+- A `min_revision` of `0` returns `applied = true` immediately.
+- A nil or zero `timeout` falls back to a server default (5 s in the standalone implementation).
+
+### Go example
+
+```go
+import (
+ "context"
+ "time"
+
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+ "google.golang.org/protobuf/types/known/durationpb"
+)
+
+resp, err := barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: targetRev,
+ Timeout: durationpb.New(10 * time.Second),
+})
+if err != nil {
+ return err // gRPC-level error; not a timeout
+}
+if !resp.GetApplied() {
+ for _, l := range resp.GetLaggards() {
+ log.Printf("node %s at rev %d, target %d", l.GetNode(), l.GetCurrentModRevision(), targetRev)
+ }
+ return fmt.Errorf("schema did not propagate within timeout")
+}
+```
+
+## AwaitSchemaApplied
+
+Block until each requested key is present at or above its target revision on every node.
+
+```protobuf
+rpc AwaitSchemaApplied(AwaitSchemaAppliedRequest)
+ returns (AwaitSchemaAppliedResponse);
+
+message AwaitSchemaAppliedRequest {
+ repeated SchemaKey keys = 1;
+ repeated int64 min_revisions = 2;
+ google.protobuf.Duration timeout = 3;
+}
+
+message AwaitSchemaAppliedResponse {
+ bool applied = 1;
+ repeated NodeLaggard laggards = 2;
+}
+```
+
+### Semantics
+
+- `keys` and `min_revisions` are **parallel arrays**: `min_revisions[i]` applies to `keys[i]`. If `min_revisions` is empty or shorter than `keys`, the missing entries default to `0` (meaning "any revision; just be present").
+- `keys` is capped at **10 000** server-side. Exceeding the cap returns `InvalidArgument`. Chunk larger sets across multiple calls; merge `applied` results with logical AND and union laggards.
+- On timeout, each laggard's `missing_keys` enumerates exactly the keys not yet at the target on that node.
+- `applied = true` is returned only when every key on every node is at or above the target.
+
+### Go example
+
+```go
+keys := []*schemav1.SchemaKey{
+ {Kind: "measure", Group: "sw_metric", Name: "service_cpm_minute"},
+ {Kind: "measure", Group: "sw_metric", Name: "service_resp_time"},
+}
+revs := []int64{firstRev, secondRev}
+
+resp, err := barrier.AwaitSchemaApplied(ctx, &schemav1.AwaitSchemaAppliedRequest{
+ Keys: keys,
+ MinRevisions: revs,
+ Timeout: durationpb.New(15 * time.Second),
+})
+if err != nil { return err }
+if !resp.GetApplied() {
+ for _, l := range resp.GetLaggards() {
+ for _, k := range l.GetMissingKeys() {
+ log.Printf("node %s missing %s/%s/%s", l.GetNode(), k.GetKind(), k.GetGroup(), k.GetName())
+ }
+ }
+}
+```
+
+## AwaitSchemaDeleted
+
+Block until each named key is **absent** from every node's cache.
+
+```protobuf
+rpc AwaitSchemaDeleted(AwaitSchemaDeletedRequest)
+ returns (AwaitSchemaDeletedResponse);
+
+message AwaitSchemaDeletedRequest {
+ repeated SchemaKey keys = 1;
+ google.protobuf.Duration timeout = 2;
+}
+
+message AwaitSchemaDeletedResponse {
+ bool applied = 1;
+ repeated NodeLaggard laggards = 2;
+}
+```
+
+### Semantics
+
+- Same 10 000-key cap as `AwaitSchemaApplied`.
+- On timeout each laggard's `still_present_keys` enumerates the keys the call was waiting to see disappear.
+- This RPC reports tombstone propagation, not tombstone GC. A key that has tombstoned is "absent" for the purpose of this call even before the tombstone is physically removed by the GC loop.
+
+### Go example
+
+```go
+resp, err := barrier.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+ Keys: []*schemav1.SchemaKey{
+ {Kind: "measure", Group: "sw_metric", Name: "service_cpm_minute"},
+ },
+ Timeout: durationpb.New(10 * time.Second),
+})
+if err != nil { return err }
+if !resp.GetApplied() {
+ // Inspect resp.Laggards[i].StillPresentKeys
+}
+```
+
+## Choosing the right barrier
+
+| Situation | RPC |
+|---|---|
+| You just got a `mod_revision = R` back from a Create/Update and want every node to see R or later | `AwaitRevisionApplied(R)` |
+| You created several schemas at once and need to confirm each one is visible | `AwaitSchemaApplied(keys, revs)` |
+| You want to confirm a deleted schema is gone before re-creating | `AwaitSchemaDeleted(keys)` |
+| You don't know the revisions but want presence | `AwaitSchemaApplied(keys, []int64{})` (zero-filled) |
+
+## Common pitfalls
+
+- **Setting an absurdly small timeout.** Even on a healthy cluster, schema propagation has measurable latency. Set timeouts in the seconds range, not milliseconds. The barrier is a synchronous safety net; treat it like one.
+- **Forgetting that `min_revisions` is a parallel array.** If you pass three keys but only two revisions, the third key is gated on revision `0` (any revision). That is rarely what the caller meant.
+- **Calling the barrier from the same context that issued the schema mutation, with a context deadline tighter than the timeout.** The context cancellation wins. Either propagate enough deadline budget or use a fresh context for the barrier.
+- **Treating a non-error response with `applied = false` as an error.** It is a soft outcome — the client may want to act on the laggards (alert, fall back to the un-gated path) rather than abort.
diff --git a/docs/interacting/schema-consistency/overview.md b/docs/interacting/schema-consistency/overview.md
new file mode 100644
index 000000000..8586f118c
--- /dev/null
+++ b/docs/interacting/schema-consistency/overview.md
@@ -0,0 +1,68 @@
+# Schema Consistency Overview
+
+BanyanDB is an eventually-consistent system. Schema mutations (Create / Update / Delete) are accepted by the schema server, persisted, and then propagated to every data node's in-memory cache asynchronously. Most clients tolerate this lag, but request paths that depend on a freshly-changed schema — read-after-write, delete-then-recreate, multi-stage rollouts — need a way to **observe** propagation and **gate** their requests on it.
+
+Phase 1 (shipped in [v0.11.0](../../../CHANGES.md)) adds the client-observable surface for that.
+
+## What is new in v0.11.0
+
+The new client surface falls into three groups.
+
+### 1. Response fields — clients read them
+
+| Field | Where | What it tells the client |
+|---|---|---|
+| `mod_revision` (`int64`) | `*ServiceCreateResponse`, `*ServiceUpdateResponse` for Group / IndexRule / IndexRuleBinding / TopNAggregation | The schema's revision after the call. Use this in subsequent gated writes/queries. |
+| `delete_time` (`int64`, Unix ns) | every `*ServiceDeleteResponse` | The tombstone's persisted delete time. Required for tombstone-aware re-create. |
+| `created_at` (`google.protobuf.Timestamp`) | Stream / Measure / Trace / Property / IndexRule / IndexRuleBinding / TopNAggregation / Group | The first-creation timestamp; preserved across updates. Drives the time-range clamp. |
+| `group_statuses` (`map<string, Status>`) | Stream / Measure / Trace `QueryResponse` | Per-group outcome of the query gate. |
+
+### 2. Request fields — clients send them
+
+| Field | Where | What it does |
+|---|---|---|
+| `Metadata.mod_revision` (`int64`) | `WriteRequest` for Stream / Measure | Gates the write against the cached schema revision. `0` skips the gate. |
+| `group_mod_revisions` (`map<string, int64>`) | Stream / Measure / Trace `QueryRequest` | Per-group gate. Empty map skips, `0` for a group skips that group. |
+
+### 3. Barrier RPCs — clients block on propagation
+
+`SchemaBarrierService` (proto: `banyandb.schema.v1.SchemaBarrierService`) exposes three RPCs:
+
+| RPC | Use it to |
+|---|---|
+| `AwaitRevisionApplied(min_revision, timeout)` | Wait until every data node's cache observes `mod_revision >= min_revision`. |
+| `AwaitSchemaApplied(keys, min_revisions, timeout)` | Wait until each named schema key is present at the requested revision on every node. |
+| `AwaitSchemaDeleted(keys, timeout)` | Wait until each named schema key is absent from every node. |
+
+See [Barrier RPCs](barriers.md) for the full reference.
+
+## Status codes that may surface to clients
+
+| Status | Code | Meaning |
+|---|---|---|
+| `STATUS_SUCCEED` | 1 | Gate passed. Continue. |
+| `STATUS_EXPIRED_SCHEMA` | 7 | Client's `mod_revision` is **behind** the cache. Client is using a stale schema view; refresh and retry. |
+| `STATUS_SCHEMA_NOT_APPLIED` | 10 | Client's `mod_revision` is **ahead** of the cache; the schema mutation has not yet propagated. Call `AwaitRevisionApplied` (or wait briefly) and retry. |
+
+## Backward compatibility
+
+Every gate is opt-in. A client that does not set `Metadata.mod_revision` (or sends `0`) and does not populate `QueryRequest.group_mod_revisions` (or sends `0` per group) sees exactly the prior behavior — no rejection, no clamp, no waiting.
+
+The new response fields are additive on the wire; older clients that ignore unknown fields keep working unchanged. The only enum addition is `STATUS_SCHEMA_NOT_APPLIED = 10`. Strict-unmarshal clients that reject unknown enum values must be upgraded before deploying against a v0.11.0 server. See the migration appendix in [Scenarios](scenarios.md#migration-appendix) for details.
+
+## When you need these tools
+
+- You write a schema and immediately read or write data against it → gate the data write or query, or block on `AwaitSchemaApplied`.
+- You delete a schema and recreate the same key → use `delete_time` and `AwaitSchemaDeleted` to confirm tombstone propagation, then satisfy the re-create invariant.
+- You drive a fan-out workflow (dashboards, batch pipelines) that joins multiple groups with independent versioning → use `group_mod_revisions` per group.
+- You bootstrap a large schema set and want to know when every node has caught up → call `AwaitRevisionApplied` once with the maximum revision.
+
+When none of those apply (single-group, schema is created long before the writes/queries it gates), you can ignore the entire surface.
+
+## Related documents
+
+- [Barrier RPCs](barriers.md) — full reference for the three `SchemaBarrierService` calls
+- [Revision Gates](revision-gates.md) — write/query gate semantics, time-range clamp, tombstone invariant
+- [Scenarios](scenarios.md) — six worked workflows that combine the tools
+- [API Reference](../../api-reference.md) — generated proto reference
+- [Data Lifecycle](../data-lifecycle.md) — TTL-based data expiry (separate from schema GC)
diff --git a/docs/interacting/schema-consistency/revision-gates.md b/docs/interacting/schema-consistency/revision-gates.md
new file mode 100644
index 000000000..af0057faa
--- /dev/null
+++ b/docs/interacting/schema-consistency/revision-gates.md
@@ -0,0 +1,141 @@
+# Revision Gates
+
+The barrier RPCs let clients **block** on propagation. The revision gates let clients **send a write or query that fails fast** when the schema view does not align. Both surfaces use the same `mod_revision` value the server returned on the originating Create/Update.
+
+This page describes the three places a revision matters at request time:
+
+1. The **write-path gate** (`Metadata.mod_revision` on `WriteRequest`)
+2. The **query-path gate** (`group_mod_revisions` on `QueryRequest`)
+3. The **time-range clamp** driven by `created_at`
+
+It also covers the **tombstone invariant** that gates `Create` after a delete.
+
+## Write-path gate
+
+Stream and Measure write RPCs read `Metadata.mod_revision` on each `WriteRequest` and compare it against the cached schema revision for that group/name.
+
+| Client `mod_revision` | Cache state | Server response status |
+|---|---|---|
+| `0` | any | `STATUS_SUCCEED` — gate skipped |
+| `== cache.ModRevision` | match | `STATUS_SUCCEED` — write proceeds |
+| `< cache.ModRevision` | client behind | `STATUS_EXPIRED_SCHEMA` — fail fast, refresh schema, retry |
+| `> cache.ModRevision` | client ahead | held until `WaitForSchema` budget elapses, then `STATUS_SCHEMA_NOT_APPLIED` |
+
+The "ahead" branch lets the server absorb a brief propagation lag without surfacing a transient error to the client. The hold budget is server-configurable; the default is small (sub-second) so a write either commits quickly or returns promptly with `STATUS_SCHEMA_NOT_APPLIED`.
+
+### When to use it
+
+- A client wrote a schema update, then writes a data point that depends on the update having applied. Pass the new `mod_revision` to the write so the server can confirm it landed against the right schema.
+- A client retries an idempotent write across a schema change. If the schema advances during retry, the gate flags the stale request rather than silently writing against the wrong shape.
+
+### Sample
+
+```go
+import (
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+)
+
+writeClient.Send(&measurev1.WriteRequest{
+ Metadata: &commonv1.Metadata{
+ Group: "sw_metric",
+ Name: "service_cpm_minute",
+ ModRevision: knownRev, // the value returned by Update / Get
+ },
+ DataPoint: dp,
+ MessageId: nextMessageID(),
+})
+```
+
+## Query-path gate
+
+Stream, Measure, and Trace `QueryRequest` messages accept a `map<string, int64> group_mod_revisions` (field 50). Each entry maps a group from the `groups` list to the `mod_revision` the client last observed for that group. The matching `QueryResponse.group_statuses` map (field 50) reports the gate outcome per group.
+
+| Map state | Server behavior |
+|---|---|
+| Empty map | Gate skipped for the entire query — backward-compatible default. |
+| Group present, value `0` | Gate skipped for that group. |
+| Group present, value matches cache | `group_statuses[group] = STATUS_SUCCEED` |
+| Group present, value below cache | `group_statuses[group] = STATUS_EXPIRED_SCHEMA`; query short-circuits, `elements` is empty |
+| Group present, value above cache | held until the wait budget; on expiry `STATUS_SCHEMA_NOT_APPLIED` |
+| Group absent from map (but present in `groups`) | Gate skipped for that group; partial-coverage opt-in |
+
+The per-group structure exists because multi-group queries should not be forced to take `max(rev_a, rev_b)` and block on the slowest group; each group is gated independently. See [Scenarios](scenarios.md#5-multi-group-cross-cutting-query) for a worked example.
+
+### Sample
+
+```go
+import streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+
+resp, err := streamClient.Query(ctx, &streamv1.QueryRequest{
+ Groups: []string{"sw_metric", "sw_metric_minute"},
+ Name: "service_resp_time",
+ GroupModRevisions: map[string]int64{
+ "sw_metric": rev1,
+ "sw_metric_minute": rev2,
+ },
+ TimeRange: trange,
+ Projection: proj,
+})
+if err != nil { return err }
+
+for group, status := range resp.GetGroupStatuses() {
+ if status != modelv1.Status_STATUS_SUCCEED {
+ log.Printf("group %s gate: %s", group, status)
+ }
+}
+```
+
+## Time-range clamp
+
+Queries whose `time_range.start` falls before a schema's `created_at` are silently clamped:
+
+```
+effective_start = max(request.time_range.start, schema.created_at)
+```
+
+For a multi-group query, the server uses the **maximum** `created_at` across the queried groups, so the result set never reaches into a window before any of the participating schemas existed.
+
+| Range relative to `created_at` | Result |
+|---|---|
+| `time_range.end < created_at` | Empty result set, no error |
+| `time_range.start < created_at <= time_range.end` | Window is clipped; only data on or after `created_at` is returned |
+| `time_range.start >= created_at` | Untouched; full window honored |
+| `created_at == nil` (schema predates v0.11.0) | No-op; clamp does not fire |
+
+The clamp is **opt-in** by virtue of `group_mod_revisions`. If a query does not specify any per-group revision, the server does not invoke the clamp at all — pre-existing queries that do not yet know about the gate continue to behave as before. This protects bydbctl and other tools that may issue queries without schema awareness.
+
+### When the clamp matters
+
+- A schema was deleted and recreated with the same name (delete-then-recreate). A query that asks for the previous month must not return data from before the new schema's `created_at`. The clamp guarantees this without requiring the client to know about the delete.
+- A new measure was added today. A dashboard that asks for "last 7 days" gets data from today onward; no fabricated zeroes for the six days before the schema existed.
+
+## Tombstone invariant for re-create
+
+When a schema is deleted, the server retains a tombstone with `delete_time` (Unix nanoseconds). A subsequent `Create` for the same key is rejected with gRPC status `codes.InvalidArgument` and message `"updated_at_before_tombstone"` if:
+
+```
+incoming_schema.updated_at <= tombstone.delete_time
+```
+
+This prevents a replayed or stale `Create` from silently overwriting a more-recent delete.
+
+### How to satisfy it
+
+Client code should derive `UpdatedAt` from the wall clock at submission time. As long as the recreate is genuinely subsequent to the delete, the invariant holds. If a client batches operations, it must ensure timestamps advance — for example by re-stamping the spec right before the `Create` rather than reusing a captured value from earlier.
+
+### How to recover
+
+If the server returns `InvalidArgument: updated_at_before_tombstone`:
+
+1. Re-stamp the spec's `UpdatedAt` to `time.Now()` (or any value strictly greater than the tombstone's `delete_time`).
+2. Re-issue the Create.
+
+The error indicates a clock or batching issue; it is not retried automatically because retrying with the same payload would loop indefinitely.
+
+## Pitfalls
+
+- **Threading `mod_revision` through retries.** A retry that captured the original `mod_revision` may find the schema has advanced. Re-fetch the spec (or the most recent `mod_revision` from a registry sync) on each retry rather than reusing a stale value.
+- **Mixing gated and ungated traffic.** A workflow that gates writes but issues ungated queries (`group_mod_revisions` empty) loses the clamp. If the workflow depends on the clamp for correctness, it must populate per-group revisions even when it does not care about the gate's three-way split.
+- **Treating `STATUS_EXPIRED_SCHEMA` and `STATUS_SCHEMA_NOT_APPLIED` as the same.** They are opposites: expired ⇒ refresh schema, not-applied ⇒ wait or call `AwaitRevisionApplied`. Lumping them into a single error path masks real misconfiguration.
+- **Forgetting that the clamp is opt-in.** Tools such as bydbctl issue queries without `group_mod_revisions`. Their results are not clamped; an integration test that depends on the clamp must populate the map.
diff --git a/docs/interacting/schema-consistency/scenarios.md b/docs/interacting/schema-consistency/scenarios.md
new file mode 100644
index 000000000..a26b18ff1
--- /dev/null
+++ b/docs/interacting/schema-consistency/scenarios.md
@@ -0,0 +1,278 @@
+# Scenarios
+
+Six worked workflows that combine the new tools. Code samples are Go pseudocode against the BanyanDB Go client; adapt the calls to your language by following the proto definitions.
+
+The scenarios assume the following short-form imports:
+
+```go
+import (
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+ "google.golang.org/protobuf/types/known/durationpb"
+)
+```
+
+---
+
+## 1. Read-your-writes after a fresh Create
+
+**Problem:** A test or migration creates a measure and immediately writes data points. Without the gate, a brief propagation lag can cause the write to land before the data node has loaded the schema.
+
+**Tools:** Create response → `mod_revision` → `AwaitRevisionApplied` → gated write.
+
+```go
+createResp, err := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: spec,
+})
+if err != nil { return err }
+rev := createResp.GetModRevision() // non-zero in v0.11.0+
+
+if _, err := barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: rev,
+ Timeout: durationpb.New(10 * time.Second),
+}); err != nil { return err }
+
+writeClient.Send(&measurev1.WriteRequest{
+ Metadata: &commonv1.Metadata{Group: spec.Metadata.Group, Name: spec.Metadata.Name, ModRevision: rev},
+ DataPoint: dp,
+ MessageId: nextMessageID(),
+})
+```
+
+**Why both barrier and gate?** The barrier is a synchronous "schema is here" check; the gate on the write protects against a race where the cache evicts and reloads the schema between the barrier and the write.
+
+---
+
+## 2. Schema update with dependent writes
+
+**Problem:** A service updates a measure's tag list and then writes data points that use the new tag. Until propagation completes, writes that include the new tag should be held or rejected, not silently dropped.
+
+**Tools:** Update response → new `mod_revision` → `AwaitRevisionApplied` → gated writes with retry on `STATUS_SCHEMA_NOT_APPLIED`.
+
+```go
+updateResp, err := measureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+ Measure: updatedSpec,
+})
+if err != nil { return err }
+newRev := updateResp.GetModRevision()
+
+// Best-effort barrier; the gate is the source of truth.
+_, _ = barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: newRev,
+ Timeout: durationpb.New(5 * time.Second),
+})
+
+for _, dp := range pendingPoints {
+ status := writeOne(ctx, dp, newRev)
+ switch status {
+ case modelv1.Status_STATUS_SUCCEED:
+ // ok
+ case modelv1.Status_STATUS_SCHEMA_NOT_APPLIED:
+ // Cache caught up while the call was held but did not converge in budget.
+ // Sleep briefly or call AwaitRevisionApplied again, then retry.
+ retry(dp, newRev)
+ case modelv1.Status_STATUS_EXPIRED_SCHEMA:
+ // The schema has already advanced past newRev. Re-read the spec.
+ return refreshAndRetry()
+ }
+}
+```
+
+**Pitfall:** A retry that reuses `newRev` after another concurrent update returns `STATUS_EXPIRED_SCHEMA`. Always reload the spec (or the latest `mod_revision`) before retrying past one cycle.
+
+---
+
+## 3. Delete-then-recreate (idempotent shape)
+
+**Problem:** A reconciliation loop deletes a measure and recreates it with the same shape. The recreate must happen after the tombstone has propagated, and must satisfy the tombstone invariant.
+
+**Tools:** Delete response → `delete_time` → `AwaitSchemaDeleted` → Create with `updated_at > delete_time`.
+
+```go
+delResp, err := measureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: meta,
+})
+if err != nil { return err }
+delTime := delResp.GetDeleteTime() // Unix nanoseconds
+
+if _, err := barrier.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+ Keys: []*schemav1.SchemaKey{{Kind: "measure", Group: meta.Group, Name: meta.Name}},
+ Timeout: durationpb.New(10 * time.Second),
+}); err != nil { return err }
+
+// Re-stamp UpdatedAt strictly after the tombstone's delete_time.
+freshSpec := proto.Clone(originalSpec).(*databasev1.Measure)
+freshSpec.UpdatedAt = timestamppb.Now() // wall clock is past delTime by construction
+
+if _, err := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: freshSpec,
+}); err != nil { return err }
+```
+
+**Pitfall:** Reusing `originalSpec` without re-stamping `UpdatedAt` can fail with `InvalidArgument: updated_at_before_tombstone` if the captured timestamp predates the delete.
+
+---
+
+## 4. Shape-break (incompatible recreate)
+
+**Problem:** A measure's shape needs to change in a way that is not a backward-compatible update (e.g., dropping an entity tag). The standard pattern is delete + recreate with the new shape, then resume writes against the new shape.
+
+**Tools:** Same as scenario 3, plus the time-range clamp and the write gate to harden the cutover.
+
+```go
+// 1. Tear down the old shape.
+delResp, _ := measureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{Metadata: meta})
+_, _ = barrier.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+ Keys: []*schemav1.SchemaKey{{Kind: "measure", Group: meta.Group, Name: meta.Name}},
+ Timeout: durationpb.New(10 * time.Second),
+})
+
+// 2. Stand up the new shape.
+freshSpec.UpdatedAt = timestamppb.Now()
+createResp, _ := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{Measure: freshSpec})
+newRev := createResp.GetModRevision()
+_, _ = barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: newRev,
+ Timeout: durationpb.New(10 * time.Second),
+})
+
+// 3. Resume writes — the gate will reject any in-flight write that still carries the old revision.
+writeNewShape(ctx, newRev)
+
+// 4. Queries before the recreate's created_at return empty automatically (clamp);
+// queries after see only new-shape data.
+queryResp, _ := measureClient.Query(ctx, &measurev1.QueryRequest{
+ Groups: []string{meta.Group},
+ Name: meta.Name,
+ GroupModRevisions: map[string]int64{meta.Group: newRev},
+ TimeRange: trange, // start may predate the recreate; the server clamps to created_at
+ ...
+})
+```
+
+The integration suite §6.8 (`shape-break: delete+apply new shape creates the new measure`) is the canonical regression for this pattern.
+
+**Pitfall:** Issuing queries without `group_mod_revisions` opts out of the clamp. After a shape-break, ungated queries can return mixed-shape elements until TTL eventually expires the old data. Either gate the query or wait for TTL.
+
+---
+
+## 5. Multi-group cross-cutting query
+
+**Problem:** A dashboard or batch pipeline reads from multiple groups in one `QueryRequest`. Each group has its own independent `mod_revision`, and a stale revision on one group should not block or corrupt the rest.
+
+**Tools:** Per-group `group_mod_revisions` map + per-group `group_statuses` inspection.
+
+```go
+revs := map[string]int64{
+ "sw_metric": latestRev("sw_metric"),
+ "sw_metric_minute": latestRev("sw_metric_minute"),
+}
+
+resp, err := measureClient.Query(ctx, &measurev1.QueryRequest{
+ Groups: []string{"sw_metric", "sw_metric_minute"},
+ Name: "service_resp_time",
+ GroupModRevisions: revs,
+ TimeRange: trange,
+ ...
+})
+if err != nil { return err }
+
+for group, status := range resp.GetGroupStatuses() {
+ switch status {
+ case modelv1.Status_STATUS_SUCCEED:
+ // group contributed normally
+ case modelv1.Status_STATUS_EXPIRED_SCHEMA:
+ log.Printf("group %s schema advanced; refresh and retry", group)
+ case modelv1.Status_STATUS_SCHEMA_NOT_APPLIED:
+ log.Printf("group %s lagging propagation", group)
+ }
+}
+```
+
+### Partial coverage
+
+Omit a group from the map to leave it ungated. Useful when one group is known stable but another is being actively rolled out:
+
+```go
+GroupModRevisions: map[string]int64{
+ "sw_metric": rev1,
+ // "sw_metric_minute" intentionally absent — caller does not gate it
+},
+```
+
+The §4.5.4 and §4.5.5 integration tests in `test/cases/schema/query_gate.go` exercise the mixed-status and partial-coverage variants.
+
+**Pitfall:** Building `group_mod_revisions` from a single global watermark forces every group to wait for the slowest. Either populate per-group revisions or omit the slow group from the map entirely.
+
+---
+
+## 6. Bulk schema bootstrap
+
+**Problem:** A migration creates many schemas at startup (dozens to thousands of measures across multiple groups). The caller wants to know when everything is visible before opening data ingest.
+
+**Tools:** Many Creates → collect max revision → one `AwaitRevisionApplied(maxRev)` → optional `AwaitSchemaApplied` for per-key confirmation.
+
+```go
+var maxRev int64
+keys := make([]*schemav1.SchemaKey, 0, len(specs))
+revs := make([]int64, 0, len(specs))
+
+for _, m := range specs {
+ resp, err := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{Measure: m})
+ if err != nil { return err }
+ r := resp.GetModRevision()
+ if r > maxRev { maxRev = r }
+ keys = append(keys, &schemav1.SchemaKey{Kind: "measure", Group: m.Metadata.Group, Name: m.Metadata.Name})
+ revs = append(revs, r)
+}
+
+// Coarse check: all schemas visible at any revision >= maxRev.
+if _, err := barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: maxRev,
+ Timeout: durationpb.New(30 * time.Second),
+}); err != nil { return err }
+
+// Optional fine check: each key at its own target revision (chunk if > 10000).
+for _, batch := range chunk(keys, revs, 10000) {
+ resp, err := barrier.AwaitSchemaApplied(ctx, &schemav1.AwaitSchemaAppliedRequest{
+ Keys: batch.keys,
+ MinRevisions: batch.revs,
+ Timeout: durationpb.New(30 * time.Second),
+ })
+ if err != nil { return err }
+ if !resp.GetApplied() {
+ return fmt.Errorf("bulk bootstrap failed; %d laggards", len(resp.GetLaggards()))
+ }
+}
+
+// Open ingest.
+```
+
+**Pitfall:** Calling `AwaitSchemaApplied` with more than 10 000 keys returns `InvalidArgument`. Chunk the keys; the server is intentionally bounded.
+
+---
+
+## Migration appendix
+
+### Existing clients on `mod_revision = 0`
+
+Clients that do not set `Metadata.mod_revision` (because they predate v0.11.0 or because their workload does not need the gate) keep working unchanged. Every gate is a no-op when the client value is zero.
+
+### `STATUS_SCHEMA_NOT_APPLIED` enum value
+
+This is a new enum value (code 10). Strict-unmarshal clients — those that throw on unknown enum values rather than treating them as the default — must be upgraded before deploying against a v0.11.0 server.
+
+For the **Java OAP client** specifically: during the migration window, treat `STATUS_SCHEMA_NOT_APPLIED` the same as `STATUS_EXPIRED_SCHEMA` (refresh schema and retry). Once the client-side enum is updated, the two cases can be split and handled distinctly.
+
+### Tombstone retention
+
+The default tombstone retention is **7 days**, configurable via `--schema-server-tombstone-retention`. Existing tombstones become eligible for GC after `tombstone_retention` elapses from their `delete_time`. Operators on resource-constrained nodes can tune the value; the floor is 1 hour.
+
+A re-create attempted after the tombstone is GC'd is no longer subject to the `updated_at_before_tombstone` invariant — there is no tombstone to compare against. This is the expected behavior; the invariant exists to prevent replays during the retention window, not as a permanent ordering check.
+
+### Time-range clamp on pre-v0.11.0 schemas
+
+Schemas created before v0.11.0 have `created_at == nil`. The clamp is a no-op for those schemas — queries return their full configured time range as before. Once such a schema is updated, `created_at` is **not** retroactively populated; only fresh Creates stamp it.
diff --git a/docs/menu.yml b/docs/menu.yml
index caaf53e16..837de71f7 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -107,6 +107,16 @@ catalog:
path: "/interacting/client"
- name: "Data Lifecycle"
path: "/interacting/data-lifecycle"
+ - name: "Schema Consistency"
+ catalog:
+ - name: "Overview"
+ path: "/interacting/schema-consistency/overview"
+ - name: "Barrier RPCs"
+ path: "/interacting/schema-consistency/barriers"
+ - name: "Revision Gates"
+ path: "/interacting/schema-consistency/revision-gates"
+ - name: "Scenarios"
+ path: "/interacting/schema-consistency/scenarios"
- name: "BydbQL"
path: "/interacting/bydbql"
- name: "Operation and Maintenance"
diff --git a/pkg/meter/native/provider.go b/pkg/meter/native/provider.go
index 236ba2d03..9ac3267ca 100644
--- a/pkg/meter/native/provider.go
+++ b/pkg/meter/native/provider.go
@@ -162,7 +162,8 @@ func (p *provider) createNativeObservabilityGroup(ctx context.Context) error {
},
},
}
- return p.metadata.GroupRegistry().CreateGroup(ctx, g)
+ _, createErr := p.metadata.GroupRegistry().CreateGroup(ctx, g)
+ return createErr
}
//nolint:unparam
diff --git a/pkg/schema/cache.go b/pkg/schema/cache.go
index 5d2c28400..ec45cda19 100644
--- a/pkg/schema/cache.go
+++ b/pkg/schema/cache.go
@@ -39,7 +39,10 @@ import (
"github.com/apache/skywalking-banyandb/pkg/run"
)
-var _ Resource = (*resourceSpec)(nil)
+var (
+ _ Resource = (*resourceSpec)(nil)
+ _ RevisionRepository = (*schemaRepo)(nil)
+)
type resourceSpec struct {
schema ResourceSchema
@@ -87,22 +90,101 @@ type schemaRepo struct {
indexRuleMap sync.Map
bindingForwardMap sync.Map
bindingBackwardMap sync.Map
+ latestModRevision atomic.Int64
workerNum int
resourceMutex sync.Mutex
groupMux sync.Mutex
}
+// SendMetadataEvent applies the given event synchronously. When the downstream
+// caches (e.g. pkg/schema.schemaRepo.groupMap and resourceMap) must be coherent
+// before the caller returns — for example, when SchemaBarrierService delegates
+// through notifyHandlers → this handler → SendMetadataEvent — the caller relies
+// on the event being fully applied here. Only transient errors queue the event
+// for retry via the background worker; the success path does not touch the
+// channel.
func (sr *schemaRepo) SendMetadataEvent(event MetadataEvent) {
if !sr.closer.AddSender() {
return
}
defer sr.closer.SenderDone()
- select {
- case sr.eventCh <- event:
- case <-sr.closer.CloseNotify():
+ if err := sr.processEvent(event); err != nil && !errors.Is(err, schema.ErrClosed) {
+ select {
+ case <-sr.closer.CloseNotify():
+ return
+ default:
+ }
+ sr.l.Err(err).Interface("event", event).Msg("fail to handle the metadata event. retry...")
+ sr.metrics.totalErrs.Inc(1)
+ select {
+ case sr.eventCh <- event:
+ case <-sr.closer.CloseNotify():
+ }
}
}
+// processEvent applies the event in-place, bumping latestModRevision on success
+// so the barrier's downstream-handler watermark catches up before notifyHandlers
+// returns to its caller.
+func (sr *schemaRepo) processEvent(evt MetadataEvent) error {
+ if e := sr.l.Debug(); e.Enabled() {
+ e.Interface("event", evt).Msg("received an event")
+ }
+ var err error
+ switch evt.Typ {
+ case EventAddOrUpdate:
+ switch evt.Kind {
+ case EventKindGroup:
+ _, err = sr.storeGroup(evt.Metadata.GetMetadata())
+ if errors.Is(err, schema.ErrGRPCResourceNotFound) {
+ err = nil
+ }
+ case EventKindResource:
+ err = sr.storeResource(evt.Metadata)
+ case EventKindIndexRule:
+ indexRule := evt.Metadata.(*databasev1.IndexRule)
+ sr.storeIndexRule(indexRule)
+ case EventKindIndexRuleBinding:
+ indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
+ sr.storeIndexRuleBinding(indexRuleBinding)
+ }
+ case EventDelete:
+ switch evt.Kind {
+ case EventKindGroup:
+ err = sr.deleteGroup(evt.Metadata.GetMetadata())
+ case EventKindResource:
+ sr.deleteResource(evt)
+ case EventKindIndexRule:
+ key := getKey(evt.Metadata.GetMetadata())
+ sr.indexRuleMap.Delete(key)
+ case EventKindIndexRuleBinding:
+ indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
+ col, _ := sr.bindingForwardMap.Load(getKey(&commonv1.Metadata{
+ Name: indexRuleBinding.Subject.GetName(),
+ Group: indexRuleBinding.GetMetadata().GetGroup(),
+ }))
+ if col == nil {
+ break
+ }
+ tMap := col.(*sync.Map)
+ key := getKey(indexRuleBinding.GetMetadata())
+ tMap.Delete(key)
+ for i := range indexRuleBinding.Rules {
+ col, _ := sr.bindingBackwardMap.Load(getKey(&commonv1.Metadata{
+ Name: indexRuleBinding.Rules[i],
+ Group: indexRuleBinding.GetMetadata().GetGroup(),
+ }))
+ if col == nil {
+ continue
+ }
+ tMap := col.(*sync.Map)
+ tMap.Delete(key)
+ }
+ }
+ }
+ return err
+}
+
// StopCh implements Repository.
func (sr *schemaRepo) StopCh() <-chan struct{} {
return sr.closer.CloseNotify()
@@ -160,84 +242,25 @@ func (sr *schemaRepo) Watcher() {
}
sr.metrics.totalPanics.Inc(1)
}()
+ // The Watcher drains events retried via the channel after transient
+ // processing errors. The fast path runs synchronously in SendMetadataEvent
+ // so the barrier's downstream-handler watermark advances correctly.
for {
select {
case evt, more := <-sr.eventCh:
if !more {
return
}
- if e := sr.l.Debug(); e.Enabled() {
- e.Interface("event", evt).Msg("received an event")
- }
- var err error
- switch evt.Typ {
- case EventAddOrUpdate:
- switch evt.Kind {
- case EventKindGroup:
- _, err = sr.storeGroup(evt.Metadata.GetMetadata())
- if errors.As(err, schema.ErrGRPCResourceNotFound) {
- err = nil
- }
- case EventKindResource:
- err = sr.storeResource(evt.Metadata)
- case EventKindIndexRule:
- indexRule := evt.Metadata.(*databasev1.IndexRule)
- if indexRule.GetMetadata().GetGroup() == "test-trace-group" {
- sr.l.Info().Str("group", indexRule.GetMetadata().GetGroup()).Msg("index rule")
- }
- sr.storeIndexRule(indexRule)
- case EventKindIndexRuleBinding:
- indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
- if indexRuleBinding.GetMetadata().GetGroup() == "test-trace-group" {
- sr.l.Info().Str("group", indexRuleBinding.GetMetadata().GetGroup()).Msg("index rule binding")
- }
- sr.storeIndexRuleBinding(indexRuleBinding)
- }
- case EventDelete:
- switch evt.Kind {
- case EventKindGroup:
- err = sr.deleteGroup(evt.Metadata.GetMetadata())
- case EventKindResource:
- sr.deleteResource(evt.Metadata.GetMetadata())
- case EventKindIndexRule:
- key := getKey(evt.Metadata.GetMetadata())
- sr.indexRuleMap.Delete(key)
- case EventKindIndexRuleBinding:
- indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
- col, _ := sr.bindingForwardMap.Load(getKey(&commonv1.Metadata{
- Name: indexRuleBinding.Subject.GetName(),
- Group: indexRuleBinding.GetMetadata().GetGroup(),
- }))
- if col == nil {
- break
- }
- tMap := col.(*sync.Map)
- key := getKey(indexRuleBinding.GetMetadata())
- tMap.Delete(key)
- for i := range indexRuleBinding.Rules {
- col, _ := sr.bindingBackwardMap.Load(getKey(&commonv1.Metadata{
- Name: indexRuleBinding.Rules[i],
- Group: indexRuleBinding.GetMetadata().GetGroup(),
- }))
- if col == nil {
- continue
- }
- tMap := col.(*sync.Map)
- tMap.Delete(key)
- }
- }
- }
- if err != nil && !errors.Is(err, schema.ErrClosed) {
+ if retryErr := sr.processEvent(evt); retryErr != nil && !errors.Is(retryErr, schema.ErrClosed) {
select {
case <-sr.closer.CloseNotify():
return
default:
}
- sr.l.Err(err).Interface("event", evt).Msg("fail to handle the metadata event. retry...")
- sr.metrics.totalErrs.Inc(1)
+ sr.l.Err(retryErr).Interface("event", evt).Msg("retry processing failed, requeueing")
+ sr.metrics.totalRetries.Inc(1)
go func() {
sr.SendMetadataEvent(evt)
- sr.metrics.totalRetries.Inc(1)
}()
}
case <-sr.closer.CloseNotify():
@@ -259,12 +282,18 @@ func (sr *schemaRepo) storeGroup(groupMeta *commonv1.Metadata) (*group, error) {
if err := g.init(name); err != nil {
return nil, err
}
+ if gs := g.GetSchema(); gs != nil {
+ sr.updateLatestModRevision(gs.GetMetadata().GetModRevision())
+ }
return g, nil
}
if !g.isInit() {
if err := g.init(name); err != nil {
return nil, err
}
+ if gs := g.GetSchema(); gs != nil {
+ sr.updateLatestModRevision(gs.GetMetadata().GetModRevision())
+ }
return g, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -278,6 +307,7 @@ func (sr *schemaRepo) storeGroup(groupMeta *commonv1.Metadata) (*group, error) {
return g, nil
}
g.groupSchema.Store(groupSchema)
+ sr.updateLatestModRevision(groupSchema.GetMetadata().GetModRevision())
if proto.Equal(groupSchema, prevGroupSchema) {
return g, nil
}
@@ -365,6 +395,7 @@ func (sr *schemaRepo) storeResource(resourceSchema ResourceSchema) error {
sm.OnIndexUpdate(sr.indexRules(resourceSchema))
resource.delegated = sm
sr.resourceMap.Store(key, resource)
+ sr.updateLatestModRevision(resourceSchema.GetMetadata().GetModRevision())
return nil
}
@@ -373,6 +404,7 @@ func (sr *schemaRepo) storeIndexRule(indexRule *databasev1.IndexRule) {
if prev, loaded := sr.indexRuleMap.LoadOrStore(key, indexRule); loaded {
if prev.(*databasev1.IndexRule).GetMetadata().ModRevision <= indexRule.GetMetadata().ModRevision {
sr.indexRuleMap.Store(key, indexRule)
+ sr.updateLatestModRevision(indexRule.GetMetadata().GetModRevision())
if col, _ := sr.bindingBackwardMap.Load(key); col != nil {
col.(*sync.Map).Range(func(_, value any) bool {
sr.updateIndex(value.(*databasev1.IndexRuleBinding))
@@ -381,6 +413,7 @@ func (sr *schemaRepo) storeIndexRule(indexRule *databasev1.IndexRule) {
}
}
} else {
+ sr.updateLatestModRevision(indexRule.GetMetadata().GetModRevision())
if col, _ := sr.bindingBackwardMap.Load(key); col != nil {
col.(*sync.Map).Range(func(_, value any) bool {
sr.updateIndex(value.(*databasev1.IndexRuleBinding))
@@ -425,6 +458,7 @@ func (sr *schemaRepo) storeIndexRuleBinding(indexRuleBinding *databasev1.IndexRu
if !changed {
return
}
+ sr.updateLatestModRevision(indexRuleBinding.GetMetadata().GetModRevision())
sr.updateIndex(indexRuleBinding)
}
@@ -468,11 +502,74 @@ func getKey(metadata *commonv1.Metadata) string {
return path.Join(metadata.GetGroup(), metadata.GetName())
}
-func (sr *schemaRepo) deleteResource(metadata *commonv1.Metadata) {
- key := getKey(metadata)
+func (sr *schemaRepo) deleteResource(evt MetadataEvent) {
+ key := getKey(evt.Metadata.GetMetadata())
+ // Hold resourceMutex across the load/check/delete to keep the revision-guard atomic.
+ // storeResource takes the same lock, so a concurrent newer store cannot interleave
+ // between the staleness check and LoadAndDelete and have its entry wiped.
+ sr.resourceMutex.Lock()
+ defer sr.resourceMutex.Unlock()
+ if evt.DeleteRevision != 0 {
+ if v, ok := sr.resourceMap.Load(key); ok {
+ stored := v.(*resourceSpec)
+ if evt.DeleteRevision < stored.maxRevision() {
+ return
+ }
+ }
+ }
_, _ = sr.resourceMap.LoadAndDelete(key)
}
+func (sr *schemaRepo) updateLatestModRevision(incoming int64) {
+ for {
+ cur := sr.latestModRevision.Load()
+ if incoming <= cur {
+ return
+ }
+ if sr.latestModRevision.CompareAndSwap(cur, incoming) {
+ return
+ }
+ }
+}
+
+// LatestModRevision returns the highest mod_revision seen across all stored kinds.
+func (sr *schemaRepo) LatestModRevision() int64 {
+ return sr.latestModRevision.Load()
+}
+
+// ResourceRevision returns the mod_revision for a stored resource and whether it was found.
+func (sr *schemaRepo) ResourceRevision(kind schema.Kind, groupName, name string) (int64, bool) {
+ key := path.Join(groupName, name)
+ switch kind {
+ case schema.KindStream, schema.KindMeasure, schema.KindTrace:
+ if v, ok := sr.resourceMap.Load(key); ok {
+ return v.(*resourceSpec).maxRevision(), true
+ }
+ case schema.KindIndexRule:
+ if v, ok := sr.indexRuleMap.Load(key); ok {
+ return v.(*databasev1.IndexRule).GetMetadata().GetModRevision(), true
+ }
+ case schema.KindGroup:
+ if v, ok := sr.groupMap.Load(name); ok {
+ grp := v.(*group)
+ if gs := grp.GetSchema(); gs != nil {
+ return gs.GetMetadata().GetModRevision(), true
+ }
+ }
+ case schema.KindIndexRuleBinding, schema.KindTopNAggregation,
+ schema.KindNode, schema.KindProperty, schema.KindMask:
+ // schemaRepo only caches resources, index rules, and groups; other kinds
+ // (bindings, top-n aggregations, nodes, properties, masks) have no entry here.
+ }
+ return 0, false
+}
+
+// IsAbsent returns true when the given resource is not present in the local cache.
+func (sr *schemaRepo) IsAbsent(kind schema.Kind, groupName, name string) bool {
+ _, ok := sr.ResourceRevision(kind, groupName, name)
+ return !ok
+}
+
func (sr *schemaRepo) Close() {
defer func() {
if err := recover(); err != nil {
diff --git a/pkg/schema/cache_test.go b/pkg/schema/cache_test.go
new file mode 100644
index 000000000..d524e238b
--- /dev/null
+++ b/pkg/schema/cache_test.go
@@ -0,0 +1,170 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ "github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+)
+
+// noopIndexListener satisfies IndexListener and discards all index updates.
+type noopIndexListener struct{}
+
+func (noopIndexListener) OnIndexUpdate(_ []*databasev1.IndexRule) {}
+
+// stubSupplier satisfies ResourceSchemaSupplier for tests that only need
+// storeResource to succeed without real DB or metadata interactions.
+type stubSupplier struct{}
+
+func (stubSupplier) ResourceSchema(_ *commonv1.Metadata) (ResourceSchema, error) {
+ return nil, nil
+}
+
+func (stubSupplier) OpenResource(_ Resource) (IndexListener, error) {
+ return noopIndexListener{}, nil
+}
+
+// newTestSchemaRepo creates a minimal schemaRepo suitable for unit tests.
+// The zero values of all sync.Map and atomic fields are ready to use.
+func newTestSchemaRepo() *schemaRepo {
+ return &schemaRepo{resourceSchemaSupplier: stubSupplier{}}
+}
+
+// buildMeasure returns a *databasev1.Measure with the specified name and mod_revision.
+// Group is fixed to "g" because every test in this file uses the same group; varying
+// it would only add noise without changing what's being asserted.
+func buildMeasure(name string, modRevision int64) *databasev1.Measure {
+ return &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Group: "g", Name: name, ModRevision: modRevision},
+ }
+}
+
+// TestDeleteResource_RevisionGuard_RejectsStaleDelete verifies that an EventDelete
+// carrying a DeleteRevision lower than the stored resource's mod_revision is ignored,
+// leaving the cache entry intact (A7).
+func TestDeleteResource_RevisionGuard_RejectsStaleDelete(t *testing.T) {
+ sr := newTestSchemaRepo()
+ require.NoError(t, sr.storeResource(buildMeasure("m", 100)))
+
+ sr.deleteResource(MetadataEvent{
+ Metadata: buildMeasure("m", 100),
+ DeleteRevision: 50,
+ Typ: EventDelete,
+ Kind: EventKindResource,
+ })
+
+ _, present := sr.resourceMap.Load("g/m")
+ assert.True(t, present, "stale delete (DeleteRevision=50 < stored rev=100) must not remove the entry")
+}
+
+// TestDeleteResource_RevisionGuard_AcceptsFreshDelete verifies that an EventDelete
+// carrying a DeleteRevision greater than the stored resource's mod_revision removes
+// the cache entry (A7).
+func TestDeleteResource_RevisionGuard_AcceptsFreshDelete(t *testing.T) {
+ sr := newTestSchemaRepo()
+ require.NoError(t, sr.storeResource(buildMeasure("m", 100)))
+
+ sr.deleteResource(MetadataEvent{
+ Metadata: buildMeasure("m", 100),
+ DeleteRevision: 150,
+ Typ: EventDelete,
+ Kind: EventKindResource,
+ })
+
+ _, present := sr.resourceMap.Load("g/m")
+ assert.False(t, present, "fresh delete (DeleteRevision=150 >= stored rev=100) must remove the entry")
+}
+
+// TestLatestModRevision_Monotonic verifies that LatestModRevision advances monotonically
+// with ascending stores and does not regress when a lower revision is applied (A8).
+func TestLatestModRevision_Monotonic(t *testing.T) {
+ sr := newTestSchemaRepo()
+ assert.Equal(t, int64(0), sr.LatestModRevision(), "watermark must start at zero")
+
+ require.NoError(t, sr.storeResource(buildMeasure("m1", 10)))
+ assert.Equal(t, int64(10), sr.LatestModRevision())
+
+ require.NoError(t, sr.storeResource(buildMeasure("m2", 30)))
+ assert.Equal(t, int64(30), sr.LatestModRevision())
+
+ // A new key at a lower revision must not regress the watermark.
+ require.NoError(t, sr.storeResource(buildMeasure("m3", 20)))
+ assert.Equal(t, int64(30), sr.LatestModRevision(), "stale store (rev=20) must not lower the watermark from 30")
+}
+
+// TestLatestModRevision_AcrossKinds verifies that LatestModRevision tracks the global
+// maximum across Resource, IndexRule, and IndexRuleBinding stores (A8).
+func TestLatestModRevision_AcrossKinds(t *testing.T) {
+ sr := newTestSchemaRepo()
+
+ require.NoError(t, sr.storeResource(buildMeasure("m1", 10)))
+ assert.Equal(t, int64(10), sr.LatestModRevision())
+
+ sr.storeIndexRule(&databasev1.IndexRule{
+ Metadata: &commonv1.Metadata{Group: "g", Name: "idx1", ModRevision: 50},
+ Tags: []string{"tag1"},
+ Type: databasev1.IndexRule_TYPE_INVERTED,
+ })
+ assert.Equal(t, int64(50), sr.LatestModRevision(), "IndexRule store (rev=50) must advance the watermark")
+
+ sr.storeIndexRuleBinding(&databasev1.IndexRuleBinding{
+ Metadata: &commonv1.Metadata{Group: "g", Name: "irb1", ModRevision: 40},
+ Rules: []string{"idx1"},
+ Subject: &databasev1.Subject{Catalog: commonv1.Catalog_CATALOG_MEASURE, Name: "m1"},
+ })
+ assert.Equal(t, int64(50), sr.LatestModRevision(), "IndexRuleBinding at rev=40 must not lower the watermark from 50")
+}
+
+// TestResourceRevision_PresentAndAbsent verifies that ResourceRevision returns the
+// stored mod_revision and ok=true for a present resource, and (0, false) for an absent one (A8).
+func TestResourceRevision_PresentAndAbsent(t *testing.T) {
+ sr := newTestSchemaRepo()
+ require.NoError(t, sr.storeResource(buildMeasure("m", 77)))
+
+ rev, ok := sr.ResourceRevision(schema.KindMeasure, "g", "m")
+ assert.True(t, ok)
+ assert.Equal(t, int64(77), rev)
+
+ rev, ok = sr.ResourceRevision(schema.KindMeasure, "g", "missing")
+ assert.False(t, ok)
+ assert.Equal(t, int64(0), rev)
+}
+
+// TestIsAbsent_AfterDelete verifies that IsAbsent returns false while the resource
+// is cached and true after a valid delete removes it from the cache (A7, A8).
+func TestIsAbsent_AfterDelete(t *testing.T) {
+ sr := newTestSchemaRepo()
+ require.NoError(t, sr.storeResource(buildMeasure("m", 100)))
+
+ assert.False(t, sr.IsAbsent(schema.KindMeasure, "g", "m"), "resource must not be absent immediately after store")
+
+ sr.deleteResource(MetadataEvent{
+ Metadata: buildMeasure("m", 100),
+ DeleteRevision: 100,
+ Typ: EventDelete,
+ Kind: EventKindResource,
+ })
+
+ assert.True(t, sr.IsAbsent(schema.KindMeasure, "g", "m"), "resource must be absent after a valid delete")
+}
diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go
index e99bcce9c..13625a7c0 100644
--- a/pkg/schema/schema.go
+++ b/pkg/schema/schema.go
@@ -55,9 +55,10 @@ type Group interface {
// MetadataEvent is the syncing message between metadata repo and this framework.
type MetadataEvent struct {
- Metadata ResourceSchema
- Typ EventType
- Kind EventKind
+ Metadata ResourceSchema
+ DeleteRevision int64 // non-zero only for EventDelete; carries the tombstone mod_revision
+ Typ EventType
+ Kind EventKind
}
// ResourceSchema allows get the metadata.
@@ -112,3 +113,12 @@ type Repository interface {
StopCh() <-chan struct{}
DropGroup(groupName string) error
}
+
+// RevisionRepository extends Repository with revision-query methods used by
+// the write/query gates and the barrier implementation.
+type RevisionRepository interface {
+ Repository
+ LatestModRevision() int64
+ ResourceRevision(kind schema.Kind, group, name string) (int64, bool)
+ IsAbsent(kind schema.Kind, group, name string) bool
+}
diff --git a/pkg/test/helpers/constant.go b/pkg/test/helpers/constant.go
index cd4aea32f..14d1b60c0 100644
--- a/pkg/test/helpers/constant.go
+++ b/pkg/test/helpers/constant.go
@@ -33,8 +33,9 @@ import (
// SharedContext is the context shared between test cases in the integration testing.
type SharedContext struct {
- Connection *grpclib.ClientConn
BaseTime time.Time
+ Connection *grpclib.ClientConn
+ Mode string
}
// Args is a wrapper seals all necessary info for table specs.
diff --git a/pkg/test/measure/schema.go b/pkg/test/measure/schema.go
index cedd2e458..573e15ea7 100644
--- a/pkg/test/measure/schema.go
+++ b/pkg/test/measure/schema.go
@@ -66,17 +66,20 @@ func PreloadResourcesOnly(ctx context.Context, e schema.Registry) error {
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
- return e.CreateIndexRule(ctx, indexRule)
+ _, innerErr := e.CreateIndexRule(ctx, indexRule)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
- return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ _, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(topNAggregationDir, &databasev1.TopNAggregation{}, func(topN *databasev1.TopNAggregation) error {
- return e.CreateTopNAggregation(ctx, topN)
+ _, innerErr := e.CreateTopNAggregation(ctx, topN)
+ return innerErr
})
},
)
@@ -87,7 +90,8 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, groupDirectory strin
return preloadSchemaWithFuncs(ctx, e,
func(ctx context.Context, e schema.Registry) error {
return loadSchema(groupDirectory, &commonv1.Group{}, func(group *commonv1.Group) error {
- return e.CreateGroup(ctx, group)
+ _, innerErr := e.CreateGroup(ctx, group)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
@@ -98,17 +102,20 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, groupDirectory strin
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
- return e.CreateIndexRule(ctx, indexRule)
+ _, innerErr := e.CreateIndexRule(ctx, indexRule)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
- return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ _, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(topNAggregationDir, &databasev1.TopNAggregation{}, func(topN *databasev1.TopNAggregation) error {
- return e.CreateTopNAggregation(ctx, topN)
+ _, innerErr := e.CreateTopNAggregation(ctx, topN)
+ return innerErr
})
},
)
diff --git a/pkg/test/property/schema.go b/pkg/test/property/schema.go
index c6bbfa35c..d3538ee68 100644
--- a/pkg/test/property/schema.go
+++ b/pkg/test/property/schema.go
@@ -88,7 +88,7 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
logger.Infof("group %s already exists", g.Metadata.Name)
return nil
}
- if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+ if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
return innerErr
}
}
diff --git a/pkg/test/replicated/measure/etcd.go b/pkg/test/replicated/measure/etcd.go
index 506ae7bee..b6331fa05 100644
--- a/pkg/test/replicated/measure/etcd.go
+++ b/pkg/test/replicated/measure/etcd.go
@@ -45,7 +45,8 @@ var store embed.FS
// PreloadSchema loads schemas from files in the booting process.
func PreloadSchema(ctx context.Context, e schema.Registry) error {
if err := loadSchema(groupDir, &commonv1.Group{}, func(group *commonv1.Group) error {
- return e.CreateGroup(ctx, group)
+ _, innerErr := e.CreateGroup(ctx, group)
+ return innerErr
}); err != nil {
return errors.WithStack(err)
}
@@ -53,7 +54,8 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
return errors.WithStack(loadErr)
}
return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(irb *databasev1.IndexRuleBinding) error {
- return e.CreateIndexRuleBinding(ctx, irb)
+ _, innerErr := e.CreateIndexRuleBinding(ctx, irb)
+ return innerErr
})
}
diff --git a/pkg/test/replicated/stream/etcd.go b/pkg/test/replicated/stream/etcd.go
index 8da2e4feb..14ff1375d 100644
--- a/pkg/test/replicated/stream/etcd.go
+++ b/pkg/test/replicated/stream/etcd.go
@@ -58,7 +58,7 @@ func LoadSchemaWithStages(ctx context.Context, e schema.Registry) error {
logger.Infof("group %s already exists", g.Metadata.Name)
return nil
}
- if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+ if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
return innerErr
}
}
@@ -85,7 +85,7 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
logger.Infof("group %s already exists", g.Metadata.Name)
return nil
}
- if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+ if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
return innerErr
}
}
diff --git a/pkg/test/replicated/trace/etcd.go b/pkg/test/replicated/trace/etcd.go
index b528fe7dd..fe25149a7 100644
--- a/pkg/test/replicated/trace/etcd.go
+++ b/pkg/test/replicated/trace/etcd.go
@@ -41,7 +41,8 @@ var store embed.FS
// PreloadSchema loads schemas from files in the booting process.
func PreloadSchema(ctx context.Context, e schema.Registry) error {
if err := loadSchema(groupDir, &commonv1.Group{}, func(group *commonv1.Group) error {
- return e.CreateGroup(ctx, group)
+ _, createErr := e.CreateGroup(ctx, group)
+ return createErr
}); err != nil {
return errors.WithStack(err)
}
diff --git a/pkg/test/setup/watch_control.go b/pkg/test/setup/watch_control.go
new file mode 100644
index 000000000..69e2b60f8
--- /dev/null
+++ b/pkg/test/setup/watch_control.go
@@ -0,0 +1,42 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package setup
+
+import "errors"
+
+// ErrWatchControlNotImplemented is returned by PauseDataNodeWatch and
+// ResumeDataNodeWatch until the data-node watch control hooks land in
+// Phase 2 (Step 2.8). Cluster-only specs in §6.12 of the schema-consistency
+// plan must skip themselves when this sentinel surfaces.
+var ErrWatchControlNotImplemented = errors.New("watch control not implemented: landed in Phase 2")
+
+// PauseDataNodeWatch suspends the named data node's schema watch loop so a
+// test can observe behavior while the node is missing schema events. It is a
+// distributed-only hook; the standalone harness has no separate watch path.
+// Phase 2 will provide a real implementation; until then it returns
+// ErrWatchControlNotImplemented and callers should skip.
+func PauseDataNodeWatch(_ string) error {
+ return ErrWatchControlNotImplemented
+}
+
+// ResumeDataNodeWatch resumes a previously paused data-node watch loop. Same
+// Phase 2 caveat as PauseDataNodeWatch: it currently returns
+// ErrWatchControlNotImplemented so cluster-only specs can self-skip.
+func ResumeDataNodeWatch(_ string) error {
+ return ErrWatchControlNotImplemented
+}
diff --git a/pkg/test/stream/schema.go b/pkg/test/stream/schema.go
index f70c5627a..25e8e82b9 100644
--- a/pkg/test/stream/schema.go
+++ b/pkg/test/stream/schema.go
@@ -94,7 +94,7 @@ func loadSchemas(ctx context.Context, e schema.Registry) error {
if err != nil {
return err
}
- if innerErr := e.CreateIndexRule(ctx, &idxRule); innerErr != nil {
+ if _, innerErr := e.CreateIndexRule(ctx, &idxRule); innerErr != nil {
return innerErr
}
}
@@ -112,7 +112,7 @@ func loadSchemas(ctx context.Context, e schema.Registry) error {
if err != nil {
return err
}
- if innerErr := e.CreateIndexRuleBinding(ctx, &idxRuleBinding); innerErr != nil {
+ if _, innerErr := e.CreateIndexRuleBinding(ctx, &idxRuleBinding); innerErr != nil {
return innerErr
}
}
@@ -139,7 +139,7 @@ func LoadSchemaWithStages(ctx context.Context, e schema.Registry) error {
logger.Infof("group %s already exists", g.Metadata.Name)
return nil
}
- if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+ if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
return innerErr
}
}
@@ -166,7 +166,7 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
logger.Infof("group %s already exists", g.Metadata.Name)
return nil
}
- if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+ if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
return innerErr
}
}
diff --git a/pkg/test/trace/schema.go b/pkg/test/trace/schema.go
index 457272923..e5bb81a3f 100644
--- a/pkg/test/trace/schema.go
+++ b/pkg/test/trace/schema.go
@@ -65,12 +65,14 @@ func PreloadResourcesOnly(ctx context.Context, e schema.Registry) error {
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
- return e.CreateIndexRule(ctx, indexRule)
+ _, innerErr := e.CreateIndexRule(ctx, indexRule)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
- return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ _, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ return innerErr
})
},
)
@@ -81,7 +83,8 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, group string) error
return preloadSchemaWithFuncs(ctx, e,
func(ctx context.Context, e schema.Registry) error {
return loadSchema(group, &commonv1.Group{}, func(group *commonv1.Group) error {
- return e.CreateGroup(ctx, group)
+ _, innerErr := e.CreateGroup(ctx, group)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
@@ -92,12 +95,14 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, group string) error
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
- return e.CreateIndexRule(ctx, indexRule)
+ _, innerErr := e.CreateIndexRule(ctx, indexRule)
+ return innerErr
})
},
func(ctx context.Context, e schema.Registry) error {
return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
- return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ _, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+ return innerErr
})
},
)
diff --git a/test/cases/schema/apply_rollback.go b/test/cases/schema/apply_rollback.go
new file mode 100644
index 000000000..0b975af25
--- /dev/null
+++ b/test/cases/schema/apply_rollback.go
@@ -0,0 +1,525 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// arMeasureGroup creates a CATALOG_MEASURE group suitable for apply-rollback tests.
+func arMeasureGroup(name string) *commonv1.Group {
+ return &commonv1.Group{
+ Metadata: &commonv1.Metadata{Name: name},
+ Catalog: commonv1.Catalog_CATALOG_MEASURE,
+ ResourceOpts: &commonv1.ResourceOpts{
+ ShardNum: 2,
+ SegmentInterval: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 1},
+ Ttl: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 7},
+ },
+ }
+}
+
+// arMeasureSpec returns a minimal Measure with entity=[host] for apply-rollback tests.
+func arMeasureSpec(group, name string) *databasev1.Measure {
+ return &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Name: name, Group: group},
+ Entity: &databasev1.Entity{TagNames: []string{"host"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{
+ {Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }},
+ Fields: []*databasev1.FieldSpec{{
+ Name: "value",
+ FieldType: databasev1.FieldType_FIELD_TYPE_INT,
+ EncodingMethod: databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+ CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+ }},
+ }
+}
+
+// applyRollbackMeasureName is the fixture measure name reused across the §6.1–§6.7 specs.
+const applyRollbackMeasureName = "cpu_total"
+
+// Schema apply-rollback tests — §6.1 through §6.7.
+var _ = g.Describe("Schema apply rollback", func() {
+ var (
+ ctx context.Context
+ clients *Clients
+ )
+
+ g.BeforeEach(func() {
+ ctx = context.Background()
+ clients = NewClients(SharedContext.Connection)
+ })
+
+ // §6.1 — creates a measure visible end-to-end.
+ g.It("creates a measure visible end-to-end (§6.1)", func() {
+ groupName := fmt.Sprintf("ar-vm-%d", time.Now().UnixNano())
+ measureName := applyRollbackMeasureName
+
+ g.By("Creating measure group and awaiting revision")
+ createGroupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+ groupRev := createGroupResp.GetModRevision()
+ gm.Expect(groupRev).Should(gm.BeNumerically(">", int64(0)))
+
+ awaitGroupErr := clients.AwaitRevision(ctx, groupRev, 10*time.Second)
+ gm.Expect(awaitGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure and awaiting revision")
+ createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+ r := createMeasureResp.GetModRevision()
+ gm.Expect(r).Should(gm.BeNumerically(">", int64(0)))
+
+ awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Verifying GetMeasure returns correct metadata")
+ getResp, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr).ShouldNot(gm.HaveOccurred())
+ got := getResp.GetMeasure()
+ gm.Expect(got.GetMetadata().GetModRevision()).Should(gm.Equal(r),
+ "GetMeasure.ModRevision must equal the create response ModRevision")
+ gm.Expect(got.GetCreatedAt()).ShouldNot(gm.BeNil(), "CreatedAt must be set")
+ gm.Expect(got.GetUpdatedAt()).ShouldNot(gm.BeNil(), "UpdatedAt must be set")
+ gm.Expect(got.GetCreatedAt().AsTime().Equal(got.GetUpdatedAt().AsTime())).Should(gm.BeTrue(),
+ "CreatedAt must equal UpdatedAt on first create")
+
+ g.By("Verifying ListMeasure returns exactly one user-created entry")
+ listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+ userOnly := userMeasures(listResp.GetMeasure())
+ gm.Expect(userOnly).Should(gm.HaveLen(1), "ListMeasure must return exactly one user-created entry")
+ gm.Expect(userOnly[0].GetMetadata().GetName()).Should(gm.Equal(measureName))
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.2 — Apply-rollback: a rejected schema update must leave the full schema state intact,
+ // including mod_revision, created_at, updated_at, and the entity definition.
+ g.It("preserves mod_revision, created_at, updated_at, and entity after a rejected entity-change update (§6.2)", func() {
+ groupName := fmt.Sprintf("ar-measure-%d", time.Now().UnixNano())
+ measureName := "ar_measure"
+
+ g.By("Creating measure group")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: &commonv1.Group{
+ Metadata: &commonv1.Metadata{Name: groupName},
+ Catalog: commonv1.Catalog_CATALOG_MEASURE,
+ ResourceOpts: &commonv1.ResourceOpts{
+ ShardNum: 2,
+ SegmentInterval: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 1},
+ Ttl: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 7},
+ },
+ },
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure with entity=[svc]")
+ _, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ Entity: &databasev1.Entity{TagNames: []string{"svc"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{
+ {Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }},
+ Fields: []*databasev1.FieldSpec{{
+ Name: "value",
+ FieldType: databasev1.FieldType_FIELD_TYPE_INT,
+ EncodingMethod: databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+ CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+ }},
+ },
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Getting measure to capture initial state")
+ initialResp, getInitialErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getInitialErr).ShouldNot(gm.HaveOccurred())
+ initial := initialResp.GetMeasure()
+ gm.Expect(initial.GetCreatedAt()).ShouldNot(gm.BeNil())
+ gm.Expect(initial.GetUpdatedAt()).ShouldNot(gm.BeNil())
+ gm.Expect(initial.GetMetadata().GetModRevision()).Should(gm.BeNumerically(">", int64(0)))
+ initialModRev := initial.GetMetadata().GetModRevision()
+ initialCreatedAt := initial.GetCreatedAt().AsTime().UnixNano()
+ initialUpdatedAt := initial.GetUpdatedAt().AsTime().UnixNano()
+
+ g.By("Attempting UpdateMeasure with entity=[svc, host] (entity change that must be rejected)")
+ changedMeasure := proto.Clone(initial).(*databasev1.Measure)
+ changedMeasure.Entity = &databasev1.Entity{TagNames: []string{"svc", "host"}}
+ _, updateErr := clients.MeasureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+ Measure: changedMeasure,
+ })
+ gm.Expect(updateErr).Should(gm.HaveOccurred(), "UpdateMeasure with entity change must return an error")
+
+ g.By("Verifying mod_revision, created_at, updated_at, and entity are all unchanged")
+ afterResp, getAfterErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getAfterErr).ShouldNot(gm.HaveOccurred())
+ after := afterResp.GetMeasure()
+
+ gm.Expect(after.GetMetadata().GetModRevision()).Should(
+ gm.Equal(initialModRev),
+ "mod_revision must be unchanged after a rejected update",
+ )
+ gm.Expect(after.GetCreatedAt()).ShouldNot(gm.BeNil())
+ gm.Expect(after.GetCreatedAt().AsTime().UnixNano()).Should(
+ gm.Equal(initialCreatedAt),
+ "created_at must be unchanged after a rejected update",
+ )
+ gm.Expect(after.GetUpdatedAt()).ShouldNot(gm.BeNil())
+ gm.Expect(after.GetUpdatedAt().AsTime().UnixNano()).Should(
+ gm.Equal(initialUpdatedAt),
+ "updated_at must be unchanged after a rejected update",
+ )
+ gm.Expect(after.GetEntity().GetTagNames()).Should(
+ gm.Equal([]string{"svc"}),
+ "entity must be unchanged after a rejected update",
+ )
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.3 — lists groups and measures after create.
+ g.It("lists groups and measures after create (§6.3)", func() {
+ groupName := fmt.Sprintf("ar-list-%d", time.Now().UnixNano())
+ measureName := applyRollbackMeasureName
+
+ g.By("Creating measure group and measure")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+ r := createMeasureResp.GetModRevision()
+
+ g.By("Awaiting revision")
+ awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("ListGroup contains the group")
+ listGroupResp, listGroupErr := clients.GroupClient.List(ctx, &databasev1.GroupRegistryServiceListRequest{})
+ gm.Expect(listGroupErr).ShouldNot(gm.HaveOccurred())
+ foundGroup := false
+ for _, grp := range listGroupResp.GetGroup() {
+ if grp.GetMetadata().GetName() == groupName {
+ foundGroup = true
+ break
+ }
+ }
+ gm.Expect(foundGroup).Should(gm.BeTrue(), "ListGroup must contain the created group")
+
+ g.By("ListMeasure returns exactly one user-created entry")
+ listMeasureResp, listMeasureErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listMeasureErr).ShouldNot(gm.HaveOccurred())
+ userMeasureOnly := userMeasures(listMeasureResp.GetMeasure())
+ gm.Expect(userMeasureOnly).Should(gm.HaveLen(1))
+ gm.Expect(userMeasureOnly[0].GetMetadata().GetName()).Should(gm.Equal(measureName))
+
+ g.By("ListStream, ListTrace, ListIndexRule, ListIndexRuleBinding, ListTopNAggregation all empty")
+ listStreamResp, listStreamErr := clients.StreamRegClient.List(ctx, &databasev1.StreamRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listStreamErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listStreamResp.GetStream()).Should(gm.BeEmpty(), "ListStream must be empty")
+
+ listTraceResp, listTraceErr := clients.TraceRegClient.List(ctx, &databasev1.TraceRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listTraceErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listTraceResp.GetTrace()).Should(gm.BeEmpty(), "ListTrace must be empty")
+
+ listIdxRuleResp, listIdxRuleErr := clients.IndexRuleClient.List(ctx, &databasev1.IndexRuleRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listIdxRuleErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listIdxRuleResp.GetIndexRule()).Should(gm.BeEmpty(), "ListIndexRule must be empty")
+
+ listIdxBindResp, listIdxBindErr := clients.IndexRuleBindingClient.List(ctx, &databasev1.IndexRuleBindingRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listIdxBindErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listIdxBindResp.GetIndexRuleBinding()).Should(gm.BeEmpty(), "ListIndexRuleBinding must be empty")
+
+ listTopNResp, listTopNErr := clients.TopNAggregationRegClient.List(ctx, &databasev1.TopNAggregationRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listTopNErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listTopNResp.GetTopNAggregation()).Should(gm.BeEmpty(), "ListTopNAggregation must be empty")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.4 — adds a second measure and keeps the first.
+ g.It("adds a second measure and keeps the first (§6.4)", func() {
+ groupName := fmt.Sprintf("ar-two-%d", time.Now().UnixNano())
+
+ g.By("Creating measure group")
+ createGroupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+ groupRev := createGroupResp.GetModRevision()
+ awaitGroupErr := clients.AwaitRevision(ctx, groupRev, 10*time.Second)
+ gm.Expect(awaitGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating first measure cpu_total → R1")
+ createResp1, createErr1 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, applyRollbackMeasureName),
+ })
+ gm.Expect(createErr1).ShouldNot(gm.HaveOccurred())
+ r1 := createResp1.GetModRevision()
+ gm.Expect(r1).Should(gm.BeNumerically(">", int64(0)))
+
+ awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+ gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating second measure mem_total → R2 > R1")
+ createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, "mem_total"),
+ })
+ gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+ r2 := createResp2.GetModRevision()
+ gm.Expect(r2).Should(gm.BeNumerically(">", r1), "R2 must be > R1")
+
+ awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+ gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+ g.By("Verifying GetMeasure for cpu_total returns R1")
+ getCPUResp, getCPUErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: applyRollbackMeasureName, Group: groupName},
+ })
+ gm.Expect(getCPUErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(getCPUResp.GetMeasure().GetMetadata().GetModRevision()).Should(gm.Equal(r1),
+ "cpu_total ModRevision must equal R1")
+
+ g.By("Verifying GetMeasure for mem_total returns R2")
+ getMemResp, getMemErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: "mem_total", Group: groupName},
+ })
+ gm.Expect(getMemErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(getMemResp.GetMeasure().GetMetadata().GetModRevision()).Should(gm.Equal(r2),
+ "mem_total ModRevision must equal R2")
+
+ g.By("ListMeasure returns both user-created measures")
+ listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+ userOnly := userMeasures(listResp.GetMeasure())
+ gm.Expect(userOnly).Should(gm.HaveLen(2), "ListMeasure must return both user-created measures")
+ names := make([]string, 0, 2)
+ for _, m := range userOnly {
+ names = append(names, m.GetMetadata().GetName())
+ }
+ gm.Expect(names).Should(gm.ConsistOf(applyRollbackMeasureName, "mem_total"))
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.5 — drops measure from BanyanDB on delete.
+ g.It("drops measure from BanyanDB on delete (§6.5)", func() {
+ groupName := fmt.Sprintf("ar-del-%d", time.Now().UnixNano())
+ measureName := applyRollbackMeasureName
+
+ g.By("Creating measure group and measure")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+ r := createMeasureResp.GetModRevision()
+
+ awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Deleting measure and verifying delete response")
+ deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(deleteResp.GetDeleted()).Should(gm.BeTrue(), "DeleteMeasure must return deleted=true")
+ gm.Expect(deleteResp.GetDeleteTime()).Should(gm.BeNumerically(">", int64(0)), "delete_time must be > 0")
+
+ g.By("Awaiting deletion propagation")
+ awaitDeletedErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+ gm.Expect(awaitDeletedErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Verifying GetMeasure returns NotFound")
+ _, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr).Should(gm.HaveOccurred())
+ st, _ := status.FromError(getErr)
+ gm.Expect(st.Code()).Should(gm.Equal(codes.NotFound), "GetMeasure must return NotFound after deletion")
+
+ g.By("Verifying ListMeasure has no user-created entries")
+ listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(userMeasures(listResp.GetMeasure())).Should(gm.BeEmpty(),
+ "ListMeasure must contain no user-created entries after deletion")
+
+ g.By("Verifying write to deleted measure fails or returns non-SUCCEED status")
+ writeStatus, writeErr := sendSingleMeasureWrite(ctx, clients.MeasureWriteClient, groupName, measureName, 0)
+ // The measure entity is no longer in the routing cache; write must fail.
+ if writeErr == nil {
+ gm.Expect(writeStatus).ShouldNot(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+ "write to deleted measure must not return STATUS_SUCCEED")
+ }
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.6 — delete-then-recreate is idempotent for identical shape.
+ g.It("delete-then-recreate is idempotent for identical shape (§6.6)", func() {
+ groupName := fmt.Sprintf("ar-recreate-%d", time.Now().UnixNano())
+ measureName := applyRollbackMeasureName
+
+ g.By("Creating measure group and measure → R1")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createResp1, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+ r1 := createResp1.GetModRevision()
+
+ awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+ gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+ g.By("Deleting measure → T_del; awaiting deletion")
+ deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+ tDel := deleteResp.GetDeleteTime()
+ gm.Expect(tDel).Should(gm.BeNumerically(">", int64(0)))
+
+ awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+ gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Recreating the same measure spec → R2")
+ createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+ r2 := createResp2.GetModRevision()
+
+ g.By("Awaiting R2")
+ awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+ gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+ g.By("Assertions: ModRevision, R2 > R1, CreatedAt2 > T_del, shape matches, ListMeasure length 1")
+ getResp, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr).ShouldNot(gm.HaveOccurred())
+ got := getResp.GetMeasure()
+
+ gm.Expect(got.GetMetadata().GetModRevision()).Should(gm.Equal(r2), "ModRevision must equal R2")
+ gm.Expect(r2).Should(gm.BeNumerically(">", r1), "R2 must be > R1")
+ gm.Expect(got.GetCreatedAt()).ShouldNot(gm.BeNil())
+ gm.Expect(got.GetCreatedAt().AsTime().UnixNano()).Should(
+ gm.BeNumerically(">", tDel),
+ "CreatedAt2 must be after T_del (tombstone invariant Step 1.3)",
+ )
+ gm.Expect(got.GetEntity().GetTagNames()).Should(gm.Equal([]string{"host"}), "entity must match original shape")
+
+ listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(userMeasures(listResp.GetMeasure())).Should(gm.HaveLen(1),
+ "ListMeasure must return exactly one user-created entry after recreate")
+
+ g.By("Write with stale R1 must return STATUS_EXPIRED_SCHEMA")
+ writeStatus, writeErr := sendSingleMeasureWrite(ctx, clients.MeasureWriteClient, groupName, measureName, r1)
+ gm.Expect(writeErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatus).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA.String()),
+ "write with stale ModRevision R1 must return STATUS_EXPIRED_SCHEMA after recreate")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.7 — produces no measures when nothing is created.
+ g.It("produces no measures when nothing is created (§6.7)", func() {
+ groupName := fmt.Sprintf("ar-empty-%d", time.Now().UnixNano())
+
+ g.By("Creating group only — no measures")
+ createGroupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+ groupRev := createGroupResp.GetModRevision()
+ awaitGroupErr := clients.AwaitRevision(ctx, groupRev, 10*time.Second)
+ gm.Expect(awaitGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("All list RPCs must return no user-created entries")
+ listMeasureResp, listMeasureErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listMeasureErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(userMeasures(listMeasureResp.GetMeasure())).Should(gm.BeEmpty(),
+ "ListMeasure must contain no user-created entries")
+
+ listStreamResp, listStreamErr := clients.StreamRegClient.List(ctx, &databasev1.StreamRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listStreamErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listStreamResp.GetStream()).Should(gm.BeEmpty(), "ListStream must be empty")
+
+ listTraceResp, listTraceErr := clients.TraceRegClient.List(ctx, &databasev1.TraceRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listTraceErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listTraceResp.GetTrace()).Should(gm.BeEmpty(), "ListTrace must be empty")
+
+ listIdxRuleResp, listIdxRuleErr := clients.IndexRuleClient.List(ctx, &databasev1.IndexRuleRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listIdxRuleErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listIdxRuleResp.GetIndexRule()).Should(gm.BeEmpty(), "ListIndexRule must be empty")
+
+ listIdxBindResp, listIdxBindErr := clients.IndexRuleBindingClient.List(ctx, &databasev1.IndexRuleBindingRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listIdxBindErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listIdxBindResp.GetIndexRuleBinding()).Should(gm.BeEmpty(), "ListIndexRuleBinding must be empty")
+
+ listTopNResp, listTopNErr := clients.TopNAggregationRegClient.List(ctx, &databasev1.TopNAggregationRegistryServiceListRequest{Group: groupName})
+ gm.Expect(listTopNErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(listTopNResp.GetTopNAggregation()).Should(gm.BeEmpty(), "ListTopNAggregation must be empty")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+})
diff --git a/test/cases/schema/clamp.go b/test/cases/schema/clamp.go
new file mode 100644
index 000000000..12d0ce58e
--- /dev/null
+++ b/test/cases/schema/clamp.go
@@ -0,0 +1,313 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// sendStreamQueryWithRange issues a unary stream Query with an explicit time range.
+// It returns the QueryResponse or an error; callers inspect Elements / GroupStatuses.
+// The query carries GroupModRevisions so the schema-aware clamp activates — without
+// it the liaison treats the request as a legacy query and skips clamping.
+func sendStreamQueryWithRange(
+ ctx context.Context,
+ client streamv1.StreamServiceClient,
+ groupName, streamName string,
+ begin, end time.Time,
+ modRevision int64,
+) (*streamv1.QueryResponse, error) {
+ return client.Query(ctx, &streamv1.QueryRequest{
+ Groups: []string{groupName},
+ Name: streamName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(begin.Truncate(time.Millisecond)),
+ End: timestamppb.New(end.Truncate(time.Millisecond)),
+ },
+ Limit: 10,
+ GroupModRevisions: map[string]int64{groupName: modRevision},
+ Projection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"svc"}},
+ },
+ },
+ })
+}
+
+// Schema time-range clamp smoke tests — Rule 7 / §4.6.
+// §4.6.4 writes real data and verifies no pre-creation leakage.
+// Each spec exercises a distinct outcome of clamping the query TimeRange.Begin to
+// the maximum schema.CreatedAt across all queried groups.
+var _ = g.Describe("Schema time-range clamp", func() {
+ var (
+ ctx context.Context
+ clients *Clients
+ )
+
+ g.BeforeEach(func() {
+ ctx = context.Background()
+ clients = NewClients(SharedContext.Connection)
+ })
+
+ // §4.6.1: when both Begin and End fall before the schema's CreatedAt, the server
+ // clamps Begin forward to CreatedAt; since clamped Begin > End the response is
+ // immediately empty (no data can exist in the resulting range).
+ g.It("returns empty result when query End is before schema CreatedAt (§4.6.1)", func() {
+ groupName := fmt.Sprintf("clamp-past-%d", time.Now().UnixNano())
+ streamName := "clamp_stream"
+
+ g.By("Creating stream group and schema")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createStreamResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(groupName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ streamRev := createStreamResp.GetModRevision()
+
+ g.By("Waiting for schema to be applied in cache (so CreatedAt is populated)")
+ awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Querying with a range that ends far in the past (before schema creation)")
+ epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ resp, queryErr := sendStreamQueryWithRange(ctx, clients.StreamWriteClient, groupName, streamName,
+ epoch, epoch.Add(time.Millisecond), streamRev)
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+ "clamped Begin (≈ CreatedAt) > End (epoch+1ms) must produce an empty response")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §4.6.2: when Begin is before schema.CreatedAt but End is in the future, the
+ // server clamps Begin forward to CreatedAt and the query executes successfully.
+ // Since no data was written the response has zero elements but no error.
+ g.It("succeeds and returns zero elements when query spans schema CreatedAt (§4.6.2)", func() {
+ groupName := fmt.Sprintf("clamp-span-%d", time.Now().UnixNano())
+ streamName := "clamp_stream"
+
+ g.By("Creating stream group and schema")
+ groupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createStreamResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(groupName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ streamRev := createStreamResp.GetModRevision()
+
+ g.By("Waiting for group and stream schema to be applied in cache")
+ gm.Expect(clients.AwaitRevision(ctx, groupResp.GetModRevision(), 10*time.Second)).ShouldNot(gm.HaveOccurred())
+ awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Querying with Begin in the far past and End in the near future")
+ epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ now := time.Now()
+ resp, queryErr := sendStreamQueryWithRange(ctx, clients.StreamWriteClient, groupName, streamName,
+ epoch, now.Add(time.Hour), streamRev)
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred(),
+ "query spanning schema CreatedAt must succeed after Begin is clamped")
+ // No data was written; expect zero elements (not an error).
+ gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+ "no data was written so zero elements are expected")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §4.6.3: when multiple groups are queried and each has a different CreatedAt,
+ // Begin is clamped to the maximum CreatedAt across all groups.
+ // An End in the far past produces an empty response.
+ g.It("clamps to the maximum CreatedAt across multiple groups (§4.6.3)", func() {
+ group1 := fmt.Sprintf("clamp-multi1-%d", time.Now().UnixNano())
+ group2 := fmt.Sprintf("clamp-multi2-%d", time.Now().UnixNano())
+ streamName := "clamp_stream"
+
+ g.By("Creating two stream groups and schemas back-to-back")
+ streamRevs := make(map[string]int64, 2)
+ for _, groupName := range []string{group1, group2} {
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+ createStreamResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(groupName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ streamRevs[groupName] = createStreamResp.GetModRevision()
+ }
+
+ g.By("Waiting for both schemas to be applied")
+ awaitErr := clients.AwaitApplied(ctx, []string{
+ fmt.Sprintf("stream:%s/%s", group1, streamName),
+ fmt.Sprintf("stream:%s/%s", group2, streamName),
+ }, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Querying both groups with a range that ends far in the past")
+ epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ resp, queryErr := clients.StreamWriteClient.Query(ctx, &streamv1.QueryRequest{
+ Groups: []string{group1, group2},
+ Name: streamName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(epoch),
+ End: timestamppb.New(epoch.Add(time.Millisecond)),
+ },
+ Limit: 10,
+ GroupModRevisions: streamRevs,
+ Projection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"svc"}},
+ },
+ },
+ })
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+ "max CreatedAt across both groups > epoch+1ms so the clamped range must be empty")
+
+ for _, groupName := range []string{group1, group2} {
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{
+ Group: groupName,
+ })
+ }
+ })
+
+ // §4.6.4: PRIMARY Rule 7 correctness test — falsifies the unclamped case.
+ // Writes one datum at T_data1 in group1 (older CreatedAt1), then creates group2
+ // with CreatedAt2 > T_data1. Querying both groups with a wide Begin must return
+ // ZERO data points: the clamp forwards Begin to max(CreatedAt1, CreatedAt2) =
+ // CreatedAt2 > T_data1, so the datum is excluded. Without clamp, T_data1 falls
+ // inside [Begin, End] and the datum would leak — proving the clamp is actually
+ // applied rather than merely consistent with an already-in-range write.
+ g.It("clips TimeRange.Begin to max(CreatedAt) and excludes pre-creation data (§4.6.4)", func() {
+ // TODO(phase-2): Phase 1 AwaitRevisionApplied is liaison-only by design. This spec's
+ // baseline sanity check (Create → AwaitRevision → Write → Query expecting HaveLen(1))
+ // races the data-node tsTable readiness in distributed mode. The actual clamp
+ // falsification is sound; only the prerequisite Write→Query round-trip flakes.
+ // Cluster-wide barrier semantics ship in Phase 2 via NodeSchemaStatusService +
+ // liaison fan-out (plan Steps 2.1–2.2); re-enable this spec in distributed mode
+ // once those land.
+ if SharedContext.Mode == "distributed" {
+ g.Skip("§4.6.4 requires cluster-wide propagation barrier (Phase 2)")
+ }
+ group1 := fmt.Sprintf("clamp-leak1-%d", time.Now().UnixNano())
+ group2 := fmt.Sprintf("clamp-leak2-%d", time.Now().UnixNano())
+ measureName := "clamp_measure"
+
+ g.By("Creating older group1 and measure (CreatedAt1)")
+ _, createGroup1Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: shapeBreakMeasureGroup(group1),
+ })
+ gm.Expect(createGroup1Err).ShouldNot(gm.HaveOccurred())
+
+ createResp1, createMeasure1Err := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: throughputMeasureSpecV1(group1, measureName),
+ })
+ gm.Expect(createMeasure1Err).ShouldNot(gm.HaveOccurred())
+ r1 := createResp1.GetModRevision()
+ gm.Expect(clients.AwaitRevision(ctx, r1, 10*time.Second)).ShouldNot(gm.HaveOccurred())
+
+ g.By("Writing one data point at T_data1 in measure1 (strictly after CreatedAt1, strictly before CreatedAt2)")
+ tData1 := timestamp.NowMilli()
+ hostTags := []*modelv1.TagFamilyForWrite{{
+ Tags: []*modelv1.TagValue{{
+ Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+ }},
+ }}
+ writeStatus, writeErr := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, group1, measureName, tData1, r1, hostTags)
+ gm.Expect(writeErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatus).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+ "write at T_data1 must return STATUS_SUCCEED")
+
+ g.By("Sanity-checking that querying group1 alone returns the datum (legacy unclamped baseline — pass modRevision=0)")
+ baselineResp, baselineErr := queryMeasureRange(ctx, clients.MeasureWriteClient, group1, measureName,
+ tData1.Add(-time.Hour), time.Now().Add(time.Hour), 0)
+ gm.Expect(baselineErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(baselineResp.GetDataPoints()).Should(gm.HaveLen(1),
+ "baseline single-group query must return the written datum — otherwise §4.6.4 is not falsifying anything")
+
+ g.By("Creating newer group2 and measure (CreatedAt2 > T_data1)")
+ _, createGroup2Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: shapeBreakMeasureGroup(group2),
+ })
+ gm.Expect(createGroup2Err).ShouldNot(gm.HaveOccurred())
+
+ createResp2, createMeasure2Err := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: throughputMeasureSpecV1(group2, measureName),
+ })
+ gm.Expect(createMeasure2Err).ShouldNot(gm.HaveOccurred())
+ r2 := createResp2.GetModRevision()
+ gm.Expect(clients.AwaitRevision(ctx, r2, 10*time.Second)).ShouldNot(gm.HaveOccurred())
+
+ g.By("Verifying CreatedAt2 > T_data1 so the clamp falsification is meaningful")
+ getResp2, getMeasure2Err := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: group2},
+ })
+ gm.Expect(getMeasure2Err).ShouldNot(gm.HaveOccurred())
+ createdAt2 := getResp2.GetMeasure().GetCreatedAt()
+ gm.Expect(createdAt2).ShouldNot(gm.BeNil())
+ gm.Expect(createdAt2.AsTime().After(tData1)).Should(gm.BeTrue(),
+ "CreatedAt2 must be strictly after T_data1 for the falsification to be meaningful")
+
+ g.By("Querying group1+group2 with Begin far before T_data1 — clamp forwards Begin to CreatedAt2 > T_data1, excluding the datum")
+ queryResp, queryErr := clients.MeasureWriteClient.Query(ctx, &measurev1.QueryRequest{
+ Groups: []string{group1, group2},
+ Name: measureName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(tData1.Add(-time.Hour).Truncate(time.Millisecond)),
+ End: timestamppb.New(time.Now().Add(time.Hour).Truncate(time.Millisecond)),
+ },
+ GroupModRevisions: map[string]int64{group1: r1, group2: r2},
+ TagProjection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"host"}},
+ },
+ },
+ FieldProjection: &measurev1.QueryRequest_FieldProjection{
+ Names: []string{"value"},
+ },
+ Limit: 100,
+ })
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(queryResp.GetDataPoints()).Should(gm.BeEmpty(),
+ "Rule 7 clamp invariant: T_data1 < CreatedAt2 must be excluded by the clamp; without clamp the datum would leak")
+
+ for _, groupName := range []string{group1, group2} {
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ }
+ })
+})
diff --git a/test/cases/schema/clients.go b/test/cases/schema/clients.go
new file mode 100644
index 000000000..b7aa6a3e0
--- /dev/null
+++ b/test/cases/schema/clients.go
@@ -0,0 +1,176 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/protobuf/types/known/durationpb"
+
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+ schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+ streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
+)
+
+// Clients holds all gRPC clients required by the schema integration test helpers.
+type Clients struct {
+ GroupClient databasev1.GroupRegistryServiceClient
+ MeasureRegClient databasev1.MeasureRegistryServiceClient
+ StreamRegClient databasev1.StreamRegistryServiceClient
+ TraceRegClient databasev1.TraceRegistryServiceClient
+ IndexRuleClient databasev1.IndexRuleRegistryServiceClient
+ IndexRuleBindingClient databasev1.IndexRuleBindingRegistryServiceClient
+ TopNAggregationRegClient databasev1.TopNAggregationRegistryServiceClient
+ MeasureWriteClient measurev1.MeasureServiceClient
+ StreamWriteClient streamv1.StreamServiceClient
+ TraceWriteClient tracev1.TraceServiceClient
+ BarrierClient schemav1.SchemaBarrierServiceClient
+}
+
+// NewClients constructs the registry/service client bundle used by the schema integration tests.
+func NewClients(conn *grpc.ClientConn) *Clients {
+ return &Clients{
+ GroupClient: databasev1.NewGroupRegistryServiceClient(conn),
+ MeasureRegClient: databasev1.NewMeasureRegistryServiceClient(conn),
+ StreamRegClient: databasev1.NewStreamRegistryServiceClient(conn),
+ TraceRegClient: databasev1.NewTraceRegistryServiceClient(conn),
+ IndexRuleClient: databasev1.NewIndexRuleRegistryServiceClient(conn),
+ IndexRuleBindingClient: databasev1.NewIndexRuleBindingRegistryServiceClient(conn),
+ TopNAggregationRegClient: databasev1.NewTopNAggregationRegistryServiceClient(conn),
+ MeasureWriteClient: measurev1.NewMeasureServiceClient(conn),
+ StreamWriteClient: streamv1.NewStreamServiceClient(conn),
+ TraceWriteClient: tracev1.NewTraceServiceClient(conn),
+ BarrierClient: schemav1.NewSchemaBarrierServiceClient(conn),
+ }
+}
+
+// AwaitRevision blocks until the cluster's schema cache has advanced to the given
+// mod_revision or the timeout elapses. Delegates to SchemaBarrierService.AwaitRevisionApplied.
+func (c *Clients) AwaitRevision(ctx context.Context, target int64, timeout time.Duration) error {
+ resp, rpcErr := c.BarrierClient.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+ MinRevision: target,
+ Timeout: durationpb.New(timeout),
+ })
+ if rpcErr != nil {
+ return fmt.Errorf("AwaitRevisionApplied RPC failed: %w", rpcErr)
+ }
+ if !resp.GetApplied() {
+ return fmt.Errorf("timeout waiting for schema revision %d", target)
+ }
+ return nil
+}
+
+// AwaitApplied blocks until every key in the list reports as present in the
+// cluster's schema cache or the timeout elapses. Each key is encoded as
+// "kind:group/name"; supported kinds are "measure", "stream", and "trace".
+// Delegates to SchemaBarrierService.AwaitSchemaApplied.
+func (c *Clients) AwaitApplied(ctx context.Context, keys []string, timeout time.Duration) error {
+ schemaKeys, parseErr := parseSchemaKeys(keys)
+ if parseErr != nil {
+ return parseErr
+ }
+ resp, rpcErr := c.BarrierClient.AwaitSchemaApplied(ctx, &schemav1.AwaitSchemaAppliedRequest{
+ Keys: schemaKeys,
+ Timeout: durationpb.New(timeout),
+ })
+ if rpcErr != nil {
+ return fmt.Errorf("AwaitSchemaApplied RPC failed: %w", rpcErr)
+ }
+ if !resp.GetApplied() {
+ return fmt.Errorf("timeout waiting for schema keys to be applied: %v", keys)
+ }
+ return nil
+}
+
+// AwaitDeleted blocks until every key in the list is absent from the cluster's
+// schema cache or the timeout elapses. Encoding follows AwaitApplied.
+// Delegates to SchemaBarrierService.AwaitSchemaDeleted.
+func (c *Clients) AwaitDeleted(ctx context.Context, keys []string, timeout time.Duration) error {
+ schemaKeys, parseErr := parseSchemaKeys(keys)
+ if parseErr != nil {
+ return parseErr
+ }
+ resp, rpcErr := c.BarrierClient.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+ Keys: schemaKeys,
+ Timeout: durationpb.New(timeout),
+ })
+ if rpcErr != nil {
+ return fmt.Errorf("AwaitSchemaDeleted RPC failed: %w", rpcErr)
+ }
+ if !resp.GetApplied() {
+ return fmt.Errorf("timeout waiting for schema keys to be deleted: %v", keys)
+ }
+ return nil
+}
+
+// internalTopNResultMeasureName is the auto-created measure that backs TopN
+// aggregations. The measure subsystem provisions it in every measure group, so
+// list assertions in the §6 specs need to skip it.
+const internalTopNResultMeasureName = "_top_n_result"
+
+// userMeasures returns measures with the auto-provisioned _top_n_result entry
+// removed, so spec assertions can count user-created measures only without
+// hard-coding off-by-one for the internal entry.
+func userMeasures(in []*databasev1.Measure) []*databasev1.Measure {
+ out := make([]*databasev1.Measure, 0, len(in))
+ for _, m := range in {
+ if m.GetMetadata().GetName() == internalTopNResultMeasureName {
+ continue
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// parseSchemaKeys converts a slice of "kind:group/name" encoded strings to SchemaKey protos.
+func parseSchemaKeys(keys []string) ([]*schemav1.SchemaKey, error) {
+ result := make([]*schemav1.SchemaKey, 0, len(keys))
+ for _, key := range keys {
+ sk, parseErr := parseSchemaKey(key)
+ if parseErr != nil {
+ return nil, parseErr
+ }
+ result = append(result, sk)
+ }
+ return result, nil
+}
+
+// parseSchemaKey splits an encoded "kind:group/name" key into a SchemaKey proto.
+func parseSchemaKey(key string) (*schemav1.SchemaKey, error) {
+ colon := strings.IndexByte(key, ':')
+ if colon <= 0 || colon == len(key)-1 {
+ return nil, fmt.Errorf("invalid schema key %q: expected kind:group/name", key)
+ }
+ kind := key[:colon]
+ rest := key[colon+1:]
+ slash := strings.IndexByte(rest, '/')
+ if slash <= 0 || slash == len(rest)-1 {
+ return nil, fmt.Errorf("invalid schema key %q: expected kind:group/name", key)
+ }
+ return &schemav1.SchemaKey{
+ Kind: kind,
+ Group: rest[:slash],
+ Name: rest[slash+1:],
+ }, nil
+}
diff --git a/test/cases/schema/deletion.go b/test/cases/schema/deletion.go
index 21b30d66c..7313e75a0 100644
--- a/test/cases/schema/deletion.go
+++ b/test/cases/schema/deletion.go
@@ -44,19 +44,6 @@ import (
// SharedContext is set by the test environment (standalone or distributed).
var SharedContext helpers.SharedContext
-// Clients holds all necessary gRPC clients for deletion tests.
-type Clients struct {
- GroupClient databasev1.GroupRegistryServiceClient
- MeasureRegClient databasev1.MeasureRegistryServiceClient
- StreamRegClient databasev1.StreamRegistryServiceClient
- TraceRegClient databasev1.TraceRegistryServiceClient
- IndexRuleClient databasev1.IndexRuleRegistryServiceClient
- IndexRuleBindingClient databasev1.IndexRuleBindingRegistryServiceClient
- MeasureWriteClient measurev1.MeasureServiceClient
- StreamWriteClient streamv1.StreamServiceClient
- TraceWriteClient tracev1.TraceServiceClient
-}
-
// Shared test cases. Automatically registered when this package is imported.
var _ = g.Describe("Schema deletion", func() {
var (
@@ -66,18 +53,7 @@ var _ = g.Describe("Schema deletion", func() {
g.BeforeEach(func() {
ctx = context.Background()
- conn := SharedContext.Connection
- clients = &Clients{
- GroupClient: databasev1.NewGroupRegistryServiceClient(conn),
- MeasureRegClient: databasev1.NewMeasureRegistryServiceClient(conn),
- StreamRegClient: databasev1.NewStreamRegistryServiceClient(conn),
- TraceRegClient: databasev1.NewTraceRegistryServiceClient(conn),
- IndexRuleClient: databasev1.NewIndexRuleRegistryServiceClient(conn),
- IndexRuleBindingClient: databasev1.NewIndexRuleBindingRegistryServiceClient(conn),
- MeasureWriteClient: measurev1.NewMeasureServiceClient(conn),
- StreamWriteClient: streamv1.NewStreamServiceClient(conn),
- TraceWriteClient: tracev1.NewTraceServiceClient(conn),
- }
+ clients = NewClients(SharedContext.Connection)
})
g.It("should delete measure correctly", func() {
diff --git a/test/cases/schema/query_gate.go b/test/cases/schema/query_gate.go
new file mode 100644
index 000000000..3b52cbc07
--- /dev/null
+++ b/test/cases/schema/query_gate.go
@@ -0,0 +1,373 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// sendStreamQuery issues a unary stream Query with a single-group GroupModRevisions entry.
+// It returns the per-group status for groupName from the response, or an error.
+func sendStreamQuery(
+ ctx context.Context,
+ client streamv1.StreamServiceClient,
+ groupName, streamName string,
+ modRevision int64,
+) (modelv1.Status, error) {
+ now := timestamp.NowMilli()
+ resp, queryErr := client.Query(ctx, &streamv1.QueryRequest{
+ Groups: []string{groupName},
+ Name: streamName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(now.Add(-time.Hour)),
+ End: timestamppb.New(now.Add(time.Hour)),
+ },
+ Limit: 1,
+ GroupModRevisions: map[string]int64{groupName: modRevision},
+ Projection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"svc"}},
+ },
+ },
+ })
+ if queryErr != nil {
+ return modelv1.Status_STATUS_UNSPECIFIED, fmt.Errorf("stream query: %w", queryErr)
+ }
+ st, ok := resp.GetGroupStatuses()[groupName]
+ if !ok {
+ return modelv1.Status_STATUS_UNSPECIFIED, fmt.Errorf("group %q absent from GroupStatuses in response", groupName)
+ }
+ return st, nil
+}
+
+// queryGateStreamName is the fixture stream name reused across §4.5 specs.
+const queryGateStreamName = "qg_stream"
+
+// Schema query gate smoke tests — §4.5.1 / §4.5.2 / §4.5.3.
+// Each spec exercises a distinct branch of the per-group ModRevision gate on the
+// query path.
+var _ = g.Describe("Schema query gate", func() {
+ var (
+ ctx context.Context
+ clients *Clients
+ )
+
+ g.BeforeEach(func() {
+ ctx = context.Background()
+ clients = NewClients(SharedContext.Connection)
+ })
+
+ // §4.5.1: a query carrying a group ModRevision below the cached schema revision
+ // is short-circuited and returns STATUS_EXPIRED_SCHEMA for that group.
+ g.It("returns STATUS_EXPIRED_SCHEMA for stale group ModRevision (§4.5.1)", func() {
+ groupName := fmt.Sprintf("qg-stale-%d", time.Now().UnixNano())
+ streamName := queryGateStreamName
+
+ g.By("Creating stream group and schema to obtain baseline ModRevision (R1)")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(groupName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ r1 := createResp.GetModRevision()
+ gm.Expect(r1).Should(gm.BeNumerically(">", int64(0)))
+
+ g.By("Updating the stream to advance the cache to R2 > R1")
+ getResp, getStreamErr := clients.StreamRegClient.Get(ctx, &databasev1.StreamRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: streamName, Group: groupName},
+ })
+ gm.Expect(getStreamErr).ShouldNot(gm.HaveOccurred())
+ updatedStream := getResp.GetStream()
+ updatedStream.TagFamilies[0].Tags = append(updatedStream.TagFamilies[0].Tags,
+ &databasev1.TagSpec{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING})
+ updateResp, updateStreamErr := clients.StreamRegClient.Update(ctx, &databasev1.StreamRegistryServiceUpdateRequest{
+ Stream: updatedStream,
+ })
+ gm.Expect(updateStreamErr).ShouldNot(gm.HaveOccurred())
+ r2 := updateResp.GetModRevision()
+ gm.Expect(r2).Should(gm.BeNumerically(">", r1))
+
+ g.By("Waiting for the cache to reach R2")
+ awaitErr := clients.AwaitRevision(ctx, r2, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Sending a query with the old ModRevision R1 (stale)")
+ st, queryErr := sendStreamQuery(ctx, clients.StreamWriteClient, groupName, streamName, r1)
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(st).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA),
+ "query with group ModRevision < cache must return STATUS_EXPIRED_SCHEMA")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §4.5.2: a query carrying a group ModRevision far ahead of the cache is held
+ // until the configured wait duration and then returned with STATUS_SCHEMA_NOT_APPLIED.
+ g.It("returns STATUS_SCHEMA_NOT_APPLIED for ahead group ModRevision that never applies (§4.5.2)", func() {
+ groupName := fmt.Sprintf("qg-ahead-%d", time.Now().UnixNano())
+ streamName := queryGateStreamName
+
+ g.By("Creating stream group and schema")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(groupName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ r1 := createResp.GetModRevision()
+
+ g.By("Waiting for the schema to be applied in cache")
+ awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Sending a query with ModRevision = R1 + 99999 (far ahead, will never apply)")
+ aheadRev := r1 + 99999
+ st, queryErr := sendStreamQuery(ctx, clients.StreamWriteClient, groupName, streamName, aheadRev)
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(st).Should(gm.Equal(modelv1.Status_STATUS_SCHEMA_NOT_APPLIED),
+ "query with group ModRevision > cache must return STATUS_SCHEMA_NOT_APPLIED after wait timeout")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §4.5.3: a query whose group ModRevision matches the cached revision passes the gate
+ // and returns STATUS_SUCCEED for that group.
+ g.It("passes the gate and returns STATUS_SUCCEED when ModRevision matches cache (§4.5.3)", func() {
+ groupName := fmt.Sprintf("qg-match-%d", time.Now().UnixNano())
+ streamName := queryGateStreamName
+
+ g.By("Creating stream group and schema")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(groupName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ r1 := createResp.GetModRevision()
+
+ g.By("Waiting for the schema to be applied in cache")
+ awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Sending a query with the current ModRevision R1 (matching cache)")
+ st, queryErr := sendStreamQuery(ctx, clients.StreamWriteClient, groupName, streamName, r1)
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(st).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+ "query with group ModRevision equal to cache must pass the gate with STATUS_SUCCEED")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §4.5.4: mixed groups — one at current rev (SUCCEED), one stale (EXPIRED_SCHEMA);
+ // query short-circuits and returns empty elements.
+ g.It("returns mixed group_statuses and empty elements when one group is stale (§4.5.4)", func() {
+ group1 := fmt.Sprintf("qg-mixed1-%d", time.Now().UnixNano())
+ group2 := fmt.Sprintf("qg-mixed2-%d", time.Now().UnixNano())
+ streamName := queryGateStreamName
+
+ g.By("Creating group1 + stream → R1a; updating stream → R1b")
+ _, createGroup1Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(group1),
+ })
+ gm.Expect(createGroup1Err).ShouldNot(gm.HaveOccurred())
+
+ createResp1a, createStream1Err := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(group1, streamName),
+ })
+ gm.Expect(createStream1Err).ShouldNot(gm.HaveOccurred())
+ r1a := createResp1a.GetModRevision()
+
+ getResp1, getStream1Err := clients.StreamRegClient.Get(ctx, &databasev1.StreamRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: streamName, Group: group1},
+ })
+ gm.Expect(getStream1Err).ShouldNot(gm.HaveOccurred())
+ updatedStream1 := getResp1.GetStream()
+ updatedStream1.TagFamilies[0].Tags = append(updatedStream1.TagFamilies[0].Tags,
+ &databasev1.TagSpec{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING})
+ updateResp1, updateStream1Err := clients.StreamRegClient.Update(ctx, &databasev1.StreamRegistryServiceUpdateRequest{
+ Stream: updatedStream1,
+ })
+ gm.Expect(updateStream1Err).ShouldNot(gm.HaveOccurred())
+ r1b := updateResp1.GetModRevision()
+ gm.Expect(r1b).Should(gm.BeNumerically(">", r1a))
+
+ g.By("Creating group2 + stream → R2")
+ _, createGroup2Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(group2),
+ })
+ gm.Expect(createGroup2Err).ShouldNot(gm.HaveOccurred())
+
+ createResp2, createStream2Err := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(group2, streamName),
+ })
+ gm.Expect(createStream2Err).ShouldNot(gm.HaveOccurred())
+ r2 := createResp2.GetModRevision()
+
+ g.By("Awaiting max revision")
+ maxRev := r1b
+ if r2 > maxRev {
+ maxRev = r2
+ }
+ awaitErr := clients.AwaitRevision(ctx, maxRev, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Querying both groups with group1 stale (R1a) and group2 current (R2)")
+ now := timestamp.NowMilli()
+ resp, queryErr := clients.StreamWriteClient.Query(ctx, &streamv1.QueryRequest{
+ Groups: []string{group1, group2},
+ Name: streamName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(now.Add(-time.Hour)),
+ End: timestamppb.New(now.Add(time.Hour)),
+ },
+ Limit: 10,
+ GroupModRevisions: map[string]int64{
+ group1: r1a,
+ group2: r2,
+ },
+ Projection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"svc"}},
+ },
+ },
+ })
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Asserting group1 status is EXPIRED_SCHEMA and group2 status is SUCCEED")
+ statuses := resp.GetGroupStatuses()
+ gm.Expect(statuses).ShouldNot(gm.BeNil())
+ gm.Expect(statuses[group1]).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA),
+ "group1 with stale R1a must be STATUS_EXPIRED_SCHEMA")
+ // group2 is current; may be SUCCEED or absent when short-circuited — check actual behavior.
+ if st2, ok := statuses[group2]; ok {
+ gm.Expect(st2).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+ "group2 with current R2 must be STATUS_SUCCEED when present")
+ }
+
+ g.By("Query short-circuits: elements must be empty")
+ gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+ "query short-circuits on EXPIRED_SCHEMA; elements must be empty")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group1})
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group2})
+ })
+
+ // §4.5.5: partial coverage opt-in — group2 not in GroupModRevisions map → ungated.
+ // Query executes; group1 gated (SUCCEED); group2 either absent or SUCCEED in statuses.
+ g.It("executes query when one group is ungated (not in GroupModRevisions) (§4.5.5)", func() {
+ group1 := fmt.Sprintf("qg-partial1-%d", time.Now().UnixNano())
+ group2 := fmt.Sprintf("qg-partial2-%d", time.Now().UnixNano())
+ streamName := queryGateStreamName
+
+ g.By("Creating group1 and group2 with streams")
+ var maxGroupRev int64
+ for _, grpName := range []string{group1, group2} {
+ grpResp, createGrpErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: wgStreamGroup(grpName),
+ })
+ gm.Expect(createGrpErr).ShouldNot(gm.HaveOccurred())
+ if grpResp.GetModRevision() > maxGroupRev {
+ maxGroupRev = grpResp.GetModRevision()
+ }
+
+ _, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+ Stream: wgStreamSpec(grpName, streamName),
+ })
+ gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+ }
+
+ g.By("Waiting for both groups and both stream schemas to be applied")
+ gm.Expect(clients.AwaitRevision(ctx, maxGroupRev, 10*time.Second)).ShouldNot(gm.HaveOccurred())
+ awaitErr := clients.AwaitApplied(ctx, []string{
+ fmt.Sprintf("stream:%s/%s", group1, streamName),
+ fmt.Sprintf("stream:%s/%s", group2, streamName),
+ }, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Getting current ModRevision for group1")
+ getResp1, getStream1Err := clients.StreamRegClient.Get(ctx, &databasev1.StreamRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: streamName, Group: group1},
+ })
+ gm.Expect(getStream1Err).ShouldNot(gm.HaveOccurred())
+ r1 := getResp1.GetStream().GetMetadata().GetModRevision()
+ gm.Expect(r1).Should(gm.BeNumerically(">", int64(0)))
+
+ g.By("Querying with group1 gated (current rev) and group2 NOT in GroupModRevisions")
+ now := timestamp.NowMilli()
+ resp, queryErr := clients.StreamWriteClient.Query(ctx, &streamv1.QueryRequest{
+ Groups: []string{group1, group2},
+ Name: streamName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(now.Add(-time.Hour)),
+ End: timestamppb.New(now.Add(time.Hour)),
+ },
+ Limit: 10,
+ GroupModRevisions: map[string]int64{
+ group1: r1,
+ // group2 intentionally absent — ungated
+ },
+ Projection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"svc"}},
+ },
+ },
+ })
+ gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Asserting group1 status is SUCCEED and group2 is absent or SUCCEED (ungated)")
+ statuses := resp.GetGroupStatuses()
+ if statuses != nil {
+ if st1, ok := statuses[group1]; ok {
+ gm.Expect(st1).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+ "group1 with current ModRevision must be STATUS_SUCCEED")
+ }
+ // group2 is ungated: its status is either absent from the map or SUCCEED — both are valid.
+ if st2, ok := statuses[group2]; ok {
+ gm.Expect(st2).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+ "group2 when present in statuses must be STATUS_SUCCEED (ungated)")
+ }
+ }
+
+ g.By("Query is not short-circuited (no EXPIRED_SCHEMA in group1)")
+ // No assertion on elements count since no data was written; just verify no error.
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group1})
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group2})
+ })
+})
diff --git a/test/cases/schema/shape_break.go b/test/cases/schema/shape_break.go
new file mode 100644
index 000000000..3284579da
--- /dev/null
+++ b/test/cases/schema/shape_break.go
@@ -0,0 +1,523 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+ modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// shapeBreakMeasureGroup creates a CATALOG_MEASURE group with the given name.
+func shapeBreakMeasureGroup(name string) *commonv1.Group {
+ return &commonv1.Group{
+ Metadata: &commonv1.Metadata{Name: name},
+ Catalog: commonv1.Catalog_CATALOG_MEASURE,
+ ResourceOpts: &commonv1.ResourceOpts{
+ ShardNum: 2,
+ SegmentInterval: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 1},
+ Ttl: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 7},
+ },
+ }
+}
+
+// shapeBreakMeasureSpec returns a Measure with entity=[svc] and both svc/host tags defined,
+// so that an update changing entity to [svc, host] passes validate.Measure but fails
+// validateMeasureUpdate (entity changed).
+func shapeBreakMeasureSpec(group, name string) *databasev1.Measure {
+ return &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Name: name, Group: group},
+ Entity: &databasev1.Entity{TagNames: []string{"svc"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{
+ {Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }},
+ Fields: []*databasev1.FieldSpec{{
+ Name: "value",
+ FieldType: databasev1.FieldType_FIELD_TYPE_INT,
+ EncodingMethod: databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+ CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+ }},
+ }
+}
+
+// throughputMeasureSpecV1 returns a Measure for the throughput tests with entity=[host].
+func throughputMeasureSpecV1(group, name string) *databasev1.Measure {
+ return &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Name: name, Group: group},
+ Entity: &databasev1.Entity{TagNames: []string{"host"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{
+ {Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }},
+ Fields: []*databasev1.FieldSpec{{
+ Name: "value",
+ FieldType: databasev1.FieldType_FIELD_TYPE_INT,
+ EncodingMethod: databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+ CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+ }},
+ }
+}
+
+// throughputMeasureSpecV2 returns a Measure with entity=[host, instance] (shape-break spec).
+func throughputMeasureSpecV2(group, name string) *databasev1.Measure {
+ return &databasev1.Measure{
+ Metadata: &commonv1.Metadata{Name: name, Group: group},
+ Entity: &databasev1.Entity{TagNames: []string{"host", "instance"}},
+ TagFamilies: []*databasev1.TagFamilySpec{{
+ Name: "default",
+ Tags: []*databasev1.TagSpec{
+ {Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+ {Name: "instance", Type: databasev1.TagType_TAG_TYPE_STRING},
+ },
+ }},
+ Fields: []*databasev1.FieldSpec{{
+ Name: "value",
+ FieldType: databasev1.FieldType_FIELD_TYPE_INT,
+ EncodingMethod: databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+ CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+ }},
+ }
+}
+
+// queryMeasureRange issues a unary measure Query over the given time range.
+// modRevision opts the request into the schema-aware path: the liaison clamps
+// TimeRange.Begin to schema.CreatedAt only when GroupModRevisions is non-empty.
+// Pass 0 to keep the query in the legacy unclamped mode.
+func queryMeasureRange(
+ ctx context.Context,
+ client measurev1.MeasureServiceClient,
+ groupName, measureName string,
+ begin, end time.Time,
+ modRevision int64,
+) (*measurev1.QueryResponse, error) {
+ req := &measurev1.QueryRequest{
+ Groups: []string{groupName},
+ Name: measureName,
+ TimeRange: &modelv1.TimeRange{
+ Begin: timestamppb.New(begin.Truncate(time.Millisecond)),
+ End: timestamppb.New(end.Truncate(time.Millisecond)),
+ },
+ TagProjection: &modelv1.TagProjection{
+ TagFamilies: []*modelv1.TagProjection_TagFamily{
+ {Name: "default", Tags: []string{"host"}},
+ },
+ },
+ FieldProjection: &measurev1.QueryRequest_FieldProjection{
+ Names: []string{"value"},
+ },
+ Limit: 100,
+ }
+ if modRevision > 0 {
+ req.GroupModRevisions = map[string]int64{groupName: modRevision}
+ }
+ return client.Query(ctx, req)
+}
+
+// §6.8 / §6.9 / §6.10 / §6.11 — Shape-break and delete-then-recreate scenarios.
+var _ = g.Describe("Schema shape-break rejection", func() {
+ var (
+ ctx context.Context
+ clients *Clients
+ )
+
+ g.BeforeEach(func() {
+ ctx = context.Background()
+ clients = NewClients(SharedContext.Connection)
+ })
+
+ // §6.8: shape-break — delete+apply new shape creates the new measure (Rule 7 clamp end-to-end).
+ g.It("shape-break: delete+apply new shape creates the new measure (§6.8)", func() {
+ // TODO(phase-2): Phase 1 AwaitRevisionApplied is liaison-only by design. Both this spec
+ // and §6.11 perform an end-to-end data Write+Query round-trip through the liaison after
+ // a schema mutation; in distributed mode the data node can lag the liaison briefly on
+ // tsTable readiness or query-side index refresh, racing the immediate Write→Query.
+ // Cluster-wide barrier semantics ship in Phase 2 via NodeSchemaStatusService + liaison
+ // fan-out (plan Steps 2.1–2.2); re-enable this spec in distributed mode once those land.
+ if SharedContext.Mode == "distributed" {
+ g.Skip("§6.8 requires cluster-wide propagation barrier (Phase 2)")
+ }
+ groupName := fmt.Sprintf("sb-new-%d", time.Now().UnixNano())
+ measureName := "throughput"
+
+ g.By("Creating measure group")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: shapeBreakMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure throughput with entity=[host] → R1")
+ createResp1, createErr1 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: throughputMeasureSpecV1(groupName, measureName),
+ })
+ gm.Expect(createErr1).ShouldNot(gm.HaveOccurred())
+ r1 := createResp1.GetModRevision()
+
+ awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+ gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+ g.By("Capturing CreatedAt1 from GetMeasure")
+ getResp1, getErr1 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr1).ShouldNot(gm.HaveOccurred())
+ createdAt1 := getResp1.GetMeasure().GetCreatedAt()
+ gm.Expect(createdAt1).ShouldNot(gm.BeNil())
+
+ g.By("Writing one data point at T_data1 = CreatedAt1 + 50ms")
+ // T_data1 uses wall-clock now rather than a future offset from CreatedAt1 so
+ // a subsequent recreate stamps CreatedAt2 strictly after this timestamp.
+ tData1 := timestamp.NowMilli()
+ hostTags1 := []*modelv1.TagFamilyForWrite{{
+ Tags: []*modelv1.TagValue{{
+ Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+ }},
+ }}
+ writeStatus1, writeErr1 := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName, tData1, r1, hostTags1)
+ gm.Expect(writeErr1).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatus1).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+ "initial write must return STATUS_SUCCEED")
+
+ g.By("Querying [CreatedAt1, now+1h] — must return exactly 1 data point")
+ queryResp1, queryErr1 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+ createdAt1.AsTime(), time.Now().Add(time.Hour), r1)
+ gm.Expect(queryErr1).ShouldNot(gm.HaveOccurred())
+ gm.Expect(queryResp1.GetDataPoints()).Should(gm.HaveLen(1), "sanity baseline: pre-delete query must return 1 data point")
+
+ g.By("Deleting measure → T_del; awaiting deletion")
+ deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+ tDel := deleteResp.GetDeleteTime()
+
+ awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+ gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure throughput with entity=[host, instance] → R2")
+ createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: throughputMeasureSpecV2(groupName, measureName),
+ })
+ gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+ r2 := createResp2.GetModRevision()
+
+ awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+ gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+ g.By("Assertions: new shape at R2, CreatedAt2 after T_del, old data clamped, stale write rejected, new write succeeds")
+ getResp2, getErr2 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr2).ShouldNot(gm.HaveOccurred())
+ got2 := getResp2.GetMeasure()
+
+ gm.Expect(got2.GetEntity().GetTagNames()).Should(gm.Equal([]string{"host", "instance"}),
+ "new shape must have entity=[host, instance]")
+ gm.Expect(got2.GetMetadata().GetModRevision()).Should(gm.Equal(r2), "ModRevision must equal R2")
+ gm.Expect(got2.GetCreatedAt()).ShouldNot(gm.BeNil())
+ createdAt2 := got2.GetCreatedAt()
+ gm.Expect(createdAt2.AsTime().After(createdAt1.AsTime())).Should(gm.BeTrue(),
+ "CreatedAt2 must be after CreatedAt1")
+ gm.Expect(createdAt2.AsTime().UnixNano()).Should(gm.BeNumerically(">", tDel),
+ "CreatedAt2 must be after T_del")
+
+ // Rule 7 clamp: query [T_data1, now+1h] must return empty because T_data1 < CreatedAt2.
+ queryResp2, queryErr2 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+ tData1, time.Now().Add(time.Hour), r2)
+ gm.Expect(queryErr2).ShouldNot(gm.HaveOccurred())
+ gm.Expect(queryResp2.GetDataPoints()).Should(gm.BeEmpty(),
+ "Rule 7 clamp must hide pre-CreatedAt2 data points")
+
+ // Stale write with R1 must be rejected.
+ staleHostTags := []*modelv1.TagFamilyForWrite{{
+ Tags: []*modelv1.TagValue{{
+ Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+ }},
+ }}
+ writeStatusStale, writeErrStale := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName,
+ time.Now(), r1, staleHostTags)
+ gm.Expect(writeErrStale).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatusStale).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA.String()),
+ "write with stale R1 must return STATUS_EXPIRED_SCHEMA")
+
+ // Write with R2 and new tag layout must succeed.
+ newTags := []*modelv1.TagFamilyForWrite{{
+ Tags: []*modelv1.TagValue{
+ {Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}}},
+ {Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "inst1"}}},
+ },
+ }}
+ writeStatusNew, writeErrNew := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName,
+ time.Now(), r2, newTags)
+ gm.Expect(writeErrNew).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatusNew).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+ "write with R2 and new tag layout must return STATUS_SUCCEED")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.9: entity-change update is rejected; the error mentions "entity"; ModRevision is unchanged.
+ g.It("rejects a measure entity-change update and leaves ModRevision unchanged (§6.9)", func() {
+ groupName := fmt.Sprintf("sb1-measure-%d", time.Now().UnixNano())
+ measureName := "sb1_measure"
+
+ g.By("Creating measure group")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: shapeBreakMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure with entity=[svc]")
+ _, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: shapeBreakMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Getting measure to capture baseline ModRevision")
+ baseResp, getBaseErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getBaseErr).ShouldNot(gm.HaveOccurred())
+ baseModRev := baseResp.GetMeasure().GetMetadata().GetModRevision()
+ gm.Expect(baseModRev).Should(gm.BeNumerically(">", int64(0)))
+
+ g.By("Attempting UpdateMeasure with entity=[svc, host] (entity change)")
+ changedMeasure := baseResp.GetMeasure()
+ changedMeasure.Entity = &databasev1.Entity{TagNames: []string{"svc", "host"}}
+ _, updateErr := clients.MeasureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+ Measure: changedMeasure,
+ })
+ gm.Expect(updateErr).Should(gm.HaveOccurred(), "UpdateMeasure with entity change must return an error")
+ gm.Expect(updateErr.Error()).Should(gm.ContainSubstring("entity"),
+ "the error message must reference 'entity'")
+
+ g.By("Verifying ModRevision is unchanged after rejected update")
+ afterResp, getAfterErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getAfterErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(afterResp.GetMeasure().GetMetadata().GetModRevision()).Should(
+ gm.Equal(baseModRev),
+ "ModRevision must be unchanged after a rejected entity-change update",
+ )
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.10: full measure content (entity, tag families, fields, created_at, updated_at, mod_revision)
+ // is unchanged after a rejected entity-change update.
+ g.It("leaves the full measure schema unchanged after a rejected entity-change update (§6.10)", func() {
+ groupName := fmt.Sprintf("sb2-measure-%d", time.Now().UnixNano())
+ measureName := "sb2_measure"
+
+ g.By("Creating measure group")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: shapeBreakMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure with entity=[svc]")
+ _, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: shapeBreakMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Getting measure to capture baseline state")
+ beforeResp, getBeforeErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getBeforeErr).ShouldNot(gm.HaveOccurred())
+ before := beforeResp.GetMeasure()
+
+ g.By("Attempting UpdateMeasure with entity=[svc, host] (entity change)")
+ changedMeasure := proto.Clone(before).(*databasev1.Measure)
+ changedMeasure.Entity = &databasev1.Entity{TagNames: []string{"svc", "host"}}
+ _, updateErr := clients.MeasureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+ Measure: changedMeasure,
+ })
+ gm.Expect(updateErr).Should(gm.HaveOccurred(), "UpdateMeasure with entity change must return an error")
+
+ g.By("Verifying the full measure schema is unchanged after rejected update")
+ afterResp, getAfterErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getAfterErr).ShouldNot(gm.HaveOccurred())
+ after := afterResp.GetMeasure()
+
+ gm.Expect(after.GetMetadata().GetModRevision()).Should(
+ gm.Equal(before.GetMetadata().GetModRevision()),
+ "ModRevision must be unchanged",
+ )
+ gm.Expect(after.GetEntity().GetTagNames()).Should(
+ gm.Equal(before.GetEntity().GetTagNames()),
+ "Entity must be unchanged",
+ )
+ gm.Expect(after.GetTagFamilies()).Should(
+ gm.HaveLen(len(before.GetTagFamilies())),
+ "TagFamilies count must be unchanged",
+ )
+ gm.Expect(after.GetFields()).Should(
+ gm.HaveLen(len(before.GetFields())),
+ "Fields count must be unchanged",
+ )
+ gm.Expect(after.GetCreatedAt()).ShouldNot(gm.BeNil())
+ if before.GetCreatedAt() != nil {
+ gm.Expect(after.GetCreatedAt().AsTime().UnixNano()).Should(
+ gm.Equal(before.GetCreatedAt().AsTime().UnixNano()),
+ "created_at must be unchanged after rejected update",
+ )
+ }
+ gm.Expect(after.GetUpdatedAt()).ShouldNot(gm.BeNil())
+ gm.Expect(after.GetUpdatedAt().AsTime().UnixNano()).Should(
+ gm.Equal(before.GetUpdatedAt().AsTime().UnixNano()),
+ "updated_at must be unchanged after rejected update",
+ )
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §6.11: delete-then-recreate original shape drops old data (Rule 7 clamp).
+ g.It("delete-then-recreate original shape drops old data (§6.11)", func() {
+ // TODO(phase-2): Phase 1 AwaitRevisionApplied is liaison-only by design — it confirms
+ // the liaison cache observes R2 but not that every data node has rebuilt the tsTable
+ // after the delete-then-recreate. The post-recreate Write+Query round-trip exercised
+ // by this spec races the data-node tsTable rebuild on slow CI runners. Cluster-wide
+ // barrier semantics ship in Phase 2 via NodeSchemaStatusService + liaison fan-out
+ // (plan Steps 2.1–2.2); re-enable this spec in distributed mode once those land.
+ if SharedContext.Mode == "distributed" {
+ g.Skip("§6.11 requires cluster-wide propagation barrier (Phase 2)")
+ }
+ groupName := fmt.Sprintf("sb-same-%d", time.Now().UnixNano())
+ measureName := "throughput"
+
+ g.By("Creating measure group")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: shapeBreakMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Creating measure throughput entity=[host] → R1")
+ createResp1, createErr1 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: throughputMeasureSpecV1(groupName, measureName),
+ })
+ gm.Expect(createErr1).ShouldNot(gm.HaveOccurred())
+ r1 := createResp1.GetModRevision()
+
+ awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+ gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+ getResp1, getErr1 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr1).ShouldNot(gm.HaveOccurred())
+ createdAt1 := getResp1.GetMeasure().GetCreatedAt()
+ gm.Expect(createdAt1).ShouldNot(gm.BeNil())
+
+ g.By("Writing data at T_data1 = CreatedAt1 + 50ms; verifying query returns that point")
+ // T_data1 uses wall-clock now rather than a future offset from CreatedAt1 so
+ // a subsequent recreate stamps CreatedAt2 strictly after this timestamp.
+ tData1 := timestamp.NowMilli()
+ hostTags := []*modelv1.TagFamilyForWrite{{
+ Tags: []*modelv1.TagValue{{
+ Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+ }},
+ }}
+ writeStatus1, writeErr1 := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName, tData1, r1, hostTags)
+ gm.Expect(writeErr1).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatus1).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()))
+
+ queryResp1, queryErr1 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+ createdAt1.AsTime(), time.Now().Add(time.Hour), r1)
+ gm.Expect(queryErr1).ShouldNot(gm.HaveOccurred())
+ gm.Expect(queryResp1.GetDataPoints()).Should(gm.HaveLen(1), "baseline: pre-delete query returns 1 data point")
+
+ g.By("Deleting measure → T_del; awaiting deletion")
+ deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+ tDel := deleteResp.GetDeleteTime()
+
+ awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+ gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Recreating same spec entity=[host] → R2; CreatedAt2 > T_del")
+ createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: throughputMeasureSpecV1(groupName, measureName),
+ })
+ gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+ r2 := createResp2.GetModRevision()
+
+ awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+ gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+ getResp2, getErr2 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr2).ShouldNot(gm.HaveOccurred())
+ got2 := getResp2.GetMeasure()
+ createdAt2 := got2.GetCreatedAt()
+ gm.Expect(createdAt2).ShouldNot(gm.BeNil())
+ gm.Expect(createdAt2.AsTime().UnixNano()).Should(gm.BeNumerically(">", tDel),
+ "CreatedAt2 must be after T_del")
+
+ g.By("Assertions: shape matches original, pre-delete data clamped, R2 write succeeds")
+ gm.Expect(got2.GetEntity().GetTagNames()).Should(gm.Equal([]string{"host"}),
+ "recreated measure must preserve original entity")
+
+ // Rule 7 clamp: query [T_data1, now+1h] returns empty because T_data1 < CreatedAt2.
+ queryResp2, queryErr2 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+ tData1, time.Now().Add(time.Hour), r2)
+ gm.Expect(queryErr2).ShouldNot(gm.HaveOccurred())
+ gm.Expect(queryResp2.GetDataPoints()).Should(gm.BeEmpty(),
+ "Rule 7 clamp must hide pre-delete data because T_data1 < CreatedAt2")
+
+ // Write with R2 must succeed.
+ tNewWrite := time.Now()
+ writeStatus2, writeErr2 := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName,
+ tNewWrite, r2, hostTags)
+ gm.Expect(writeErr2).ShouldNot(gm.HaveOccurred())
+ gm.Expect(writeStatus2).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+ "write with R2 must succeed")
+
+ // Post-write query [CreatedAt2, now+1h] must return the newly-written point.
+ queryResp3, queryErr3 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+ createdAt2.AsTime(), time.Now().Add(time.Hour), r2)
+ gm.Expect(queryErr3).ShouldNot(gm.HaveOccurred())
+ gm.Expect(queryResp3.GetDataPoints()).Should(gm.HaveLen(1),
+ "post-creation write must be queryable after AwaitRevision(R2)")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+})
diff --git a/test/cases/schema/tombstone_gc.go b/test/cases/schema/tombstone_gc.go
new file mode 100644
index 000000000..84e6e265b
--- /dev/null
+++ b/test/cases/schema/tombstone_gc.go
@@ -0,0 +1,187 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package schema contains shared test cases for schema-related functionality.
+//
+// §4.7.1 and §4.7.2 exercise tombstone retention behavior. The test suites wire
+// the standalone server with --schema-server-tombstone-retention=2s in the
+// BeforeSuite, so the specs run without requiring any environment variable.
+package schema
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+
+ commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+)
+
+// tombstoneRetentionForTest mirrors the --schema-server-tombstone-retention flag
+// configured in the integration suite setup. Keep in sync with common.go.
+const tombstoneRetentionForTest = 2 * time.Second
+
+// Schema tombstone GC specs — §4.7.1 / §4.7.2.
+// The integration suite wires the standalone server with
+// --schema-server-tombstone-retention=tombstoneRetentionForTest in BeforeSuite,
+// so these specs run without any environment-variable setup.
+var _ = g.Describe("Schema tombstone GC", func() {
+ var (
+ ctx context.Context
+ clients *Clients
+ )
+
+ g.BeforeEach(func() {
+ ctx = context.Background()
+ clients = NewClients(SharedContext.Connection)
+ })
+
+ // §4.7.1 — retains tombstone within retention window.
+ // The tombstone invariant (Step 1.3): server stamps updated_at = now() which is always
+ // after delete_time, so a normal recreate within the retention window always succeeds.
+ // This spec verifies that the recreate succeeds and CreatedAt2 is after T_del.
+ g.It("retains tombstone within retention window — recreate succeeds with fresh CreatedAt (§4.7.1)", func() {
+ retention := tombstoneRetentionForTest
+
+ groupName := fmt.Sprintf("tgc-retain-%d", time.Now().UnixNano())
+ measureName := "tgc_measure"
+
+ g.By("Creating measure group and measure")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+ r := createMeasureResp.GetModRevision()
+
+ awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Capturing T_create from GetMeasure.CreatedAt")
+ getResp, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(getResp.GetMeasure().GetCreatedAt()).ShouldNot(gm.BeNil())
+
+ g.By("Deleting measure → T_del; awaiting deletion")
+ deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(deleteResp.GetDeleted()).Should(gm.BeTrue())
+ tDel := deleteResp.GetDeleteTime()
+ gm.Expect(tDel).Should(gm.BeNumerically(">", int64(0)))
+
+ awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+ gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Waiting half the retention duration (within the window)")
+ time.Sleep(retention / 2)
+
+ // The server stamps updated_at = now() on create, which is always > T_del.
+ // Therefore the tombstone invariant is satisfied and the recreate succeeds.
+ g.By("Recreating same measure spec within retention window — must succeed")
+ createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createErr2).ShouldNot(gm.HaveOccurred(),
+ "recreate within retention window must succeed: server stamps updated_at = now() > T_del")
+
+ r2 := createResp2.GetModRevision()
+ awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+ gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+ g.By("Verifying CreatedAt2 is after T_del (Step 1.3 tombstone invariant)")
+ getResp2, getErr2 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(getErr2).ShouldNot(gm.HaveOccurred())
+ createdAt2 := getResp2.GetMeasure().GetCreatedAt()
+ gm.Expect(createdAt2).ShouldNot(gm.BeNil())
+ gm.Expect(createdAt2.AsTime().UnixNano()).Should(gm.BeNumerically(">", tDel),
+ "CreatedAt2 must be after T_del — Step 1.3 tombstone invariant")
+
+ _, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+ })
+
+ // §4.7.2 — physically removes tombstone after retention.
+ // After retention + 1s, the GC loop purges the tombstone. A recreate succeeds
+ // without any constraint from the (now-expired) tombstone.
+ g.It("physically removes tombstone after retention — recreate succeeds cleanly (§4.7.2)", func() {
+ retention := tombstoneRetentionForTest
+
+ groupName := fmt.Sprintf("tgc-gc-%d", time.Now().UnixNano())
+ measureName := "tgc_measure"
+
+ g.By("Creating measure group and measure")
+ _, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+ Group: arMeasureGroup(groupName),
+ })
+ gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+ createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+ Measure: arMeasureSpec(groupName, measureName),
+ })
+ gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+ r := createMeasureResp.GetModRevision()
+
+ awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+ gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+ g.By("Deleting measure → T_del; awaiting deletion")
+ deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+ Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+ })
+ gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+ gm.Expect(deleteResp.GetDeleted()).Should(gm.BeTrue())
+
... 451 lines suppressed ...