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&#39;s local time zone, for example &#34;Asia/Shanghai&#34;. It is empty when the node cannot determine the name, which means &#34;unknown&#34; rather than &#34;matches whatever the other side reports&#34;. |
 
 
 
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())
+	})
+})