You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by li...@apache.org on 2026/09/23 05:58:51 UTC
(skywalking-banyandb) 01/01: Extend `databasev1.Node` to support report file format versions and timezone
This is an automated email from the ASF dual-hosted git repository.
mrproliu pushed a commit to branch node-ext
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
commit 1a6ae0bcdf364112e652167d328f876d8cfa41a7
Author: mrproliu <74...@qq.com>
AuthorDate: Wed Sep 23 13:58:30 2026 +0800
Extend `databasev1.Node` to support report file format versions and timezone
---
CHANGES.md | 1 +
Makefile | 4 +
api/common/id.go | 14 +-
api/common/id_test.go | 54 +++++++
api/proto/banyandb/database/v1/database.proto | 7 +
banyand/backup/lifecycle/service.go | 15 +-
banyand/internal/storage/version.go | 34 +----
docs/api-reference.md | 2 +
pkg/fileformat/version.go | 64 ++++++++
pkg/fileformat/version_test.go | 28 ++++
.../storage => pkg/fileformat}/versions.yml | 0
pkg/host/timezone.go | 33 ++++
pkg/host/timezone_cldr_windows.go | 168 +++++++++++++++++++++
pkg/host/timezone_unix.go | 82 ++++++++++
pkg/host/timezone_unix_test.go | 121 +++++++++++++++
pkg/host/timezone_windows.go | 51 +++++++
.../distributed/cluster_state/common.go | 41 +++++
.../integration/standalone/other/node_info_test.go | 79 ++++++++++
18 files changed, 758 insertions(+), 40 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 8b2fa8700..9def365a2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -13,6 +13,7 @@ Release Notes.
- Push the criteria tag filter ahead of the vectorized stream merge, so a filtered index-order query bounds its merge at limit+offset. For such a query, where several rows share an ElementID, the criteria is now evaluated first and the element is represented by its first matching row in the requested sort order. Filtered timestamp-order queries are unchanged.
- `measure.v1.QueryRequest.Aggregation` can now target a tag (`tag_name` + `tag_family`) instead of a field: `SUM`/`MIN`/`MAX`/`MEAN` over an `INT` tag, and `COUNT` over any scalar tag, in both standalone and distributed queries. Wire-additive only — existing clients that only ever set `field_name` are unaffected. `COUNT_DISTINCT` is not yet implemented; see the [tag aggregation and time bucketing design](docs/design/0.12.0/tag-aggregation/README.md).
- `measure.v1.QueryRequest.GroupBy.time_bucket` groups a measure query by tumbling time windows, alone or combined with a tag `GroupBy`, in both standalone and distributed queries. The bucket width comes from the request or falls back to the measure's own `interval`. The part-scan path streams bucket-by-bucket so live memory never exceeds one bucket's groups; index-mode measures (no ascending-timestamp guarantee) use a bounded non-streaming fallback instead. A mixed-version cluster where [...]
+- `databasev1.Node` now reports its supported file format versions and IANA time zone name.
### Bug Fixes
diff --git a/Makefile b/Makefile
index 369258c73..6ff4942ac 100644
--- a/Makefile
+++ b/Makefile
@@ -246,6 +246,10 @@ check-import-boundaries: ## Enforce import-boundary invariants for the version-i
echo "FAIL: pkg/initerror/ must remain a leaf with zero project-internal imports (test files excepted)"; \
bad=1; \
fi; \
+ if grep -rln 'github.com/apache/skywalking-banyandb/' pkg/fileformat/*.go 2>/dev/null | grep -v _test.go; then \
+ echo "FAIL: pkg/fileformat/ must remain a leaf with zero project-internal imports (test files excepted)"; \
+ bad=1; \
+ fi; \
if [ $$bad -ne 0 ]; then exit 1; fi; \
echo "import boundaries OK"
diff --git a/api/common/id.go b/api/common/id.go
index 07f2f4913..1152bfeae 100644
--- a/api/common/id.go
+++ b/api/common/id.go
@@ -27,11 +27,14 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
+ apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
+ clusterv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/cluster/v1"
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"
"github.com/apache/skywalking-banyandb/pkg/convert"
"github.com/apache/skywalking-banyandb/pkg/encoding"
+ "github.com/apache/skywalking-banyandb/pkg/fileformat"
"github.com/apache/skywalking-banyandb/pkg/host"
)
@@ -163,7 +166,10 @@ type Node struct {
PropertySchemaGossipGrpcAddress string
}
-// ToProtoNode converts a Node to a databasev1.Node with the given roles.
+// ToProtoNode converts a Node to a databasev1.Node with the given roles. The
+// version and time zone describe the running process, so consumers of the node
+// registry can tell which file formats the node reads and which segment
+// boundaries its timestamps fall on.
func (n Node) ToProtoNode(roles []databasev1.Role) *databasev1.Node {
return &databasev1.Node{
Metadata: &commonv1.Metadata{
@@ -177,6 +183,12 @@ func (n Node) ToProtoNode(roles []databasev1.Role) *databasev1.Node {
PropertyRepairGossipGrpcAddress: n.PropertyGossipGrpcAddress,
PropertySchemaGrpcAddress: n.PropertySchemaGrpcAddress,
PropertySchemaGossipGrpcAddress: n.PropertySchemaGossipGrpcAddress,
+ Version: &clusterv1.VersionInfo{
+ ApiVersion: apiversion.Version,
+ FileFormatVersion: fileformat.CurrentVersion,
+ CompatibleFileFormatVersion: fileformat.CompatibleVersions(),
+ },
+ TzName: host.TimeZoneName(),
}
}
diff --git a/api/common/id_test.go b/api/common/id_test.go
new file mode 100644
index 000000000..d314f6f00
--- /dev/null
+++ b/api/common/id_test.go
@@ -0,0 +1,54 @@
+// 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 common
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ "github.com/apache/skywalking-banyandb/pkg/fileformat"
+)
+
+func TestToProtoNodeReportsVersionAndTimeZone(t *testing.T) {
+ t.Setenv("TZ", "Asia/Shanghai")
+ n := Node{NodeID: "10.0.0.5:17912", GrpcAddress: "10.0.0.5:17912"}
+
+ pn := n.ToProtoNode([]databasev1.Role{databasev1.Role_ROLE_DATA})
+
+ require.NotNil(t, pn.Version)
+ assert.Equal(t, apiversion.Version, pn.Version.ApiVersion)
+ assert.Equal(t, fileformat.CurrentVersion, pn.Version.FileFormatVersion)
+ // The gate tests membership in this list, so a node that reports a list its own
+ // version is missing from would reject data it can actually read.
+ assert.Contains(t, pn.Version.CompatibleFileFormatVersion, fileformat.CurrentVersion)
+ assert.Equal(t, "Asia/Shanghai", pn.TzName)
+}
+
+// The gate on the import side rejects a unit when the two sides disagree, so an
+// unresolvable zone has to arrive as an empty name rather than as "Local".
+func TestToProtoNodeLeavesTimeZoneEmptyWhenUnresolvable(t *testing.T) {
+ t.Setenv("TZ", "Mars/Olympus_Mons")
+
+ pn := Node{NodeID: "10.0.0.5:17912"}.ToProtoNode(nil)
+
+ assert.Empty(t, pn.TzName)
+}
diff --git a/api/proto/banyandb/database/v1/database.proto b/api/proto/banyandb/database/v1/database.proto
index a564eb157..881a2edf9 100644
--- a/api/proto/banyandb/database/v1/database.proto
+++ b/api/proto/banyandb/database/v1/database.proto
@@ -19,6 +19,7 @@ syntax = "proto3";
package banyandb.database.v1;
+import "banyandb/cluster/v1/rpc.proto";
import "banyandb/common/v1/common.proto";
import "google/protobuf/timestamp.proto";
@@ -43,6 +44,12 @@ message Node {
string property_repair_gossip_grpc_address = 7;
string property_schema_grpc_address = 8;
string property_schema_gossip_grpc_address = 9;
+ // version reports the file format versions the node accepts and its API version.
+ cluster.v1.VersionInfo version = 10;
+ // tz_name is the IANA name of the node's local time zone, for example
+ // "Asia/Shanghai". It is empty when the node cannot determine the name, which
+ // means "unknown" rather than "matches whatever the other side reports".
+ string tz_name = 11;
}
message Shard {
diff --git a/banyand/backup/lifecycle/service.go b/banyand/backup/lifecycle/service.go
index dbd8858d6..450dd184f 100644
--- a/banyand/backup/lifecycle/service.go
+++ b/banyand/backup/lifecycle/service.go
@@ -41,7 +41,6 @@ import (
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
- "google.golang.org/protobuf/types/known/timestamppb"
"github.com/apache/skywalking-banyandb/api/common"
commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
@@ -245,16 +244,14 @@ func (l *lifecycleService) Validate() error {
return errors.New("missing key file when TLS is enabled")
}
}
- l.currentNode = &databasev1.Node{
- Metadata: &commonv1.Metadata{
- Name: l.lifecycleGRPCAddr,
- },
+ // Built through ToProtoNode so this node reports the same file format
+ // version and time zone as every other one; GetCurrentNode serves it.
+ l.currentNode = common.Node{
+ NodeID: l.lifecycleGRPCAddr,
GrpcAddress: l.lifecycleGRPCAddr,
- HttpAddress: l.lifecycleHTTPAddr,
- Roles: make([]databasev1.Role, 0),
+ HTTPAddress: l.lifecycleHTTPAddr,
Labels: common.ParseNodeFlags(),
- CreatedAt: timestamppb.Now(),
- }
+ }.ToProtoNode(nil)
}
policy, err := parseOrphanPolicy(l.orphanPolicyStr)
if err != nil {
diff --git a/banyand/internal/storage/version.go b/banyand/internal/storage/version.go
index 29648c261..00071710c 100644
--- a/banyand/internal/storage/version.go
+++ b/banyand/internal/storage/version.go
@@ -18,21 +18,18 @@
package storage
import (
- "embed"
"encoding/json"
"strings"
"github.com/pkg/errors"
- "sigs.k8s.io/yaml"
+ "github.com/apache/skywalking-banyandb/pkg/fileformat"
"github.com/apache/skywalking-banyandb/pkg/initerror"
)
const (
- metadataFilename = "metadata"
- currentVersion = "1.5.0"
- compatibleVersionsKey = "versions"
- compatibleVersionsFilename = "versions.yml"
+ metadataFilename = "metadata"
+ currentVersion = fileformat.CurrentVersion
)
// SegmentMetadataFilename is the filename used for the per-segment
@@ -56,10 +53,7 @@ type SegmentMetadata struct {
var errVersionIncompatible = errors.New("version not compatible")
-var compatibleVersions = readCompatibleVersions()
-
-//go:embed versions.yml
-var versionFS embed.FS
+var compatibleVersions = fileformat.CompatibleVersions()
func checkVersion(version string) error {
for _, v := range compatibleVersions {
@@ -92,26 +86,6 @@ func readSegmentMeta(data []byte) (segmentMeta, error) {
return meta, nil
}
-func readCompatibleVersions() []string {
- i, err := versionFS.ReadFile(compatibleVersionsFilename)
- if err != nil {
- panic(err)
- }
- j, err := yaml.YAMLToJSON(i)
- if err != nil {
- panic(err)
- }
- var compatibleVersions map[string][]string
- if err := json.Unmarshal(j, &compatibleVersions); err != nil {
- panic(err)
- }
- vv, ok := compatibleVersions[compatibleVersionsKey]
- if !ok {
- panic("versions not found")
- }
- return vv
-}
-
// GetCurrentVersion returns the current storage version.
func GetCurrentVersion() string {
return currentVersion
diff --git a/docs/api-reference.md b/docs/api-reference.md
index a94360ae1..ad83512bc 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -2952,6 +2952,8 @@ Service is the service for the API
| property_repair_gossip_grpc_address | [string](#string) | | |
| property_schema_grpc_address | [string](#string) | | |
| property_schema_gossip_grpc_address | [string](#string) | | |
+| version | [banyandb.cluster.v1.VersionInfo](#banyandb-cluster-v1-VersionInfo) | | version reports the file format versions the node accepts and its API version. |
+| tz_name | [string](#string) | | tz_name is the IANA name of the node's local time zone, for example "Asia/Shanghai". It is empty when the node cannot determine the name, which means "unknown" rather than "matches whatever the other side reports". |
diff --git a/pkg/fileformat/version.go b/pkg/fileformat/version.go
new file mode 100644
index 000000000..29827ff36
--- /dev/null
+++ b/pkg/fileformat/version.go
@@ -0,0 +1,64 @@
+// 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 fileformat holds the on-disk segment file format version line. It is a
+// leaf package so that both the storage engine and the low-level node types in
+// api/common can report the same versions without importing each other.
+package fileformat
+
+import (
+ "embed"
+ "encoding/json"
+ "slices"
+
+ "sigs.k8s.io/yaml"
+)
+
+// CurrentVersion is the version string a freshly-written segment must carry.
+const CurrentVersion = "1.5.0"
+
+//go:embed versions.yml
+var versionFS embed.FS
+
+var compatibleVersions = readCompatibleVersions()
+
+// CompatibleVersions returns every segment file format version this build reads.
+// The result is a copy: callers hand it straight to proto messages, and one of
+// them appending to it would rewrite the list for the whole process.
+func CompatibleVersions() []string {
+ return slices.Clone(compatibleVersions)
+}
+
+func readCompatibleVersions() []string {
+ i, err := versionFS.ReadFile("versions.yml")
+ if err != nil {
+ panic(err)
+ }
+ j, err := yaml.YAMLToJSON(i)
+ if err != nil {
+ panic(err)
+ }
+ var versions map[string][]string
+ if err := json.Unmarshal(j, &versions); err != nil {
+ panic(err)
+ }
+ vv, ok := versions["versions"]
+ if !ok {
+ panic("versions not found")
+ }
+ return vv
+}
diff --git a/pkg/fileformat/version_test.go b/pkg/fileformat/version_test.go
new file mode 100644
index 000000000..f6bd3929e
--- /dev/null
+++ b/pkg/fileformat/version_test.go
@@ -0,0 +1,28 @@
+// 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 fileformat
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCurrentVersionIsCompatible(t *testing.T) {
+ assert.Contains(t, CompatibleVersions(), CurrentVersion)
+}
diff --git a/banyand/internal/storage/versions.yml b/pkg/fileformat/versions.yml
similarity index 100%
rename from banyand/internal/storage/versions.yml
rename to pkg/fileformat/versions.yml
diff --git a/pkg/host/timezone.go b/pkg/host/timezone.go
new file mode 100644
index 000000000..226c7eb10
--- /dev/null
+++ b/pkg/host/timezone.go
@@ -0,0 +1,33 @@
+// 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 host
+
+// TimeZoneName returns the IANA name of the host's local time zone, such as
+// "Asia/Shanghai", or an empty string when the real name cannot be determined.
+// An empty name means "unknown": callers comparing two hosts must treat it as a
+// mismatch rather than as agreement.
+//
+// There is no portable way to ask for this. The Go runtime resolves the local
+// zone but never exposes its name — time.Local.String() reports the literal
+// "Local" whenever TZ is unset, which is the normal state inside a container,
+// so two hosts in different zones would look identical. Each platform therefore
+// resolves the name the same way its own runtime resolves the zone: see
+// localZoneName in timezone_unix.go and timezone_windows.go.
+func TimeZoneName() string {
+ return localZoneName()
+}
diff --git a/pkg/host/timezone_cldr_windows.go b/pkg/host/timezone_cldr_windows.go
new file mode 100644
index 000000000..d024ca781
--- /dev/null
+++ b/pkg/host/timezone_cldr_windows.go
@@ -0,0 +1,168 @@
+// 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 host
+
+// windowsToIANA maps a Windows time zone ID, as the registry reports it, to the
+// IANA name CLDR designates as its default. Windows has no IANA zones of its
+// own, so this table is the only way to answer with a name the rest of the
+// cluster understands.
+//
+// Extracted from the territory="001" rows of the Unicode CLDR
+// supplemental/windowsZones.xml, typeVersion 2021a. Refresh it from that file
+// rather than by hand.
+var windowsToIANA = map[string]string{
+ "AUS Central Standard Time": "Australia/Darwin",
+ "AUS Eastern Standard Time": "Australia/Sydney",
+ "Afghanistan Standard Time": "Asia/Kabul",
+ "Alaskan Standard Time": "America/Anchorage",
+ "Aleutian Standard Time": "America/Adak",
+ "Altai Standard Time": "Asia/Barnaul",
+ "Arab Standard Time": "Asia/Riyadh",
+ "Arabian Standard Time": "Asia/Dubai",
+ "Arabic Standard Time": "Asia/Baghdad",
+ "Argentina Standard Time": "America/Buenos_Aires",
+ "Astrakhan Standard Time": "Europe/Astrakhan",
+ "Atlantic Standard Time": "America/Halifax",
+ "Aus Central W. Standard Time": "Australia/Eucla",
+ "Azerbaijan Standard Time": "Asia/Baku",
+ "Azores Standard Time": "Atlantic/Azores",
+ "Bahia Standard Time": "America/Bahia",
+ "Bangladesh Standard Time": "Asia/Dhaka",
+ "Belarus Standard Time": "Europe/Minsk",
+ "Bougainville Standard Time": "Pacific/Bougainville",
+ "Canada Central Standard Time": "America/Regina",
+ "Cape Verde Standard Time": "Atlantic/Cape_Verde",
+ "Caucasus Standard Time": "Asia/Yerevan",
+ "Cen. Australia Standard Time": "Australia/Adelaide",
+ "Central America Standard Time": "America/Guatemala",
+ "Central Asia Standard Time": "Asia/Bishkek",
+ "Central Brazilian Standard Time": "America/Cuiaba",
+ "Central Europe Standard Time": "Europe/Budapest",
+ "Central European Standard Time": "Europe/Warsaw",
+ "Central Pacific Standard Time": "Pacific/Guadalcanal",
+ "Central Standard Time": "America/Chicago",
+ "Central Standard Time (Mexico)": "America/Mexico_City",
+ "Chatham Islands Standard Time": "Pacific/Chatham",
+ "China Standard Time": "Asia/Shanghai",
+ "Cuba Standard Time": "America/Havana",
+ "Dateline Standard Time": "Etc/GMT+12",
+ "E. Africa Standard Time": "Africa/Nairobi",
+ "E. Australia Standard Time": "Australia/Brisbane",
+ "E. Europe Standard Time": "Europe/Chisinau",
+ "E. South America Standard Time": "America/Sao_Paulo",
+ "Easter Island Standard Time": "Pacific/Easter",
+ "Eastern Standard Time": "America/New_York",
+ "Eastern Standard Time (Mexico)": "America/Cancun",
+ "Egypt Standard Time": "Africa/Cairo",
+ "Ekaterinburg Standard Time": "Asia/Yekaterinburg",
+ "FLE Standard Time": "Europe/Kiev",
+ "Fiji Standard Time": "Pacific/Fiji",
+ "GMT Standard Time": "Europe/London",
+ "GTB Standard Time": "Europe/Bucharest",
+ "Georgian Standard Time": "Asia/Tbilisi",
+ "Greenland Standard Time": "America/Godthab",
+ "Greenwich Standard Time": "Atlantic/Reykjavik",
+ "Haiti Standard Time": "America/Port-au-Prince",
+ "Hawaiian Standard Time": "Pacific/Honolulu",
+ "India Standard Time": "Asia/Calcutta",
+ "Iran Standard Time": "Asia/Tehran",
+ "Israel Standard Time": "Asia/Jerusalem",
+ "Jordan Standard Time": "Asia/Amman",
+ "Kaliningrad Standard Time": "Europe/Kaliningrad",
+ "Korea Standard Time": "Asia/Seoul",
+ "Libya Standard Time": "Africa/Tripoli",
+ "Line Islands Standard Time": "Pacific/Kiritimati",
+ "Lord Howe Standard Time": "Australia/Lord_Howe",
+ "Magadan Standard Time": "Asia/Magadan",
+ "Magallanes Standard Time": "America/Punta_Arenas",
+ "Marquesas Standard Time": "Pacific/Marquesas",
+ "Mauritius Standard Time": "Indian/Mauritius",
+ "Middle East Standard Time": "Asia/Beirut",
+ "Montevideo Standard Time": "America/Montevideo",
+ "Morocco Standard Time": "Africa/Casablanca",
+ "Mountain Standard Time": "America/Denver",
+ "Mountain Standard Time (Mexico)": "America/Mazatlan",
+ "Myanmar Standard Time": "Asia/Rangoon",
+ "N. Central Asia Standard Time": "Asia/Novosibirsk",
+ "Namibia Standard Time": "Africa/Windhoek",
+ "Nepal Standard Time": "Asia/Katmandu",
+ "New Zealand Standard Time": "Pacific/Auckland",
+ "Newfoundland Standard Time": "America/St_Johns",
+ "Norfolk Standard Time": "Pacific/Norfolk",
+ "North Asia East Standard Time": "Asia/Irkutsk",
+ "North Asia Standard Time": "Asia/Krasnoyarsk",
+ "North Korea Standard Time": "Asia/Pyongyang",
+ "Omsk Standard Time": "Asia/Omsk",
+ "Pacific SA Standard Time": "America/Santiago",
+ "Pacific Standard Time": "America/Los_Angeles",
+ "Pacific Standard Time (Mexico)": "America/Tijuana",
+ "Pakistan Standard Time": "Asia/Karachi",
+ "Paraguay Standard Time": "America/Asuncion",
+ "Qyzylorda Standard Time": "Asia/Qyzylorda",
+ "Romance Standard Time": "Europe/Paris",
+ "Russia Time Zone 10": "Asia/Srednekolymsk",
+ "Russia Time Zone 11": "Asia/Kamchatka",
+ "Russia Time Zone 3": "Europe/Samara",
+ "Russian Standard Time": "Europe/Moscow",
+ "SA Eastern Standard Time": "America/Cayenne",
+ "SA Pacific Standard Time": "America/Bogota",
+ "SA Western Standard Time": "America/La_Paz",
+ "SE Asia Standard Time": "Asia/Bangkok",
+ "Saint Pierre Standard Time": "America/Miquelon",
+ "Sakhalin Standard Time": "Asia/Sakhalin",
+ "Samoa Standard Time": "Pacific/Apia",
+ "Sao Tome Standard Time": "Africa/Sao_Tome",
+ "Saratov Standard Time": "Europe/Saratov",
+ "Singapore Standard Time": "Asia/Singapore",
+ "South Africa Standard Time": "Africa/Johannesburg",
+ "South Sudan Standard Time": "Africa/Juba",
+ "Sri Lanka Standard Time": "Asia/Colombo",
+ "Sudan Standard Time": "Africa/Khartoum",
+ "Syria Standard Time": "Asia/Damascus",
+ "Taipei Standard Time": "Asia/Taipei",
+ "Tasmania Standard Time": "Australia/Hobart",
+ "Tocantins Standard Time": "America/Araguaina",
+ "Tokyo Standard Time": "Asia/Tokyo",
+ "Tomsk Standard Time": "Asia/Tomsk",
+ "Tonga Standard Time": "Pacific/Tongatapu",
+ "Transbaikal Standard Time": "Asia/Chita",
+ "Turkey Standard Time": "Europe/Istanbul",
+ "Turks And Caicos Standard Time": "America/Grand_Turk",
+ "US Eastern Standard Time": "America/Indianapolis",
+ "US Mountain Standard Time": "America/Phoenix",
+ "UTC": "Etc/UTC",
+ "UTC+12": "Etc/GMT-12",
+ "UTC+13": "Etc/GMT-13",
+ "UTC-02": "Etc/GMT+2",
+ "UTC-08": "Etc/GMT+8",
+ "UTC-09": "Etc/GMT+9",
+ "UTC-11": "Etc/GMT+11",
+ "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
+ "Venezuela Standard Time": "America/Caracas",
+ "Vladivostok Standard Time": "Asia/Vladivostok",
+ "Volgograd Standard Time": "Europe/Volgograd",
+ "W. Australia Standard Time": "Australia/Perth",
+ "W. Central Africa Standard Time": "Africa/Lagos",
+ "W. Europe Standard Time": "Europe/Berlin",
+ "W. Mongolia Standard Time": "Asia/Hovd",
+ "West Asia Standard Time": "Asia/Tashkent",
+ "West Bank Standard Time": "Asia/Hebron",
+ "West Pacific Standard Time": "Pacific/Port_Moresby",
+ "Yakutsk Standard Time": "Asia/Yakutsk",
+ "Yukon Standard Time": "America/Whitehorse",
+}
diff --git a/pkg/host/timezone_unix.go b/pkg/host/timezone_unix.go
new file mode 100644
index 000000000..bec0bcdae
--- /dev/null
+++ b/pkg/host/timezone_unix.go
@@ -0,0 +1,82 @@
+// 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.
+
+//go:build !windows
+
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// localtimePath is the symlink the Go runtime itself consults when TZ is unset.
+// It is a variable so tests can point it at a fixture.
+var localtimePath = "/etc/localtime"
+
+// zoneinfoMarker is the last path element shared by every zoneinfo database
+// layout: /usr/share/zoneinfo on Linux, /var/db/timezone/zoneinfo on macOS.
+const zoneinfoMarker = "zoneinfo"
+
+// localZoneName reads the zone the way the Go runtime does on this platform:
+// TZ when it is set, otherwise the /etc/localtime link.
+func localZoneName() string {
+ if tz, ok := os.LookupEnv("TZ"); ok {
+ // Read TZ the way the Go runtime does: an empty value means UTC, a leading
+ // colon is stripped, and an absolute path names a zone file rather than a
+ // zone, so the name has to be recovered from the path.
+ name := strings.TrimPrefix(tz, ":")
+ if name == "" {
+ return "UTC"
+ }
+ if strings.HasPrefix(name, "/") {
+ name = zoneNameFromPath(name)
+ }
+ return validZoneName(name)
+ }
+ // EvalSymlinks rather than Readlink: it follows a chain of links, and on a
+ // plain file it yields that file's own path, which carries no zone name.
+ target, err := filepath.EvalSymlinks(localtimePath)
+ if err != nil {
+ return ""
+ }
+ return validZoneName(zoneNameFromPath(target))
+}
+
+// zoneNameFromPath extracts the part of a zoneinfo file path that follows the
+// zoneinfo directory, which is exactly the IANA name.
+func zoneNameFromPath(path string) string {
+ segments := strings.Split(filepath.Clean(path), "/")
+ for i := len(segments) - 1; i >= 0; i-- {
+ if segments[i] == zoneinfoMarker {
+ return strings.Join(segments[i+1:], "/")
+ }
+ }
+ return ""
+}
+
+func validZoneName(name string) string {
+ if name == "" {
+ return ""
+ }
+ if _, err := time.LoadLocation(name); err != nil {
+ return ""
+ }
+ return name
+}
diff --git a/pkg/host/timezone_unix_test.go b/pkg/host/timezone_unix_test.go
new file mode 100644
index 000000000..d11398fe3
--- /dev/null
+++ b/pkg/host/timezone_unix_test.go
@@ -0,0 +1,121 @@
+// 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.
+
+//go:build !windows
+
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTimeZoneNameFromEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ tz string
+ want string
+ }{
+ {name: "iana name", tz: "Asia/Shanghai", want: "Asia/Shanghai"},
+ {name: "leading colon", tz: ":Europe/Paris", want: "Europe/Paris"},
+ {name: "utc", tz: "UTC", want: "UTC"},
+ {name: "empty means utc", tz: "", want: "UTC"},
+ {name: "absolute path names a zone file", tz: "/usr/share/zoneinfo/Asia/Tokyo", want: "Asia/Tokyo"},
+ {name: "colon before absolute path", tz: ":/var/db/timezone/zoneinfo/Europe/Paris", want: "Europe/Paris"},
+ // A POSIX offset string is not an IANA name, and the runtime silently falls
+ // back to UTC for it, so the node must not claim the zone it spells out.
+ {name: "posix offset yields no claim", tz: "CST-8", want: ""},
+ {name: "unknown name yields no claim", tz: "Mars/Olympus_Mons", want: ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("TZ", tt.tz)
+ assert.Equal(t, tt.want, TimeZoneName())
+ })
+ }
+}
+
+func TestTimeZoneNameFromLocaltime(t *testing.T) {
+ tests := []struct {
+ name string
+ zonePath string
+ chained bool
+ plain bool
+ want string
+ }{
+ {name: "linux layout", zonePath: "usr/share/zoneinfo/Asia/Shanghai", want: "Asia/Shanghai"},
+ {name: "macos layout", zonePath: "var/db/timezone/zoneinfo/Europe/Paris", want: "Europe/Paris"},
+ {name: "nested region", zonePath: "usr/share/zoneinfo/America/Argentina/Buenos_Aires", want: "America/Argentina/Buenos_Aires"},
+ {name: "chain of symlinks", zonePath: "usr/share/zoneinfo/America/New_York", chained: true, want: "America/New_York"},
+ // busybox and friends copy the zone data to /etc/localtime instead of linking
+ // it, which leaves the name nowhere on disk.
+ {name: "plain file outside zoneinfo", zonePath: "etc/localtime", plain: true, want: ""},
+ // A copy that happens to sit under a zoneinfo tree still names its zone.
+ {name: "plain file under zoneinfo", zonePath: "usr/share/zoneinfo/Asia/Shanghai", plain: true, want: "Asia/Shanghai"},
+ {name: "link outside zoneinfo", zonePath: "etc/localtime-copy", want: ""},
+ {name: "unknown zone", zonePath: "usr/share/zoneinfo/Mars/Olympus_Mons", want: ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ withoutTZ(t)
+ root := t.TempDir()
+ target := filepath.Join(root, tt.zonePath)
+ require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o750))
+ require.NoError(t, os.WriteFile(target, nil, 0o600))
+ if tt.plain {
+ withLocaltimePath(t, target)
+ assert.Equal(t, tt.want, TimeZoneName())
+ return
+ }
+ if tt.chained {
+ mid := filepath.Join(root, "middle-link")
+ require.NoError(t, os.Symlink(target, mid))
+ target = mid
+ }
+ link := filepath.Join(root, "localtime")
+ require.NoError(t, os.Symlink(target, link))
+ withLocaltimePath(t, link)
+ assert.Equal(t, tt.want, TimeZoneName())
+ })
+ }
+}
+
+// A container without TZ and without /etc/localtime must report no name at all:
+// reporting "Local" here would make two nodes in different zones compare equal.
+func TestTimeZoneNameUnresolvable(t *testing.T) {
+ withoutTZ(t)
+ withLocaltimePath(t, filepath.Join(t.TempDir(), "absent"))
+ assert.Empty(t, TimeZoneName())
+}
+
+func withoutTZ(t *testing.T) {
+ t.Helper()
+ // t.Setenv registers the restore; Unsetenv afterwards leaves TZ unset for the test.
+ t.Setenv("TZ", "")
+ require.NoError(t, os.Unsetenv("TZ"))
+}
+
+func withLocaltimePath(t *testing.T, path string) {
+ t.Helper()
+ original := localtimePath
+ localtimePath = path
+ t.Cleanup(func() { localtimePath = original })
+}
diff --git a/pkg/host/timezone_windows.go b/pkg/host/timezone_windows.go
new file mode 100644
index 000000000..87059717f
--- /dev/null
+++ b/pkg/host/timezone_windows.go
@@ -0,0 +1,51 @@
+// 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 host
+
+import (
+ "golang.org/x/sys/windows/registry"
+)
+
+// tzInfoKey is where Windows records the zone the whole machine runs on. It is a
+// variable so tests can point it at a fixture key.
+var tzInfoKey = `SYSTEM\CurrentControlSet\Control\TimeZoneInformation`
+
+// localZoneName reads the Windows zone ID from the registry and translates it to
+// an IANA name.
+//
+// It deliberately ignores TZ: the Go runtime's initLocal on Windows calls
+// GetTimeZoneInformation and never looks at TZ, so honouring it here would name
+// a zone the process is not actually running in. It also does not validate the
+// result with time.LoadLocation, because Windows ships no zoneinfo database —
+// the CLDR table is what makes a Windows ID mean exactly one IANA zone.
+func localZoneName() string {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, tzInfoKey, registry.QUERY_VALUE)
+ if err != nil {
+ return ""
+ }
+ defer func() {
+ _ = k.Close()
+ }()
+ // TimeZoneKeyName is the invariant zone ID. StandardName sits next to it but is
+ // localised, so it cannot be looked up in the CLDR table.
+ id, _, err := k.GetStringValue("TimeZoneKeyName")
+ if err != nil {
+ return ""
+ }
+ return windowsToIANA[id]
+}
diff --git a/test/integration/distributed/cluster_state/common.go b/test/integration/distributed/cluster_state/common.go
index e8ba6c5dd..aa1927000 100644
--- a/test/integration/distributed/cluster_state/common.go
+++ b/test/integration/distributed/cluster_state/common.go
@@ -28,8 +28,11 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
+ apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ "github.com/apache/skywalking-banyandb/pkg/fileformat"
"github.com/apache/skywalking-banyandb/pkg/grpchelper"
+ "github.com/apache/skywalking-banyandb/pkg/host"
"github.com/apache/skywalking-banyandb/pkg/logger"
"github.com/apache/skywalking-banyandb/pkg/test"
"github.com/apache/skywalking-banyandb/pkg/test/flags"
@@ -86,6 +89,44 @@ var _ = ginkgo.Describe("ClusterState API", func() {
gomega.Expect(state.GetRouteTables()).To(gomega.HaveKey("tire1"))
gomega.Expect(state.GetRouteTables()).To(gomega.HaveKey("tire2"))
})
+
+ // The import gates read the file format version and the time zone of every
+ // data node out of this one call, so the fields have to survive registration
+ // and show up on the liaison's tire2 table, not just on the node itself.
+ ginkgo.It("Report the file format version and the time zone of each data node", func() {
+ client := databasev1.NewClusterStateServiceClient(liaisonConnection)
+ var dataNodes []*databasev1.Node
+ gomega.Eventually(func(g gomega.Gomega) {
+ state, err := client.GetClusterState(context.Background(), &databasev1.GetClusterStateRequest{})
+ g.Expect(err).NotTo(gomega.HaveOccurred())
+ dataNodes = state.GetRouteTables()["tire2"].GetRegistered()
+ g.Expect(dataNodes).NotTo(gomega.BeEmpty())
+ }, flags.EventuallyTimeout).Should(gomega.Succeed())
+ for _, n := range dataNodes {
+ name := n.GetMetadata().GetName()
+ gomega.Expect(n.GetRoles()).To(gomega.ContainElement(databasev1.Role_ROLE_DATA), "node %s in tire2 is not a data node", name)
+ gomega.Expect(n.GetVersion()).NotTo(gomega.BeNil(), "node %s reports no version", name)
+ gomega.Expect(n.GetVersion().GetFileFormatVersion()).To(gomega.Equal(fileformat.CurrentVersion))
+ gomega.Expect(n.GetVersion().GetCompatibleFileFormatVersion()).To(gomega.ContainElement(fileformat.CurrentVersion))
+ gomega.Expect(n.GetVersion().GetApiVersion()).To(gomega.Equal(apiversion.Version))
+ // An unresolved zone would make both sides empty and the comparison
+ // vacuous, so require a name first. CI pins the zone per matrix entry.
+ gomega.Expect(n.GetTzName()).NotTo(gomega.BeEmpty(), "node %s resolved no time zone; set TZ or /etc/localtime", name)
+ gomega.Expect(n.GetTzName()).To(gomega.Equal(host.TimeZoneName()))
+ }
+ })
+
+ // GetCurrentNode answers for the liaison itself, and it carries the same two
+ // fields because every node type goes through ToProtoNode.
+ ginkgo.It("Report the file format version and the time zone on the current node", func() {
+ client := databasev1.NewNodeQueryServiceClient(liaisonConnection)
+ resp, err := client.GetCurrentNode(context.Background(), &databasev1.GetCurrentNodeRequest{})
+ gomega.Expect(err).NotTo(gomega.HaveOccurred())
+ gomega.Expect(resp.GetNode().GetVersion().GetFileFormatVersion()).To(gomega.Equal(fileformat.CurrentVersion))
+ gomega.Expect(resp.GetNode().GetVersion().GetApiVersion()).To(gomega.Equal(apiversion.Version))
+ gomega.Expect(resp.GetNode().GetTzName()).NotTo(gomega.BeEmpty())
+ gomega.Expect(resp.GetNode().GetTzName()).To(gomega.Equal(host.TimeZoneName()))
+ })
})
var _ = ginkgo.SynchronizedAfterSuite(func() {
diff --git a/test/integration/standalone/other/node_info_test.go b/test/integration/standalone/other/node_info_test.go
new file mode 100644
index 000000000..c39d4f782
--- /dev/null
+++ b/test/integration/standalone/other/node_info_test.go
@@ -0,0 +1,79 @@
+// 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 other
+
+import (
+ "context"
+ "time"
+
+ g "github.com/onsi/ginkgo/v2"
+ gm "github.com/onsi/gomega"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+
+ apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
+ databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+ "github.com/apache/skywalking-banyandb/pkg/fileformat"
+ "github.com/apache/skywalking-banyandb/pkg/grpchelper"
+ "github.com/apache/skywalking-banyandb/pkg/host"
+ "github.com/apache/skywalking-banyandb/pkg/test/setup"
+)
+
+// A standalone process registers no route table, so GetClusterState comes back
+// empty and a client has to read the node's capabilities off GetCurrentNode
+// instead. That only works if the single process reports both roles along with
+// its file format version and time zone.
+var _ = g.Describe("Current node", func() {
+ var conn *grpc.ClientConn
+ var deferFn func()
+
+ g.BeforeEach(func() {
+ var addr string
+ addr, _, deferFn = setup.Standalone(NewTestConfig())
+ var err error
+ conn, err = grpchelper.Conn(addr, 10*time.Second,
+ grpc.WithTransportCredentials(insecure.NewCredentials()))
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ })
+
+ g.AfterEach(func() {
+ gm.Expect(conn.Close()).To(gm.Succeed())
+ deferFn()
+ })
+
+ g.It("reports the file format version and the time zone", func() {
+ resp, err := databasev1.NewNodeQueryServiceClient(conn).
+ GetCurrentNode(context.Background(), &databasev1.GetCurrentNodeRequest{})
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ node := resp.GetNode()
+ gm.Expect(node.GetRoles()).To(gm.ContainElements(databasev1.Role_ROLE_LIAISON, databasev1.Role_ROLE_DATA))
+ gm.Expect(node.GetVersion()).NotTo(gm.BeNil())
+ gm.Expect(node.GetVersion().GetFileFormatVersion()).To(gm.Equal(fileformat.CurrentVersion))
+ gm.Expect(node.GetVersion().GetCompatibleFileFormatVersion()).To(gm.ContainElement(fileformat.CurrentVersion))
+ gm.Expect(node.GetVersion().GetApiVersion()).To(gm.Equal(apiversion.Version))
+ gm.Expect(node.GetTzName()).NotTo(gm.BeEmpty())
+ gm.Expect(node.GetTzName()).To(gm.Equal(host.TimeZoneName()))
+ })
+
+ g.It("returns an empty route table so the client falls back to the current node", func() {
+ resp, err := databasev1.NewClusterStateServiceClient(conn).
+ GetClusterState(context.Background(), &databasev1.GetClusterStateRequest{})
+ gm.Expect(err).NotTo(gm.HaveOccurred())
+ gm.Expect(resp.GetRouteTables()).To(gm.BeEmpty())
+ })
+})