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/09/14 08:19:05 UTC

(skywalking-banyandb) branch main updated: fix(metadata): tolerate unreachable peers in DNS discovery ListNode so non-meta nodes no longer deadlock in PreRun (#1354)

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 7f5e29fcf fix(metadata): tolerate unreachable peers in DNS discovery ListNode so non-meta nodes no longer deadlock in PreRun (#1354)
7f5e29fcf is described below

commit 7f5e29fcfa4c85003fa97e96479f8031c932a3c3
Author: mrproliu <74...@qq.com>
AuthorDate: Mon Sep 14 16:19:00 2026 +0800

    fix(metadata): tolerate unreachable peers in DNS discovery ListNode so non-meta nodes no longer deadlock in PreRun (#1354)
---
 .github/workflows/e2e.yml                          |  15 +-
 .gitignore                                         |   3 +
 CHANGES.md                                         |   1 +
 banyand/metadata/discovery/dns/dns.go              |   7 +-
 banyand/metadata/discovery/dns/prerun_test.go      | 172 ++++++++++
 test/e2e-v2/cases/lifecycle/data-generator/gen.sh  |   7 +
 .../lifecycle/dns-discovery/banyandb-cluster.yaml  | 380 +++++++++++++++++++++
 test/e2e-v2/cases/lifecycle/dns-discovery/e2e.yaml | 137 ++++++++
 .../{nodes.yaml => dns-discovery/kind.yaml.tpl}    |  19 +-
 .../lifecycle/dns-discovery/provider-consumer.yaml | 183 ++++++++++
 .../lifecycle/dns-discovery/skywalking-values.yaml |  56 +++
 test/e2e-v2/cases/lifecycle/e2e.yaml               |  98 ------
 .../cases/lifecycle/expected/traces-list.yml       |  40 ++-
 .../{ => file-discovery}/docker-compose.yml        |  26 +-
 .../e2e-v2/cases/lifecycle/file-discovery/e2e.yaml |  84 +++++
 .../lifecycle/{ => file-discovery}/nodes.yaml      |   0
 test/e2e-v2/cases/lifecycle/lifecycle-cases.yaml   |  46 +++
 17 files changed, 1138 insertions(+), 136 deletions(-)

diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 9c54f071f..46cadf1f4 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -93,9 +93,10 @@ jobs:
           - name: Rover eBPF - Istio mesh, Kind
             config: test/e2e-v2/cases/rover/process/istio/banyandb/e2e-banyandb.yaml
             runs-on: ubuntu-24.04
-          # TODO: Enable Lifecycle test when it is ready
-          # - name: Lifecycle
-          #   config: test/e2e-v2/cases/lifecycle/e2e.yaml
+          - name: Lifecycle File Discovery
+            config: test/e2e-v2/cases/lifecycle/file-discovery/e2e.yaml
+          - name: Lifecycle DNS Discovery
+            config: test/e2e-v2/cases/lifecycle/dns-discovery/e2e.yaml
         engine:
           - suffix: ""
           - suffix: " Vec"
@@ -146,7 +147,7 @@ jobs:
               ;;
           esac
       - name: Generate data for lifecycle
-        if: ${{ matrix.test.name == 'Lifecycle' }}
+        if: ${{ startsWith(matrix.test.name, 'Lifecycle') }}
         run: |
           bash test/e2e-v2/cases/lifecycle/data-generator/gen.sh
       - name: ${{ matrix.test.name }}
@@ -154,7 +155,7 @@ jobs:
         with:
           e2e-file: $GITHUB_WORKSPACE/${{ matrix.test.config }}
       - name: Fix directory permissions
-        if: ${{ (failure() && matrix.test.name == 'Lifecycle') }}
+        if: ${{ (failure() && startsWith(matrix.test.name, 'Lifecycle')) }}
         run: sudo chmod -R 777 test/e2e-v2/cases/lifecycle/data-generator/tmp
       - if: ${{ failure() }}
         run: |
@@ -168,10 +169,10 @@ jobs:
           name: test-logs-${{ matrix.test.name }}
           path: "${{ env.SW_INFRA_E2E_LOG_DIR }}"
       - uses: actions/upload-artifact@v7
-        if: ${{ (failure() && matrix.test.name == 'Lifecycle') }}
+        if: ${{ (failure() && startsWith(matrix.test.name, 'Lifecycle')) }}
         name: Upload generated data
         with:
-          name: lifecycle-data
+          name: lifecycle-data-${{ matrix.test.name }}${{ matrix.engine.suffix }}
           path: "test/e2e-v2/cases/lifecycle/data-generator/tmp"
 
   Storage:
diff --git a/.gitignore b/.gitignore
index 20d0916a5..947d201bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -112,3 +112,6 @@ progress.txt
 verify-*.png
 sw-tiered-*.png
 handoff-login.png
+
+# rendered by test/e2e-v2/cases/lifecycle/data-generator/gen.sh
+test/e2e-v2/cases/lifecycle/dns-discovery/kind.yaml
diff --git a/CHANGES.md b/CHANGES.md
index 958c42600..f64d15a9b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -20,6 +20,7 @@ Release Notes.
 - Enforce trace query time ranges independently of the sort index, skipping row timestamp checks when the query fully covers a part.
 - Retry property schema registry initialization indefinitely, logging an error every 10 attempts.
 - Re-fetch file/DNS discovery nodes that are parked in the retry queue while discovery has not Start()-ed yet, so unbounded PreRun schema-registry retries can recover once peers become reachable without restarting liaison.
+- Keep DNS discovery `ListNode` successful when some SRV addresses refuse connections but at least one node was discovered, so non-meta nodes no longer deadlock in PreRun on their own not-yet-listening gRPC address.
 
 ### Document
 
diff --git a/banyand/metadata/discovery/dns/dns.go b/banyand/metadata/discovery/dns/dns.go
index 7dd906556..725ff8b64 100644
--- a/banyand/metadata/discovery/dns/dns.go
+++ b/banyand/metadata/discovery/dns/dns.go
@@ -573,10 +573,13 @@ func (s *Service) updateNodeCache(ctx context.Context, srvToAddresses map[string
 		s.metrics.totalNodesCount.Set(float64(s.GetCacheSize()))
 	}
 
-	if len(addErrors) > 0 {
+	// Unreachable peers only fail the update when nothing was discovered at all.
+	// With at least one cached node the caller can proceed while the retry queue
+	// keeps chasing the rest. This matters during PreRun: the node's own address
+	// is already published but its gRPC port only opens after PreRun completes.
+	if len(addErrors) > 0 && s.GetCacheSize() == 0 {
 		return errors.Join(addErrors...)
 	}
-
 	return nil
 }
 
diff --git a/banyand/metadata/discovery/dns/prerun_test.go b/banyand/metadata/discovery/dns/prerun_test.go
new file mode 100644
index 000000000..b8df4bc15
--- /dev/null
+++ b/banyand/metadata/discovery/dns/prerun_test.go
@@ -0,0 +1,172 @@
+// 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 dns_test
+
+import (
+	"context"
+	"net"
+	"strconv"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"google.golang.org/grpc"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/discovery/dns"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema/property"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema/schemaserver"
+	"github.com/apache/skywalking-banyandb/banyand/observability"
+)
+
+// These specs cover the metadata PreRun phase: discovery has not been started,
+// so ListNode re-fetches unreachable peers on every call. A peer that can never
+// answer during PreRun (typically the node's own gRPC address published through
+// the headless service) must not hide the peers that did answer.
+var _ = Describe("ListNode before Start", func() {
+	var (
+		ctx          context.Context
+		cancel       context.CancelFunc
+		svc          *dns.Service
+		mockResolver *mockDNSResolver
+		mockServer   *mockNodeQueryServer
+		metaAddr     string
+		refusedAddr  string
+		stopServer   func()
+	)
+
+	BeforeEach(func() {
+		ctx, cancel = context.WithCancel(context.Background())
+		mockResolver = newMockDNSResolver()
+
+		var listener net.Listener
+		var grpcServer *grpc.Server
+		listener, grpcServer, mockServer = setupMockGRPCServer()
+		metaAddr = listener.Addr().String()
+		mockServer.node = createTestNode("meta-node", metaAddr, databasev1.Role_ROLE_META)
+		stopServer = func() {
+			grpcServer.Stop()
+			_ = listener.Close()
+		}
+
+		refusedAddr = reserveRefusedAddr()
+	})
+
+	AfterEach(func() {
+		if svc != nil {
+			Expect(svc.Close()).To(Succeed())
+			svc = nil
+		}
+		stopServer()
+		cancel()
+	})
+
+	It("should return reachable nodes when another SRV address refuses connections", func() {
+		mockResolver.setResponse("_grpc._tcp.test.local", []*net.SRV{addrToSRV(refusedAddr), addrToSRV(metaAddr)})
+
+		var err error
+		svc, err = dns.NewServiceWithResolver(createDefaultConfig(), mockResolver)
+		Expect(err).NotTo(HaveOccurred())
+
+		nodes, listErr := svc.ListNode(ctx, databasev1.Role_ROLE_META)
+		Expect(listErr).NotTo(HaveOccurred())
+		Expect(nodes).To(HaveLen(1))
+		Expect(nodes[0].GetMetadata().GetName()).To(Equal("meta-node"))
+		Expect(svc.RetryManager.IsInRetry(refusedAddr)).To(BeTrue(), "unreachable address should be parked for retry")
+
+		// Every pre-Start ListNode re-dials the parked address; the repeated
+		// failure must keep being tolerated rather than poisoning the result.
+		nodes, listErr = svc.ListNode(ctx, databasev1.Role_ROLE_META)
+		Expect(listErr).NotTo(HaveOccurred())
+		Expect(nodes).To(HaveLen(1))
+		Expect(svc.RetryManager.IsInRetry(refusedAddr)).To(BeTrue())
+	})
+
+	It("should still report an error when no SRV address is reachable", func() {
+		mockResolver.setResponse("_grpc._tcp.test.local", []*net.SRV{addrToSRV(refusedAddr)})
+
+		var err error
+		svc, err = dns.NewServiceWithResolver(createDefaultConfig(), mockResolver)
+		Expect(err).NotTo(HaveOccurred())
+
+		nodes, listErr := svc.ListNode(ctx, databasev1.Role_ROLE_META)
+		Expect(listErr).To(HaveOccurred())
+		Expect(listErr.Error()).To(ContainSubstring(refusedAddr))
+		Expect(nodes).To(BeEmpty())
+	})
+
+	It("should bootstrap the property schema registry while the node's own address is unreachable", func() {
+		schemaAddr, stopSchema := startSchemaServer()
+		defer stopSchema()
+
+		// The meta node advertises the schema server; the refused address plays
+		// the role of the booting node itself, whose gRPC port only opens after
+		// PreRun completes.
+		mockServer.node.PropertySchemaGrpcAddress = schemaAddr
+		mockResolver.setResponse("_grpc._tcp.test.local", []*net.SRV{addrToSRV(refusedAddr), addrToSRV(metaAddr)})
+
+		var err error
+		svc, err = dns.NewServiceWithResolver(createDefaultConfig(), mockResolver)
+		Expect(err).NotTo(HaveOccurred())
+
+		reg, regErr := property.NewSchemaRegistryClient(&property.ClientConfig{
+			GRPCTimeout:  5 * time.Second,
+			InitWaitTime: 10 * time.Second,
+			NodeRegistry: svc,
+		})
+		Expect(regErr).NotTo(HaveOccurred())
+		Expect(reg.Close()).To(Succeed())
+	})
+})
+
+// reserveRefusedAddr returns a loopback address that nothing listens on, so
+// dialing it fails immediately with connection refused.
+func reserveRefusedAddr() string {
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	Expect(err).NotTo(HaveOccurred())
+	addr := listener.Addr().String()
+	Expect(listener.Close()).To(Succeed())
+	return addr
+}
+
+func startSchemaServer() (string, func()) {
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	Expect(err).NotTo(HaveOccurred())
+	port := listener.Addr().(*net.TCPAddr).Port
+	Expect(listener.Close()).To(Succeed())
+
+	srv := schemaserver.NewServer(observability.BypassRegistry)
+	Expect(srv.FlagSet().Parse([]string{
+		"--schema-server-root-path", GinkgoT().TempDir(),
+		"--schema-server-grpc-host", "127.0.0.1",
+		"--schema-server-grpc-port", strconv.Itoa(port),
+	})).To(Succeed())
+	Expect(srv.Validate()).To(Succeed())
+	Expect(srv.PreRun(context.Background())).To(Succeed())
+	srv.Serve()
+
+	addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
+	Eventually(func() error {
+		conn, dialErr := net.DialTimeout("tcp", addr, 100*time.Millisecond)
+		if dialErr != nil {
+			return dialErr
+		}
+		return conn.Close()
+	}, 5*time.Second, 50*time.Millisecond).Should(Succeed())
+	return addr, srv.GracefulStop
+}
diff --git a/test/e2e-v2/cases/lifecycle/data-generator/gen.sh b/test/e2e-v2/cases/lifecycle/data-generator/gen.sh
index fa8a4382d..5cdb1c533 100644
--- a/test/e2e-v2/cases/lifecycle/data-generator/gen.sh
+++ b/test/e2e-v2/cases/lifecycle/data-generator/gen.sh
@@ -54,3 +54,10 @@ else
 fi
 
 docker compose -f "$COMPOSE_FILE" down -v
+
+# Render the kind config for the dns-discovery variant. kind only accepts an
+# absolute hostPath and the e2e runner does not expand variables in kind.yaml,
+# so bake in the absolute path of the data generated above.
+KIND_DIR="$DIR/../dns-discovery"
+sed "s|__LIFECYCLE_DATA_DIR__|$DIR/tmp|" "$KIND_DIR/kind.yaml.tpl" > "$KIND_DIR/kind.yaml"
+echo "✅ rendered $KIND_DIR/kind.yaml"
diff --git a/test/e2e-v2/cases/lifecycle/dns-discovery/banyandb-cluster.yaml b/test/e2e-v2/cases/lifecycle/dns-discovery/banyandb-cluster.yaml
new file mode 100644
index 000000000..a9ce9ef7f
--- /dev/null
+++ b/test/e2e-v2/cases/lifecycle/dns-discovery/banyandb-cluster.yaml
@@ -0,0 +1,380 @@
+# Licensed to the 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.
+# The 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.
+
+# BanyanDB hot/warm/cold cluster with DNS-based node discovery, the same
+# topology as ../file-discovery/docker-compose.yml. Every node lists the SRV
+# records of the four headless Services below and finds its peers from them,
+# mirroring the Helm chart: data nodes publish `grpc`, the liaison publishes
+# its data-facing `internal-grpc` port. publishNotReadyAddresses keeps a
+# booting pod in DNS before its readiness probe passes, exactly like production.
+#
+# Only hot keeps the meta role, so warm, cold and the liaison bootstrap their
+# schema registry by listing META nodes through discovery while their own
+# address is already published but not yet listening.
+#
+# The cold node is pre-loaded with the data produced by ../data-generator, which
+# kind.yaml mounts into the node at /lifecycle-data. Applied through envsubst so
+# ${BANYANDB_*_VECTORIZED_FLAGS} resolve; container args use $(POD_NAME), which
+# Kubernetes expands and envsubst leaves alone.
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: data-hot
+  labels:
+    app: banyandb-data
+    role: hot
+spec:
+  clusterIP: None
+  publishNotReadyAddresses: true
+  selector:
+    app: banyandb-data
+    role: hot
+  ports:
+    - name: grpc
+      port: 17912
+      targetPort: 17912
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: data-warm
+  labels:
+    app: banyandb-data
+    role: warm
+spec:
+  clusterIP: None
+  publishNotReadyAddresses: true
+  selector:
+    app: banyandb-data
+    role: warm
+  ports:
+    - name: grpc
+      port: 17912
+      targetPort: 17912
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: data-cold
+  labels:
+    app: banyandb-data
+    role: cold
+spec:
+  clusterIP: None
+  publishNotReadyAddresses: true
+  selector:
+    app: banyandb-data
+    role: cold
+  ports:
+    - name: grpc
+      port: 17912
+      targetPort: 17912
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: liaison-headless
+  labels:
+    app: banyandb-liaison
+spec:
+  clusterIP: None
+  publishNotReadyAddresses: true
+  selector:
+    app: banyandb-liaison
+  ports:
+    - name: internal-grpc
+      port: 18912
+      targetPort: 18912
+---
+# Client-facing liaison endpoint used by OAP (SW_STORAGE_BANYANDB_TARGETS).
+apiVersion: v1
+kind: Service
+metadata:
+  name: liaison
+  labels:
+    app: banyandb-liaison
+spec:
+  selector:
+    app: banyandb-liaison
+  ports:
+    - name: grpc
+      port: 17912
+      targetPort: 17912
+    - name: http
+      port: 17913
+      targetPort: 17913
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: data-hot
+  labels:
+    app: banyandb-data
+    role: hot
+spec:
+  serviceName: data-hot
+  replicas: 1
+  selector:
+    matchLabels:
+      app: banyandb-data
+      role: hot
+  template:
+    metadata:
+      labels:
+        app: banyandb-data
+        role: hot
+    spec:
+      containers:
+        - name: data
+          image: apache/skywalking-banyandb:${TAG}-testing
+          imagePullPolicy: IfNotPresent
+          args:
+            - data
+            - --node-discovery-mode=dns
+            - --node-discovery-dns-srv-addresses=_internal-grpc._tcp.liaison-headless.default.svc.cluster.local,_grpc._tcp.data-hot.default.svc.cluster.local,_grpc._tcp.data-warm.default.svc.cluster.local,_grpc._tcp.data-cold.default.svc.cluster.local
+            - --node-host-provider=flag
+            - --node-host=$(POD_NAME).data-hot.default
+            - --node-labels=type=hot
+            - ${BANYANDB_MEASURE_VECTORIZED_FLAGS}
+            - ${BANYANDB_TRACE_VECTORIZED_FLAGS}
+          env:
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+          ports:
+            - { name: grpc, containerPort: 17912 }
+            - { name: http, containerPort: 17913 }
+            - { name: metrics, containerPort: 2121 }
+            - { name: prop-schema, containerPort: 17916 }
+            - { name: prop-gossip, containerPort: 17932 }
+          readinessProbe:
+            # /api/healthz reports SERVING only after pre-run completes and the
+            # node serves gRPC on 17912, so OAP (deployed after) never starts
+            # against a not-yet-listening BanyanDB and crashloops.
+            httpGet:
+              path: /api/healthz
+              port: 17913
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            failureThreshold: 60
+          volumeMounts:
+            - name: lifecycle-data
+              mountPath: /tmp/measure/data/sw_metadata
+              subPath: sw_metadata
+          resources:
+            limits:
+              memory: 1536Mi
+              cpu: "2"
+            requests:
+              memory: 256Mi
+              cpu: "250m"
+      volumes:
+        - name: lifecycle-data
+          hostPath:
+            path: /lifecycle-data
+            type: Directory
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: data-warm
+  labels:
+    app: banyandb-data
+    role: warm
+spec:
+  serviceName: data-warm
+  replicas: 1
+  selector:
+    matchLabels:
+      app: banyandb-data
+      role: warm
+  template:
+    metadata:
+      labels:
+        app: banyandb-data
+        role: warm
+    spec:
+      containers:
+        - name: data
+          image: apache/skywalking-banyandb:${TAG}-testing
+          imagePullPolicy: IfNotPresent
+          args:
+            - data
+            - --node-discovery-mode=dns
+            - --node-discovery-dns-srv-addresses=_internal-grpc._tcp.liaison-headless.default.svc.cluster.local,_grpc._tcp.data-hot.default.svc.cluster.local,_grpc._tcp.data-warm.default.svc.cluster.local,_grpc._tcp.data-cold.default.svc.cluster.local
+            - --node-host-provider=flag
+            - --node-host=$(POD_NAME).data-warm.default
+            - --node-labels=type=warm
+            - --has-meta-role=false
+            - ${BANYANDB_MEASURE_VECTORIZED_FLAGS}
+            - ${BANYANDB_TRACE_VECTORIZED_FLAGS}
+          env:
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+          ports:
+            - { name: grpc, containerPort: 17912 }
+            - { name: http, containerPort: 17913 }
+            - { name: metrics, containerPort: 2121 }
+          readinessProbe:
+            httpGet:
+              path: /api/healthz
+              port: 17913
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            failureThreshold: 60
+          resources:
+            limits:
+              memory: 1536Mi
+              cpu: "2"
+            requests:
+              memory: 256Mi
+              cpu: "250m"
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: data-cold
+  labels:
+    app: banyandb-data
+    role: cold
+spec:
+  serviceName: data-cold
+  replicas: 1
+  selector:
+    matchLabels:
+      app: banyandb-data
+      role: cold
+  template:
+    metadata:
+      labels:
+        app: banyandb-data
+        role: cold
+    spec:
+      containers:
+        - name: data
+          image: apache/skywalking-banyandb:${TAG}-testing
+          imagePullPolicy: IfNotPresent
+          args:
+            - data
+            - --node-discovery-mode=dns
+            - --node-discovery-dns-srv-addresses=_internal-grpc._tcp.liaison-headless.default.svc.cluster.local,_grpc._tcp.data-hot.default.svc.cluster.local,_grpc._tcp.data-warm.default.svc.cluster.local,_grpc._tcp.data-cold.default.svc.cluster.local
+            - --node-host-provider=flag
+            - --node-host=$(POD_NAME).data-cold.default
+            - --node-labels=type=cold
+            - --has-meta-role=false
+            - ${BANYANDB_MEASURE_VECTORIZED_FLAGS}
+            - ${BANYANDB_TRACE_VECTORIZED_FLAGS}
+          env:
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+          ports:
+            - { name: grpc, containerPort: 17912 }
+            - { name: http, containerPort: 17913 }
+            - { name: metrics, containerPort: 2121 }
+          readinessProbe:
+            httpGet:
+              path: /api/healthz
+              port: 17913
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            failureThreshold: 60
+          volumeMounts:
+            # Pre-generated cold data, the same directories the compose variant
+            # bind-mounts into data-cold1.
+            - name: lifecycle-data
+              mountPath: /tmp/measure
+              subPath: measure
+            - name: lifecycle-data
+              mountPath: /tmp/stream
+              subPath: stream
+            - name: lifecycle-data
+              mountPath: /tmp/property
+              subPath: property
+            - name: lifecycle-data
+              mountPath: /tmp/trace
+              subPath: trace
+          resources:
+            limits:
+              memory: 1536Mi
+              cpu: "2"
+            requests:
+              memory: 256Mi
+              cpu: "250m"
+      volumes:
+        - name: lifecycle-data
+          hostPath:
+            path: /lifecycle-data
+            type: Directory
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: liaison
+  labels:
+    app: banyandb-liaison
+spec:
+  serviceName: liaison-headless
+  replicas: 1
+  selector:
+    matchLabels:
+      app: banyandb-liaison
+  template:
+    metadata:
+      labels:
+        app: banyandb-liaison
+    spec:
+      containers:
+        - name: liaison
+          image: apache/skywalking-banyandb:${TAG}-testing
+          imagePullPolicy: IfNotPresent
+          args:
+            - liaison
+            - --node-discovery-mode=dns
+            - --node-discovery-dns-srv-addresses=_internal-grpc._tcp.liaison-headless.default.svc.cluster.local,_grpc._tcp.data-hot.default.svc.cluster.local,_grpc._tcp.data-warm.default.svc.cluster.local,_grpc._tcp.data-cold.default.svc.cluster.local
+            - --node-host-provider=flag
+            - --node-host=$(POD_NAME).liaison-headless.default
+            - --data-node-selector=type=hot
+            - ${BANYANDB_MEASURE_VECTORIZED_FLAGS}
+            - ${BANYANDB_TRACE_VECTORIZED_FLAGS}
+          env:
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+          ports:
+            - { name: grpc, containerPort: 17912 }
+            - { name: http, containerPort: 17913 }
+            - { name: internal-grpc, containerPort: 18912 }
+            - { name: metrics, containerPort: 2121 }
+          readinessProbe:
+            httpGet:
+              path: /api/healthz
+              port: 17913
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            failureThreshold: 60
+          resources:
+            limits:
+              memory: 1Gi
+              cpu: "2"
+            requests:
+              memory: 256Mi
+              cpu: "250m"
diff --git a/test/e2e-v2/cases/lifecycle/dns-discovery/e2e.yaml b/test/e2e-v2/cases/lifecycle/dns-discovery/e2e.yaml
new file mode 100644
index 000000000..b37a66b9b
--- /dev/null
+++ b/test/e2e-v2/cases/lifecycle/dns-discovery/e2e.yaml
@@ -0,0 +1,137 @@
+# Licensed to the 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.
+# The 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.
+
+# Lifecycle e2e on kind with DNS-based node discovery: every BanyanDB node
+# finds its peers through the SRV records of headless Services, the same way
+# the Helm chart wires a cluster. OAP is installed from the skywalking-helm
+# chart like the rover istio e2e. Run ../data-generator/gen.sh first: it
+# produces the cold data and renders kind.yaml with the absolute path that
+# mounts that data into the kind node.
+
+setup:
+  env: kind
+  file: kind.yaml
+  timeout: 25m
+  init-system-environment: ../../../script/env
+  kind:
+    import-images:
+      - apache/skywalking-banyandb:${TAG}-testing
+    expose-ports:
+      - namespace: default
+        resource: service/skywalking-oap
+        port: 12800
+      - namespace: default
+        resource: service/consumer
+        port: 9092
+  steps:
+    - name: set PATH
+      command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+    - name: install yq
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq
+    - name: install swctl
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
+    - name: install kubectl
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh kubectl
+    - name: install helm
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh helm
+    - name: default the vectorized engine flags
+      # The CI engine axis exports these to switch the engine; envsubst has no
+      # default syntax, so fill in the compose defaults when they are unset.
+      command: |
+        export BANYANDB_MEASURE_VECTORIZED_FLAGS=${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false}
+        export BANYANDB_TRACE_VECTORIZED_FLAGS=${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
+    - name: Deploy BanyanDB hot/warm/cold cluster
+      command: envsubst < test/e2e-v2/cases/lifecycle/dns-discovery/banyandb-cluster.yaml | kubectl apply -f -
+    - name: Deploy SkyWalking OAP with helm
+      # Same chart and pin as the rover istio e2e; the chart's own BanyanDB is
+      # disabled in skywalking-values.yaml and OAP targets the liaison Service.
+      command: |
+        helm install skywalking \
+          oci://ghcr.io/apache/skywalking-helm/skywalking-helm \
+          --version "0.0.0-${SW_KUBERNETES_COMMIT_SHA}" \
+          --timeout 15m \
+          --set fullnameOverride=skywalking \
+          --set oap.image.repository=ghcr.io/apache/skywalking/oap \
+          --set oap.image.tag=${SW_OAP_COMMIT} \
+          --set ui.image.repository=ghcr.io/apache/skywalking/ui \
+          --set ui.image.tag=${SW_OAP_COMMIT} \
+          -f test/e2e-v2/cases/lifecycle/dns-discovery/skywalking-values.yaml
+    - name: Deploy provider and consumer demo apps
+      command: envsubst < test/e2e-v2/cases/lifecycle/dns-discovery/provider-consumer.yaml | kubectl apply -f -
+    - name: Wait for all workloads to be ready
+      # Block here so the kind expose-ports port-forward (set up right after the
+      # setup steps, before the wait: conditions) targets already-running pods.
+      command: |
+        kubectl rollout status statefulset/data-hot --timeout=300s
+        kubectl rollout status statefulset/data-warm --timeout=300s
+        kubectl rollout status statefulset/data-cold --timeout=300s
+        kubectl rollout status statefulset/liaison --timeout=300s
+        kubectl rollout status deployment/skywalking-oap --timeout=600s
+        kubectl rollout status deployment/provider --timeout=600s
+        kubectl rollout status deployment/consumer --timeout=600s
+  wait:
+    - namespace: default
+      resource: pod
+      label-selector: app=banyandb-data
+      for: condition=Ready
+    - namespace: default
+      resource: pod
+      label-selector: app=banyandb-liaison
+      for: condition=Ready
+    - namespace: default
+      resource: deployment/skywalking-oap
+      for: condition=available
+    - namespace: default
+      resource: deployment/provider
+      for: condition=available
+    - namespace: default
+      resource: deployment/consumer
+      for: condition=available
+
+trigger:
+  action: http
+  interval: 5s
+  times: -1
+  url: http://${service_consumer_host}:${service_consumer_9092}/users
+  method: POST
+  body: '{"id":"123","name":"skywalking"}'
+  headers:
+    "Content-Type": "application/json"
+
+verify:
+  # verify with retry strategy
+  retry:
+    # max retry count
+    count: 20
+    # the interval between two retries, in millisecond.
+    interval: 10s
+  cases:
+    - includes:
+        - ../lifecycle-cases.yaml
+
+cleanup:
+  on: always
+  collect:
+    on: failure
+    output-dir: $SW_INFRA_E2E_LOG_DIR/banyandb-data
+    items:
+      - namespace: default
+        label-selector: app=banyandb-data
+        paths:
+          - /tmp/trace/
+          - /tmp/stream/
+          - /tmp/measure/
+          - /tmp/property/
+          - /tmp/schema-property/
diff --git a/test/e2e-v2/cases/lifecycle/nodes.yaml b/test/e2e-v2/cases/lifecycle/dns-discovery/kind.yaml.tpl
similarity index 64%
copy from test/e2e-v2/cases/lifecycle/nodes.yaml
copy to test/e2e-v2/cases/lifecycle/dns-discovery/kind.yaml.tpl
index fcc72ce89..9d6a0275c 100644
--- a/test/e2e-v2/cases/lifecycle/nodes.yaml
+++ b/test/e2e-v2/cases/lifecycle/dns-discovery/kind.yaml.tpl
@@ -13,12 +13,15 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# Template for kind.yaml. kind requires an absolute hostPath and the e2e
+# runner does not expand variables in this file, so ../data-generator/gen.sh
+# renders kind.yaml from it with the absolute path of the generated data.
+
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
 nodes:
-  - name: data-hot1
-    grpc_address: data-hot1:17912
-  - name: data-warm1
-    grpc_address: data-warm1:17912
-  - name: data-cold1
-    grpc_address: data-cold1:17912
-  - name: liaison
-    grpc_address: liaison:17912
+  - role: control-plane
+    image: kindest/node:v1.30.0
+    extraMounts:
+      - hostPath: __LIFECYCLE_DATA_DIR__
+        containerPath: /lifecycle-data
diff --git a/test/e2e-v2/cases/lifecycle/dns-discovery/provider-consumer.yaml b/test/e2e-v2/cases/lifecycle/dns-discovery/provider-consumer.yaml
new file mode 100644
index 000000000..72f450935
--- /dev/null
+++ b/test/e2e-v2/cases/lifecycle/dns-discovery/provider-consumer.yaml
@@ -0,0 +1,183 @@
+# Licensed to the 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.
+# The 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.
+
+# SkyWalking e2e provider/consumer demo apps instrumented with the Java agent.
+# An initContainer copies the agent from the skywalking-java image into a shared
+# emptyDir, mirroring the docker-compose `agent` sidecar + sw_agent volume.
+# Consumer POSTs to provider; both report to the helm-deployed OAP
+# (Service `skywalking-oap`), generating writes into BanyanDB. Applied through
+# envsubst so the ${SW_AGENT_*_COMMIT} tags resolve from test/e2e-v2/script/env.
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: provider
+  labels:
+    app: provider
+spec:
+  selector:
+    app: provider
+  ports:
+    - name: http
+      port: 9090
+      targetPort: 9090
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: consumer
+  labels:
+    app: consumer
+spec:
+  selector:
+    app: consumer
+  ports:
+    - name: http
+      port: 9092
+      targetPort: 9092
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: provider
+  labels:
+    app: provider
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: provider
+  template:
+    metadata:
+      labels:
+        app: provider
+    spec:
+      initContainers:
+        - name: agent-init
+          image: ghcr.io/apache/skywalking-java/skywalking-java:${SW_AGENT_JAVA_COMMIT}-java8
+          command: ["sh", "-c", "cp -r /skywalking/agent /sw-java-agent/"]
+          volumeMounts:
+            - name: sw-agent
+              mountPath: /sw-java-agent
+      containers:
+        - name: provider
+          image: ghcr.io/apache/skywalking/e2e-service-provider:${SW_AGENT_E2E_SERVICE_PROVIDER_COMMIT}
+          imagePullPolicy: IfNotPresent
+          env:
+            - name: JAVA_TOOL_OPTIONS
+              # Cap the heap explicitly: these demo images run Java 8u322, which
+              # predates cgroup-v2 support (8u372+), so on a cgroup-v2 host the JVM
+              # would size its heap off the host's RAM and get OOMKilled despite the
+              # container memory limit.
+              value: -javaagent:/sw-java-agent/agent/skywalking-agent.jar -Xmx512m -Xms128m
+            - name: SW_AGENT_COLLECTOR_BACKEND_SERVICES
+              value: skywalking-oap:11800
+            - name: SW_LOGGING_OUTPUT
+              value: CONSOLE
+            - name: SW_AGENT_NAME
+              value: e2e-service-provider
+            - name: SW_AGENT_INSTANCE_NAME
+              value: provider1
+            - name: SW_METER_ACTIVE
+              value: "false"
+          ports:
+            - { name: http, containerPort: 9090 }
+          volumeMounts:
+            - name: sw-agent
+              mountPath: /sw-java-agent
+          readinessProbe:
+            tcpSocket:
+              port: 9090
+            initialDelaySeconds: 20
+            periodSeconds: 5
+            failureThreshold: 60
+          resources:
+            limits:
+              memory: 1536Mi
+              cpu: "1"
+            requests:
+              memory: 512Mi
+              cpu: "250m"
+      volumes:
+        - name: sw-agent
+          emptyDir: {}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: consumer
+  labels:
+    app: consumer
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: consumer
+  template:
+    metadata:
+      labels:
+        app: consumer
+    spec:
+      initContainers:
+        - name: agent-init
+          image: ghcr.io/apache/skywalking-java/skywalking-java:${SW_AGENT_JAVA_COMMIT}-java8
+          command: ["sh", "-c", "cp -r /skywalking/agent /sw-java-agent/"]
+          volumeMounts:
+            - name: sw-agent
+              mountPath: /sw-java-agent
+      containers:
+        - name: consumer
+          image: ghcr.io/apache/skywalking/e2e-service-consumer:${SW_AGENT_E2E_SERVICE_PROVIDER_COMMIT}
+          imagePullPolicy: IfNotPresent
+          env:
+            - name: JAVA_TOOL_OPTIONS
+              # Cap the heap explicitly: these demo images run Java 8u322, which
+              # predates cgroup-v2 support (8u372+), so on a cgroup-v2 host the JVM
+              # would size its heap off the host's RAM and get OOMKilled despite the
+              # container memory limit.
+              value: -javaagent:/sw-java-agent/agent/skywalking-agent.jar -Xmx512m -Xms128m
+            - name: SW_AGENT_COLLECTOR_BACKEND_SERVICES
+              value: skywalking-oap:11800
+            - name: SW_LOGGING_OUTPUT
+              value: CONSOLE
+            - name: PROVIDER_URL
+              value: http://provider:9090
+            - name: SW_AGENT_NAME
+              value: e2e-service-consumer
+            - name: SW_AGENT_INSTANCE_NAME
+              value: consumer1
+            - name: SW_METER_ACTIVE
+              value: "false"
+          ports:
+            - { name: http, containerPort: 9092 }
+          volumeMounts:
+            - name: sw-agent
+              mountPath: /sw-java-agent
+          readinessProbe:
+            tcpSocket:
+              port: 9092
+            initialDelaySeconds: 20
+            periodSeconds: 5
+            failureThreshold: 60
+          resources:
+            limits:
+              memory: 1536Mi
+              cpu: "1"
+            requests:
+              memory: 512Mi
+              cpu: "250m"
+      volumes:
+        - name: sw-agent
+          emptyDir: {}
diff --git a/test/e2e-v2/cases/lifecycle/dns-discovery/skywalking-values.yaml b/test/e2e-v2/cases/lifecycle/dns-discovery/skywalking-values.yaml
new file mode 100644
index 000000000..e38bf7936
--- /dev/null
+++ b/test/e2e-v2/cases/lifecycle/dns-discovery/skywalking-values.yaml
@@ -0,0 +1,56 @@
+# Licensed to the 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.
+# The 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.
+
+# Values for the skywalking-helm chart (pinned by SW_KUBERNETES_COMMIT_SHA in
+# test/e2e-v2/script/env), the same way the rover istio e2e deploys OAP. The
+# chart's own BanyanDB stays disabled; OAP targets the hot/warm/cold cluster
+# from banyandb-cluster.yaml through the `liaison` Service. Image tags and the
+# release name are passed on the helm command line in e2e.yaml.
+eckOperator:
+  enabled: false
+elasticsearch:
+  enabled: false
+banyandb:
+  enabled: false
+  config:
+    grpcAddress: "liaison:17912"
+    httpAddress: "liaison:17913"
+oap:
+  storageType: banyandb
+  replicas: 1
+  # The chart defaults to -Xmx2g; keep the OAP heap within what a GitHub runner
+  # can spare next to four BanyanDB nodes and the two demo apps.
+  javaOpts: -Xmx1g -Xms512m
+  # Schema installation against a fresh cluster can exceed the chart's default
+  # 90s startup window, which would restart OAP mid-install.
+  startupProbe:
+    tcpSocket:
+      port: 12800
+    failureThreshold: 60
+    periodSeconds: 10
+  env:
+    SW_HEALTH_CHECKER: default
+    SW_STORAGE_BANYANDB_COMPATIBLE_SERVER_API_VERSIONS: "0.11"
+    # Same warm/cold stage switches as ../file-discovery/docker-compose.yml.
+    SW_STORAGE_BANYANDB_METRICS_MINUTE_ENABLE_WARM_STAGE: "true"
+    SW_STORAGE_BANYANDB_METRICS_MINUTE_ENABLE_COLD_STAGE: "true"
+    SW_STORAGE_BANYANDB_METRICS_HOUR_ENABLE_WARM_STAGE: "true"
+    SW_STORAGE_BANYANDB_METRICS_HOUR_ENABLE_COLD_STAGE: "true"
+    SW_STORAGE_BANYANDB_METRICS_DAY_ENABLE_WARM_STAGE: "true"
+    SW_STORAGE_BANYANDB_METRICS_DAY_ENABLE_COLD_STAGE: "true"
+    SW_STORAGE_BANYANDB_RECORDS_ENABLE_WARM_STAGE: "true"
+    SW_STORAGE_BANYANDB_RECORDS_ENABLE_COLD_STAGE: "true"
+    SW_STORAGE_BANYANDB_TRACE_ENABLE_WARM_STAGE: "true"
+    SW_STORAGE_BANYANDB_TRACE_ENABLE_COLD_STAGE: "true"
diff --git a/test/e2e-v2/cases/lifecycle/e2e.yaml b/test/e2e-v2/cases/lifecycle/e2e.yaml
deleted file mode 100644
index 92cd88966..000000000
--- a/test/e2e-v2/cases/lifecycle/e2e.yaml
+++ /dev/null
@@ -1,98 +0,0 @@
-# Licensed to the 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.
-# The 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.
-
-# This file is used to show how to write configuration files and can be used to test.
-
-setup:
-  env: compose
-  file: docker-compose.yml
-  timeout: 20m
-  init-system-environment: ../../script/env
-  steps:
-    - name: set PATH
-      command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
-    - name: install yq
-      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq
-    - name: install swctl
-      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
-
-trigger:
-  action: http
-  interval: 5s
-  times: -1
-  url: http://${consumer_host}:${consumer_9092}/users
-  method: POST
-  body: '{"id":"123","name":"skywalking"}'
-  headers:
-    "Content-Type": "application/json"
-
-verify:
-  # verify with retry strategy
-  retry:
-    # max retry count
-    count: 20
-    # the interval between two retries, in millisecond.
-    interval: 10s
-  cases:
-    - includes:
-        - ../storage/storage-cases.yaml
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression="top_n(service_resp_time,3,des,attr0='GENERAL',attr1!='Not_exist')/100"
-      expected: ../storage/expected/topN-OP-service.yml
-
-    # cold data test
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression=service_resp_time --service-name=mock_a_service --start="-96h" --end="-48h" --cold=true --step=DAY
-      expected: ../storage/expected/metrics-has-value.yml
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql dependency service --service-name=mock_a_service  --start="-96h" --end="-48h" --cold=true --step=DAY
-      expected: ./expected/dependency-services.yml
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql dependency instance --service-name=mock_a_service --dest-service-name=mock_b_service --start="-96h" --end="-48h" --cold=true --step=DAY
-      expected: ./expected/dependency-instance.yml
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql dependency endpoint --service-name=mock_a_service --endpoint-name=/dubbox-case/case/dubbox-rest/404-test --start="-96h" --end="-48h" --cold=true --step=DAY
-      expected: ./expected/dependency-endpoint.yml
-    # trace list
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql tv2 ls --service-name=mock_a_service --endpoint-name=/dubbox-case/case/dubbox-rest/404-test --tags http.method=get --start="-96h" --end="-48h" --cold=true
-      expected: ./expected/traces-list.yml
-    ## topN
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression="top_n(service_resp_time,3,des,attr0='GENERAL')" --start="-96h" --end="-48h" --cold=true --step=DAY
-      expected: ./expected/topN-OP-service.yml
-    - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql metrics exec --expression="top_n(endpoint_resp_time,3,des)" --start="-96h" --end="-48h" --cold=true --step=DAY
-      expected: ./expected/topN-OP-endpoint.yml
-
-cleanup:
-  on: always
-  collect:
-    on: failure
-    output-dir: $SW_INFRA_E2E_LOG_DIR/banyandb-data
-    items:
-      - service: data-hot1
-        paths:
-          - /tmp/trace/
-          - /tmp/stream/
-          - /tmp/measure/
-          - /tmp/property/
-          - /tmp/schema-property/
-      - service: data-warm1
-        paths:
-          - /tmp/trace/
-          - /tmp/stream/
-          - /tmp/measure/
-          - /tmp/property/
-          - /tmp/schema-property/
-      - service: data-cold1
-        paths:
-          - /tmp/trace/
-          - /tmp/stream/
-          - /tmp/measure/
-          - /tmp/property/
-          - /tmp/schema-property/
diff --git a/test/e2e-v2/cases/lifecycle/expected/traces-list.yml b/test/e2e-v2/cases/lifecycle/expected/traces-list.yml
index e2f0a6243..c17d15e16 100644
--- a/test/e2e-v2/cases/lifecycle/expected/traces-list.yml
+++ b/test/e2e-v2/cases/lifecycle/expected/traces-list.yml
@@ -13,15 +13,39 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# Cold trace list for mock_a_service in the Trace V2 shape: every returned trace
+# must contain the Entry span of the queried endpoint carrying the queried tag.
 debuggingtrace: null
+retrievedtimerange:
+  {{- with .retrievedtimerange }}
+  starttime: {{ gt .starttime 0 }}
+  endtime: {{ gt .endtime 0 }}
+  {{- end }}
 traces:
 {{- contains (.traces | noDuplicates) }}
-- segmentid: {{ notEmpty .segmentid }}
-  endpointnames:
-    - /dubbox-case/case/dubbox-rest/404-test
-  duration: {{ ge .duration 0 }}
-  start: "{{ notEmpty .start}}"
-  iserror: false
-  traceids:
-    - {{ index .traceids 0 }}
+- spans:
+  {{- contains (.spans | noDuplicates) }}
+  - traceid: {{ notEmpty .traceid }}
+    segmentid: {{ notEmpty .segmentid }}
+    spanid: 0
+    parentspanid: -1
+    refs: []
+    servicecode: mock_a_service
+    serviceinstancename: mock_a_service_instance
+    starttime: {{ gt .starttime 0 }}
+    endtime: {{ gt .endtime 0 }}
+    endpointname: /dubbox-case/case/dubbox-rest/404-test
+    type: Entry
+    peer: ""
+    component: Tomcat
+    iserror: false
+    layer: Http
+    tags:
+      {{- contains .tags }}
+      - key: http.method
+        value: get
+      {{- end }}
+    logs: []
+    attachedevents: []
+  {{- end }}
 {{- end }}
diff --git a/test/e2e-v2/cases/lifecycle/docker-compose.yml b/test/e2e-v2/cases/lifecycle/file-discovery/docker-compose.yml
similarity index 83%
rename from test/e2e-v2/cases/lifecycle/docker-compose.yml
rename to test/e2e-v2/cases/lifecycle/file-discovery/docker-compose.yml
index 2913c1d83..43b9f6298 100644
--- a/test/e2e-v2/cases/lifecycle/docker-compose.yml
+++ b/test/e2e-v2/cases/lifecycle/file-discovery/docker-compose.yml
@@ -16,19 +16,19 @@
 services:
   data-hot1:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: data
     hostname: data-hot1
     command: data --node-discovery-mode=file --node-discovery-file-path=/etc/banyandb/nodes.yaml --node-labels type=hot ${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false} ${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
     volumes:
-      - ./data-generator/tmp/metadata:/tmp/measure/data/sw_metadata
+      - ../data-generator/tmp/sw_metadata:/tmp/measure/data/sw_metadata
       - ./nodes.yaml:/etc/banyandb/nodes.yaml
     networks:
       - e2e
 
   data-warm1:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: data
     hostname: data-warm1
     command: data --node-discovery-mode=file --node-discovery-file-path=/etc/banyandb/nodes.yaml --node-labels type=warm ${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false} ${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
@@ -39,22 +39,22 @@ services:
 
   data-cold1:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: data
     hostname: data-cold1
     command: data --node-discovery-mode=file --node-discovery-file-path=/etc/banyandb/nodes.yaml --node-labels type=cold ${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false} ${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
     volumes:
-      - ./data-generator/tmp/measure:/tmp/measure
-      - ./data-generator/tmp/stream:/tmp/stream
-      - ./data-generator/tmp/property:/tmp/property
-      - ./data-generator/tmp/trace:/tmp/trace
+      - ../data-generator/tmp/measure:/tmp/measure
+      - ../data-generator/tmp/stream:/tmp/stream
+      - ../data-generator/tmp/property:/tmp/property
+      - ../data-generator/tmp/trace:/tmp/trace
       - ./nodes.yaml:/etc/banyandb/nodes.yaml
     networks:
       - e2e
 
   liaison:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: liaison
     command: liaison --node-discovery-mode=file --node-discovery-file-path=/etc/banyandb/nodes.yaml --data-node-selector type=hot ${BANYANDB_MEASURE_VECTORIZED_FLAGS:---measure-vectorized-enabled=false} ${BANYANDB_TRACE_VECTORIZED_FLAGS:---trace-vectorized-enabled=false}
     volumes:
@@ -64,7 +64,7 @@ services:
 
   oap:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: oap
     environment:
       SW_STORAGE: banyandb
@@ -88,14 +88,14 @@ services:
 
   agent:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: agent
     networks:
       - e2e
 
   provider:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: provider
     ports:
       - 9090
@@ -109,7 +109,7 @@ services:
 
   consumer:
     extends:
-      file: ../../script/docker-compose/base-compose.yml
+      file: ../../../script/docker-compose/base-compose.yml
       service: consumer
     ports:
       - 9092
diff --git a/test/e2e-v2/cases/lifecycle/file-discovery/e2e.yaml b/test/e2e-v2/cases/lifecycle/file-discovery/e2e.yaml
new file mode 100644
index 000000000..9100638ac
--- /dev/null
+++ b/test/e2e-v2/cases/lifecycle/file-discovery/e2e.yaml
@@ -0,0 +1,84 @@
+# Licensed to the 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.
+# The 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.
+
+# Lifecycle e2e on docker-compose with file-based node discovery (nodes.yaml).
+# Run ../data-generator/gen.sh first: it produces the cold data mounted below.
+
+setup:
+  env: compose
+  file: docker-compose.yml
+  timeout: 20m
+  init-system-environment: ../../../script/env
+  steps:
+    - name: set PATH
+      command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+    - name: install yq
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq
+    - name: install swctl
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
+    - name: alias OAP address to the kind-style names used by ../lifecycle-cases.yaml
+      command: |
+        export service_skywalking_oap_host=${oap_host}
+        export service_skywalking_oap_12800=${oap_12800}
+
+trigger:
+  action: http
+  interval: 5s
+  times: -1
+  url: http://${consumer_host}:${consumer_9092}/users
+  method: POST
+  body: '{"id":"123","name":"skywalking"}'
+  headers:
+    "Content-Type": "application/json"
+
+verify:
+  # verify with retry strategy
+  retry:
+    # max retry count
+    count: 20
+    # the interval between two retries, in millisecond.
+    interval: 10s
+  cases:
+    - includes:
+        - ../../storage/storage-cases.yaml
+        - ../lifecycle-cases.yaml
+
+cleanup:
+  on: always
+  collect:
+    on: failure
+    output-dir: $SW_INFRA_E2E_LOG_DIR/banyandb-data
+    items:
+      - service: data-hot1
+        paths:
+          - /tmp/trace/
+          - /tmp/stream/
+          - /tmp/measure/
+          - /tmp/property/
+          - /tmp/schema-property/
+      - service: data-warm1
+        paths:
+          - /tmp/trace/
+          - /tmp/stream/
+          - /tmp/measure/
+          - /tmp/property/
+          - /tmp/schema-property/
+      - service: data-cold1
+        paths:
+          - /tmp/trace/
+          - /tmp/stream/
+          - /tmp/measure/
+          - /tmp/property/
+          - /tmp/schema-property/
diff --git a/test/e2e-v2/cases/lifecycle/nodes.yaml b/test/e2e-v2/cases/lifecycle/file-discovery/nodes.yaml
similarity index 100%
rename from test/e2e-v2/cases/lifecycle/nodes.yaml
rename to test/e2e-v2/cases/lifecycle/file-discovery/nodes.yaml
diff --git a/test/e2e-v2/cases/lifecycle/lifecycle-cases.yaml b/test/e2e-v2/cases/lifecycle/lifecycle-cases.yaml
new file mode 100644
index 000000000..ec57505cd
--- /dev/null
+++ b/test/e2e-v2/cases/lifecycle/lifecycle-cases.yaml
@@ -0,0 +1,46 @@
+# Licensed to the 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.
+# The 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.
+
+# Verify cases shared by the file-discovery (docker-compose) and dns-discovery
+# (kind) lifecycle e2e. Both variants pre-load the same generated cold data, so
+# the queries and expected files are identical; only the cluster bootstrap differs.
+#
+# The OAP address uses the kind-style variables ${service_skywalking_oap_host}
+# and ${service_skywalking_oap_12800}, which the kind runner exports for the
+# helm-deployed `service/skywalking-oap`. The compose runner exports
+# ${oap_host}/${oap_12800} instead, so file-discovery/e2e.yaml aliases them to
+# these names in a setup step.
+
+cases:
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql metrics exec --expression="top_n(service_resp_time,3,des,attr0='GENERAL',attr1!='Not_exist')/100"
+    expected: ../storage/expected/topN-OP-service.yml
+
+  # cold data test
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql metrics exec --expression=service_resp_time --service-name=mock_a_service --start="-96h" --end="-48h" --cold=true --step=DAY
+    expected: ../storage/expected/metrics-has-value.yml
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql dependency service --service-name=mock_a_service  --start="-96h" --end="-48h" --cold=true --step=DAY
+    expected: ./expected/dependency-services.yml
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql dependency instance --service-name=mock_a_service --dest-service-name=mock_b_service --start="-96h" --end="-48h" --cold=true --step=DAY
+    expected: ./expected/dependency-instance.yml
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql dependency endpoint --service-name=mock_a_service --endpoint-name=/dubbox-case/case/dubbox-rest/404-test --start="-96h" --end="-48h" --cold=true --step=DAY
+    expected: ./expected/dependency-endpoint.yml
+  # trace list
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql tv2 ls --service-name=mock_a_service --endpoint-name=/dubbox-case/case/dubbox-rest/404-test --tags http.method=get --start="-96h" --end="-48h" --cold=true
+    expected: ./expected/traces-list.yml
+  ## topN
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql metrics exec --expression="top_n(service_resp_time,3,des,attr0='GENERAL')" --start="-96h" --end="-48h" --cold=true --step=DAY
+    expected: ./expected/topN-OP-service.yml
+  - query: swctl --display yaml --base-url=http://${service_skywalking_oap_host}:${service_skywalking_oap_12800}/graphql metrics exec --expression="top_n(endpoint_resp_time,3,des)" --start="-96h" --end="-48h" --cold=true --step=DAY
+    expected: ./expected/topN-OP-endpoint.yml