You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by al...@apache.org on 2026/05/24 06:33:49 UTC
(dubbo-go-pixiu) branch develop updated: fix(endpoint): stabilize endpoint.ID across restarts (nacos + static-config paths) (#930)
This is an automated email from the ASF dual-hosted git repository.
AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new 91688317 fix(endpoint): stabilize endpoint.ID across restarts (nacos + static-config paths) (#930)
91688317 is described below
commit 91688317f74306b5117c2120fc8146def0bc4eea
Author: 承潜 <29...@qq.com>
AuthorDate: Sun May 24 14:33:43 2026 +0800
fix(endpoint): stabilize endpoint.ID across restarts (nacos + static-config paths) (#930)
* model: add GeneratedEndpointID helper for deterministic endpoint identity
Returns a stable "generated-<sha8>" identifier derived from cluster name,
endpoint address, and (when present) LLM provider + API key. Endpoints
that differ only by credential do not collide, and the same endpoint
across restarts hashes to the same ID.
Used by the Nacos LLM registry to drop random UUID fallback identifiers.
* fix(adapter/nacos): deterministic endpoint identity and address baseline
generateEndpoint previously generated a fresh UUID for every Nacos
instance that did not carry an explicit metadata["id"]. Process restarts
and re-subscriptions therefore broke any external mapping keyed on
endpoint.ID (metrics labels, log correlation, runtime health tracking).
Replace the UUID fallback with a three-step lookup:
1. metadata["id"] (trimmed)
2. instance.InstanceId (trimmed)
3. model.GeneratedEndpointID(cluster, endpoint) — stable hash of
cluster, address, and LLM credential material
Two related fixes ride along because they share the same function:
- Address now seeds from instance.Ip / instance.Port before metadata
overrides, so instances without metadata.ip/port still get a usable
upstream address.
- When metadata["port"] fails to parse, the previous code overwrote
Address.Port with 0; now the parsed value only applies on success.
Endpoint.ID becomes upgrade-visible: existing deployments will see new
IDs after upgrade. Operators relying on endpoint.ID for dashboards or
log correlation should rebuild their indexes.
* test(adapter/nacos): cover stable ID, rename, InstanceId fallback paths
- Invalid port now expects fallback to instance.Port instead of zero,
matching the parse-failure fix.
- generateEndpoint prefers instance.InstanceId when metadata id is absent.
- generateEndpoint hashes deterministically when both metadata id and
instance id are absent; rotating an API key changes the generated ID,
the raw key never appears in it.
- subscribeCallback emits stable IDs across renames and re-subscriptions.
- subscribeCallback keeps endpoints with identical credentials but
different Nacos InstanceIds distinct.
* model: rename GeneratedEndpointID -> GenerateEndpointID and expand docs
Rename uses the standard Go verb form. The godoc now states three
non-obvious design choices so future readers don't have to re-derive
them:
- The 64-bit (8-byte) truncation rationale and the per-cluster scale
at which it is safe.
- That clusterName is part of the hash material so endpoints from
different clusters never alias, and how Nacos callers supply it.
- That the LLM API key is included on purpose, with the SHA-256
one-way guarantee that prevents the raw key from leaking into IDs.
Adds two helper test cases that nail the contract:
- Different cluster names produce different IDs for the same endpoint.
- Different addresses (host or port) produce different IDs.
* server: replace random-UUID endpoint identity with GenerateEndpointID
assembleClusterEndpoints used to assign a random UUID to any endpoint
with an empty ID, mirroring the now-removed Nacos UUID fallback and
breaking the same downstream contracts (metrics keyed on endpoint.ID,
PickNextEndpoint cursors, DeleteEndpoint lookups). Switch to the
deterministic helper so endpoint identity is stable across process
restarts regardless of registry source.
Two static endpoints sharing (cluster_name, address, provider, api_key)
now collapse to a single identifier. They were already indistinguishable
to the picker, so the previous random IDs offered no real separation;
operators who need distinct entries must set an explicit "id:".
hashicorp/go-uuid is no longer imported anywhere in pkg/, so it drops
from the direct dependency list (still transitively pulled in).
* server: warn on duplicate generated endpoint IDs and harden contract
Two follow-ups to the random-UUID -> deterministic-ID migration:
assembleClusterEndpoints now logs a warning when two endpoints inside
the same cluster collapse to the same generated ID. Previously this was
silent: operators would discover the missing entry only via dashboards
counting fewer endpoints than the config declares. The warning names
both slice positions and the shared ID so the fix (set explicit `id:`)
is mechanical.
endpointIDMaterial gets a contract comment stating that it MUST NOT
depend on endpoint.Name. Two call sites rely on this implicitly:
ClusterStore.assembleClusterEndpoints derives the ID before assigning
a default Name, and TestGenerateEndpointIDIgnoresEndpointName asserts
rename invariance. Documenting the contract makes the constraint
visible to anyone tempted to extend the hash material.
The godoc paragraph on truncation now reads as a neutral statement
rather than first-person ("we"), since this is library code in an OSS
project.
Adds TestClusterManager_AssembleEndpointsCollapseDuplicateGenerated to
lock the documented collapse behavior.
* adapter/nacos: unify nacosEndpointID lookup style
The three-tier lookup previously mixed two idioms — an ok-check plus
TrimSpace for metadata["id"], and a single-step assignment for
InstanceId — which left the metadata branch calling TrimSpace twice.
Switch metadata["id"] to the same single-step form. Go returns the
zero value for a missing map key, so the ok-check is redundant.
Behavior is unchanged; this is a readability cleanup surfaced by the
second-round review.
* docs(kvcache): document endpoint.id derivation and upgrade behavior
The "Routing Contract" section already states that LMCache instance_id
must equal pixiu endpoint.id, but did not say how endpoint.id is
derived or what happens when pixiu's identity scheme changes. Operators
running kvcache against the new deterministic identity could not tell
from the doc whether the contract was still meant to hold long-term.
Adds two sub-sections under "Routing Contract":
- Lists the three-tier derivation order (metadata id -> InstanceId ->
generated-<sha8>) and states that all three forms are stable across
restarts, so the LMCache <-> pixiu contract holds long-term.
- Calls out the one-time ID-shape transition during the upgrade from
the previous random-UUID behavior, so LMCache operators know to
expect a relearn window per endpoint and that it self-recovers.
Mirrors the same update in the Chinese doc.
* fix(nacos): fall back to cluster name for endpoint id
* Refine contract definition for LMCache and pixiu
Clarify the definition of the contract between LMCache instance_id and pixiu endpoint.id.
* fix(model): clarify generated endpoint id encoding
* ci: avoid persisted checkout credentials in integration test
---
.github/workflows/github-actions.yml | 4 +-
docs/ai/kvcache.md | 24 +++
docs/ai/kvcache_CN.md | 21 ++
go.mod | 2 +-
pkg/adapter/llmregistry/registry/nacos/listener.go | 29 ++-
.../llmregistry/registry/nacos/listener_test.go | 221 ++++++++++++++++++++-
pkg/model/cluster.go | 59 ++++++
pkg/model/cluster_test.go | 72 +++++++
pkg/server/cluster_manager.go | 25 ++-
pkg/server/cluster_manager_test.go | 85 ++++++++
10 files changed, 523 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index 548fb2c7..762d4c14 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -127,6 +127,8 @@ jobs:
SAMPLES_CLONE_DIR: integrate_samples
steps:
- uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
@@ -171,4 +173,4 @@ jobs:
- name: Run Integration Tests
run: |
chmod +x start_integrate_test.sh
- ./start_integrate_test.sh
\ No newline at end of file
+ ./start_integrate_test.sh
diff --git a/docs/ai/kvcache.md b/docs/ai/kvcache.md
index d73a2e85..7ab571db 100644
--- a/docs/ai/kvcache.md
+++ b/docs/ai/kvcache.md
@@ -43,6 +43,30 @@ So for routing to work:
If no match exists, the request falls back to normal load-balancing behavior.
+#### How `pixiu cluster endpoint.id` is derived
+
+Pixiu picks the endpoint identifier in three-tier order. The first non-empty
+value wins:
+
+1. `metadata["id"]` on the registry instance (Nacos LLM registry), or
+ the `id:` field on a static-config endpoint.
+2. The Nacos `InstanceId` if present.
+3. A deterministic `pixiu-generated-endpoint-<sha8>` hash derived from
+ `(cluster_name, address, provider, api_key)`.
+
+All three forms are stable across pixiu restarts as long as the underlying
+attributes are unchanged, so the `LMCache instance_id ↔ pixiu endpoint.id`
+contract holds long-term.
+
+#### Upgrade note
+
+Earlier pixiu versions used a random UUID for tiers 2 and 3, which made
+`endpoint.id` change on every restart and re-subscription. The current
+version is deterministic; LMCache deployments whose `instance_id` values
+were derived from those random UUIDs will see a one-time ID-shape
+transition on upgrade. After the transition, LMCache only needs to
+relearn the `instance_id` once per endpoint; routing then stays stable.
+
Note:
- Current implementation does **not** call LMCache `/query_worker_info`.
diff --git a/docs/ai/kvcache_CN.md b/docs/ai/kvcache_CN.md
index 48cc466c..e807e227 100644
--- a/docs/ai/kvcache_CN.md
+++ b/docs/ai/kvcache_CN.md
@@ -43,6 +43,27 @@
如果不一致,请求会自动回退到正常负载均衡。
+#### `pixiu cluster endpoint.id` 的生成规则
+
+按三档优先级取第一个非空值:
+
+1. 注册中心实例上的 `metadata["id"]`(Nacos LLM 注册中心),
+ 或静态配置 endpoint 上的 `id:` 字段。
+2. Nacos 的 `InstanceId`(若存在)。
+3. 由 `(cluster_name, address, provider, api_key)` 派生的确定性哈希
+ `pixiu-generated-endpoint-<sha8>`。
+
+三种形式在 pixiu 重启之间都保持稳定,只要底层属性没有变化,所以
+`LMCache instance_id ↔ pixiu endpoint.id` 这样定义长期成立。
+
+#### 升级说明
+
+早期 pixiu 版本在第 2、3 档使用随机 UUID,导致 `endpoint.id` 每次
+重启和重订阅都会变化。当前版本改为确定性 ID;如果你的 LMCache
+部署中 `instance_id` 是基于那些随机 UUID 推导的,升级时会看到一次性
+的 ID 形态切换。切换之后,LMCache 只需为每个 endpoint 重新学习一次
+`instance_id`,之后路由恢复稳定。
+
说明:
- 当前实现 **不会** 调 LMCache `/query_worker_info`
diff --git a/go.mod b/go.mod
index e27fbe7d..574fefcd 100644
--- a/go.mod
+++ b/go.mod
@@ -28,7 +28,6 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang/mock v1.6.0
github.com/golang/protobuf v1.5.4
- github.com/hashicorp/go-uuid v1.0.3
github.com/jhump/protoreflect v1.17.0
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/lestrrat-go/httprc/v3 v3.0.1
@@ -163,6 +162,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/vault/sdk v0.7.0 // indirect
diff --git a/pkg/adapter/llmregistry/registry/nacos/listener.go b/pkg/adapter/llmregistry/registry/nacos/listener.go
index ff064a80..bc3952e8 100644
--- a/pkg/adapter/llmregistry/registry/nacos/listener.go
+++ b/pkg/adapter/llmregistry/registry/nacos/listener.go
@@ -31,8 +31,6 @@ import (
"github.com/creasty/defaults"
- "github.com/hashicorp/go-uuid"
-
"github.com/nacos-group/nacos-sdk-go/clients/naming_client"
nacosModel "github.com/nacos-group/nacos-sdk-go/model"
"github.com/nacos-group/nacos-sdk-go/vo"
@@ -275,6 +273,9 @@ func generateEndpoint(instance nacosModel.Instance) *model.Endpoint {
return nil
}
+ ret.Address.Address = instance.Ip
+ ret.Address.Port = int(instance.Port)
+
if ip, ok := instance.Metadata["ip"]; ok {
ret.Address.Address = ip
}
@@ -283,14 +284,9 @@ func generateEndpoint(instance nacosModel.Instance) *model.Endpoint {
p, err := strconv.Atoi(port)
if err != nil {
logger.Warnf("Invalid port in metadata: %s, error: %v", port, err)
+ } else {
+ ret.Address.Port = p
}
- ret.Address.Port = p
- }
-
- if id, ok := instance.Metadata["id"]; ok {
- ret.ID = id
- } else {
- ret.ID, _ = uuid.GenerateUUID()
}
if name, ok := instance.Metadata["name"]; ok {
@@ -316,10 +312,25 @@ func generateEndpoint(instance nacosModel.Instance) *model.Endpoint {
}
ret.Metadata = instance.Metadata
+ ret.ID = nacosEndpointID(instance, ret)
return ret
}
+func nacosEndpointID(instance nacosModel.Instance, endpoint *model.Endpoint) string {
+ if id := strings.TrimSpace(instance.Metadata["id"]); id != "" {
+ return id
+ }
+ if instanceID := strings.TrimSpace(instance.InstanceId); instanceID != "" {
+ return instanceID
+ }
+ clusterName := strings.TrimSpace(instance.Metadata["cluster"])
+ if clusterName == "" {
+ clusterName = strings.TrimSpace(instance.ClusterName)
+ }
+ return model.GenerateEndpointID(clusterName, endpoint)
+}
+
func generateInstance(ss nacosModel.SubscribeService) nacosModel.Instance {
return nacosModel.Instance{
InstanceId: ss.InstanceId,
diff --git a/pkg/adapter/llmregistry/registry/nacos/listener_test.go b/pkg/adapter/llmregistry/registry/nacos/listener_test.go
index 6835f7d3..4fa9df8d 100644
--- a/pkg/adapter/llmregistry/registry/nacos/listener_test.go
+++ b/pkg/adapter/llmregistry/registry/nacos/listener_test.go
@@ -154,10 +154,82 @@ func TestGenerateEndpoint(t *testing.T) {
})
t.Run("Invalid port", func(t *testing.T) {
- instance := nacosModel.Instance{Metadata: map[string]string{"port": "not-a-number"}}
+ instance := nacosModel.Instance{
+ Port: 8080,
+ Metadata: map[string]string{"port": "not-a-number"},
+ }
+ endpoint := generateEndpoint(instance)
+ assert.NotNil(t, endpoint)
+ assert.Equal(t, 8080, endpoint.Address.Port)
+ })
+
+ t.Run("Missing metadata ID uses Nacos instance identity", func(t *testing.T) {
+ instance := nacosModel.Instance{
+ InstanceId: "nacos-instance-1",
+ Ip: "10.0.0.1",
+ Port: 18080,
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm",
+ },
+ }
+
endpoint := generateEndpoint(instance)
assert.NotNil(t, endpoint)
- assert.Equal(t, 0, endpoint.Address.Port)
+ assert.Equal(t, "nacos-instance-1", endpoint.ID)
+ assert.Equal(t, "10.0.0.1", endpoint.Address.Address)
+ assert.Equal(t, 18080, endpoint.Address.Port)
+ })
+
+ t.Run("Missing ID uses stable generated endpoint ID", func(t *testing.T) {
+ instance := nacosModel.Instance{
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm",
+ "ip": "127.0.0.1",
+ "port": "8080",
+ "llm-meta.api_key": "key-a",
+ },
+ }
+
+ first := generateEndpoint(instance)
+ second := generateEndpoint(instance)
+ changedCredential := instance
+ changedCredential.Metadata = map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm",
+ "ip": "127.0.0.1",
+ "port": "8080",
+ "llm-meta.api_key": "key-b",
+ }
+
+ assert.NotNil(t, first)
+ assert.Equal(t, first.ID, second.ID)
+ assert.NotEqual(t, first.ID, generateEndpoint(changedCredential).ID)
+ assert.Contains(t, first.ID, "pixiu-generated-endpoint-")
+ assert.NotContains(t, first.ID, "key-a")
+ })
+
+ t.Run("Missing metadata cluster falls back to Nacos cluster name", func(t *testing.T) {
+ build := func(clusterName string) nacosModel.Instance {
+ return nacosModel.Instance{
+ Ip: "127.0.0.1",
+ Port: 8080,
+ ClusterName: clusterName,
+ Metadata: map[string]string{
+ "name": "shared-llm",
+ "llm-meta.api_key": "key-a",
+ },
+ }
+ }
+
+ clusterA := generateEndpoint(build("cluster-a"))
+ clusterB := generateEndpoint(build("cluster-b"))
+
+ assert.NotNil(t, clusterA)
+ assert.NotNil(t, clusterB)
+ assert.Contains(t, clusterA.ID, "pixiu-generated-endpoint-")
+ assert.NotEqual(t, clusterA.ID, clusterB.ID)
})
}
@@ -273,6 +345,151 @@ func TestServiceCallback(t *testing.T) {
})
}
+func TestServiceCallbackUsesStableGeneratedEndpointID(t *testing.T) {
+ l, client, adapterListener := testSetup()
+
+ _ = client.Subscribe(&vo.SubscribeParam{
+ ServiceName: "service-generated",
+ SubscribeCallback: l.serviceCallback,
+ })
+
+ instance1 := nacosModel.SubscribeService{
+ ServiceName: "service-generated",
+ Enable: true,
+ Healthy: true,
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm-a",
+ "ip": "127.0.0.1",
+ "port": "8080",
+ "llm-meta.api_key": "key-a",
+ },
+ }
+ instance2 := nacosModel.SubscribeService{
+ ServiceName: "service-generated",
+ Enable: true,
+ Healthy: true,
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm-b",
+ "ip": "127.0.0.1",
+ "port": "8080",
+ "llm-meta.api_key": "key-b",
+ },
+ }
+
+ client.subscribeCallback([]nacosModel.SubscribeService{instance1, instance2}, nil)
+ assert.Len(t, adapterListener.addedEndpoints, 2)
+
+ var removedID string
+ for id, endpoint := range adapterListener.addedEndpoints {
+ if endpoint.Name == "shared-llm-a" {
+ removedID = id
+ }
+ }
+ assert.NotEmpty(t, removedID)
+
+ adapterListener.reset()
+ client.subscribeCallback([]nacosModel.SubscribeService{instance2}, nil)
+
+ assert.Empty(t, adapterListener.addedEndpoints)
+ assert.Contains(t, adapterListener.removedEndpoints, removedID)
+}
+
+func TestServiceCallbackKeepsGeneratedEndpointIDWhenNameChanges(t *testing.T) {
+ l, client, adapterListener := testSetup()
+
+ _ = client.Subscribe(&vo.SubscribeParam{
+ ServiceName: "service-rename",
+ SubscribeCallback: l.serviceCallback,
+ })
+
+ instance := nacosModel.SubscribeService{
+ ServiceName: "service-rename",
+ Ip: "127.0.0.1",
+ Port: 8080,
+ Enable: true,
+ Healthy: true,
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm-old",
+ "llm-meta.api_key": "key-a",
+ },
+ }
+
+ client.subscribeCallback([]nacosModel.SubscribeService{instance}, nil)
+ assert.Len(t, adapterListener.addedEndpoints, 1)
+
+ var endpointID string
+ for id := range adapterListener.addedEndpoints {
+ endpointID = id
+ }
+ assert.NotEmpty(t, endpointID)
+
+ renamed := instance
+ renamed.Metadata = map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm-new",
+ "llm-meta.api_key": "key-a",
+ }
+
+ adapterListener.reset()
+ client.subscribeCallback([]nacosModel.SubscribeService{renamed}, nil)
+
+ assert.Empty(t, adapterListener.removedEndpoints)
+ if assert.Contains(t, adapterListener.addedEndpoints, endpointID) {
+ assert.Equal(t, "shared-llm-new", adapterListener.addedEndpoints[endpointID].Name)
+ }
+}
+
+func TestServiceCallbackKeepsNacosInstancesWithoutMetadataIDDistinct(t *testing.T) {
+ l, client, adapterListener := testSetup()
+
+ _ = client.Subscribe(&vo.SubscribeParam{
+ ServiceName: "service-instance-id",
+ SubscribeCallback: l.serviceCallback,
+ })
+
+ instance1 := nacosModel.SubscribeService{
+ InstanceId: "nacos-instance-a",
+ ServiceName: "service-instance-id",
+ Ip: "10.0.0.1",
+ Port: 18080,
+ Enable: true,
+ Healthy: true,
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm",
+ "llm-meta.api_key": "same-key",
+ },
+ }
+ instance2 := nacosModel.SubscribeService{
+ InstanceId: "nacos-instance-b",
+ ServiceName: "service-instance-id",
+ Ip: "10.0.0.2",
+ Port: 18081,
+ Enable: true,
+ Healthy: true,
+ Metadata: map[string]string{
+ "cluster": "llm-cluster",
+ "name": "shared-llm",
+ "llm-meta.api_key": "same-key",
+ },
+ }
+
+ client.subscribeCallback([]nacosModel.SubscribeService{instance1, instance2}, nil)
+
+ assert.Len(t, adapterListener.addedEndpoints, 2)
+ assert.Contains(t, adapterListener.addedEndpoints, "nacos-instance-a")
+ assert.Contains(t, adapterListener.addedEndpoints, "nacos-instance-b")
+
+ adapterListener.reset()
+ client.subscribeCallback([]nacosModel.SubscribeService{instance2}, nil)
+
+ assert.Empty(t, adapterListener.addedEndpoints)
+ assert.Contains(t, adapterListener.removedEndpoints, "nacos-instance-a")
+}
+
func TestLifecycle(t *testing.T) {
l, _, _ := testSetup()
diff --git a/pkg/model/cluster.go b/pkg/model/cluster.go
index f8bd0bac..c7ae4a49 100644
--- a/pkg/model/cluster.go
+++ b/pkg/model/cluster.go
@@ -18,9 +18,12 @@
package model
import (
+ "crypto/sha256"
"fmt"
)
+const generatedEndpointIDPrefix = "pixiu-generated-endpoint-"
+
const (
Static DiscoveryType = iota
StrictDNS
@@ -128,3 +131,59 @@ func (c *ClusterConfig) CreateConsistentHash() {
func (e Endpoint) GetHost() string {
return fmt.Sprintf("%s:%d", e.Address.Address, e.Address.Port)
}
+
+// GenerateEndpointID returns a deterministic runtime identity for endpoints
+// that do not provide an explicit ID. The hash material includes cluster name,
+// endpoint address, and (when present) LLM provider + API key, so endpoints
+// that differ only by credential do not collide and the same endpoint hashes
+// to the same ID across process restarts.
+//
+// Design notes:
+// - The output is sha256(material) truncated to the first 8 bytes (64 bits).
+// Birthday-collision probability becomes meaningful only around 2^32
+// endpoints, far above any realistic per-cluster scale.
+// - The output uses the pixiu-generated-endpoint- prefix so generated IDs
+// are recognizable in logs and dashboards without implying they only come
+// from the LLM registry path. Static-config endpoints use this helper too.
+// - clusterName is part of the material so endpoints in different clusters
+// never alias. Callers from the Nacos LLM path supply
+// instance.Metadata["cluster"]; if that metadata is missing the value is
+// the empty string, and identity is then determined by address +
+// credential only.
+// - The LLM API key is included on purpose so two endpoints that share an
+// address but use different credentials never alias to the same ID. The
+// output is one-way (sha256), so the raw key never appears in the ID.
+func GenerateEndpointID(clusterName string, endpoint *Endpoint) string {
+ sum := sha256.Sum256([]byte(endpointIDMaterial(clusterName, endpoint)))
+ return fmt.Sprintf("%s%x", generatedEndpointIDPrefix, sum[:8])
+}
+
+// endpointIDMaterial builds the byte string fed into the hash inside
+// GenerateEndpointID. Each component is tagged and length-prefixed so field
+// boundaries remain unambiguous even if values contain punctuation or newlines.
+//
+// Contract: this function MUST NOT depend on endpoint.Name. Callers
+// (notably ClusterStore.assembleClusterEndpoints) rely on being able to
+// derive the ID before assigning a default Name. Adding Name into the
+// material would also break the rename-invariance guarantee asserted by
+// TestGenerateEndpointIDIgnoresEndpointName.
+func endpointIDMaterial(clusterName string, endpoint *Endpoint) string {
+ address := ""
+ provider := ""
+ apiKey := ""
+ if endpoint != nil {
+ address = endpoint.Address.GetAddress()
+ }
+ if endpoint != nil && endpoint.LLMMeta != nil {
+ provider = endpoint.LLMMeta.Provider
+ apiKey = endpoint.LLMMeta.APIKey
+ }
+ return endpointIDMaterialField("cluster", clusterName) +
+ endpointIDMaterialField("address", address) +
+ endpointIDMaterialField("llm_provider", provider) +
+ endpointIDMaterialField("llm_api_key", apiKey)
+}
+
+func endpointIDMaterialField(name, value string) string {
+ return fmt.Sprintf("%s:%d:%s\n", name, len(value), value)
+}
diff --git a/pkg/model/cluster_test.go b/pkg/model/cluster_test.go
index bb834071..aeb26495 100644
--- a/pkg/model/cluster_test.go
+++ b/pkg/model/cluster_test.go
@@ -102,3 +102,75 @@ func TestEndpoint_GetHost(t *testing.T) {
assert.Equal(t, "127.0.0.1:20880", endpoint.GetHost())
}
+
+func TestGenerateEndpointIDIsDeterministic(t *testing.T) {
+ build := func(apiKey string) *model.Endpoint {
+ return &model.Endpoint{
+ Name: "shared-llm",
+ Address: model.SocketAddress{Address: "127.0.0.1", Port: 20880},
+ LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: apiKey},
+ }
+ }
+
+ first := model.GenerateEndpointID("cluster-a", build("key-a"))
+ second := model.GenerateEndpointID("cluster-a", build("key-a"))
+ other := model.GenerateEndpointID("cluster-a", build("key-b"))
+
+ assert.Equal(t, first, second, "same material must produce same ID")
+ assert.NotEqual(t, first, other, "different credential must produce different ID")
+ assert.True(t, strings.HasPrefix(first, "pixiu-generated-endpoint-"),
+ "ID must use pixiu-generated-endpoint- prefix")
+ assert.NotContains(t, first, "key-a", "raw API key must not leak into the ID")
+}
+
+func TestGenerateEndpointIDIgnoresEndpointName(t *testing.T) {
+ base := &model.Endpoint{
+ Name: "old-name",
+ Address: model.SocketAddress{Address: "127.0.0.1", Port: 20880},
+ LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: "k"},
+ }
+ renamed := *base
+ renamed.Name = "new-name"
+
+ assert.Equal(t,
+ model.GenerateEndpointID("cluster-a", base),
+ model.GenerateEndpointID("cluster-a", &renamed),
+ )
+}
+
+func TestGenerateEndpointIDSeparatesClusters(t *testing.T) {
+ endpoint := &model.Endpoint{
+ Address: model.SocketAddress{Address: "127.0.0.1", Port: 20880},
+ LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: "k"},
+ }
+
+ a := model.GenerateEndpointID("cluster-a", endpoint)
+ b := model.GenerateEndpointID("cluster-b", endpoint)
+
+ assert.NotEqual(t, a, b, "different cluster names must produce different IDs for the same endpoint")
+}
+
+func TestGenerateEndpointIDSeparatesAddresses(t *testing.T) {
+ build := func(host string, port int) *model.Endpoint {
+ return &model.Endpoint{
+ Address: model.SocketAddress{Address: host, Port: port},
+ LLMMeta: &model.LLMMeta{Provider: "openai", APIKey: "k"},
+ }
+ }
+
+ base := model.GenerateEndpointID("cluster-a", build("127.0.0.1", 20880))
+
+ assert.NotEqual(t, base,
+ model.GenerateEndpointID("cluster-a", build("127.0.0.2", 20880)),
+ "different host must produce different ID")
+ assert.NotEqual(t, base,
+ model.GenerateEndpointID("cluster-a", build("127.0.0.1", 20881)),
+ "different port must produce different ID")
+}
+
+func TestGenerateEndpointIDHandlesNilEndpoint(t *testing.T) {
+ a := model.GenerateEndpointID("cluster-a", nil)
+ b := model.GenerateEndpointID("cluster-b", nil)
+ assert.True(t, strings.HasPrefix(a, "pixiu-generated-endpoint-"))
+ assert.NotEqual(t, a, b)
+}
diff --git a/pkg/server/cluster_manager.go b/pkg/server/cluster_manager.go
index 112440c3..56f7fb78 100644
--- a/pkg/server/cluster_manager.go
+++ b/pkg/server/cluster_manager.go
@@ -23,10 +23,6 @@ import (
"sync/atomic"
)
-import (
- "github.com/hashicorp/go-uuid"
-)
-
import (
"github.com/apache/dubbo-go-pixiu/pkg/cluster"
"github.com/apache/dubbo-go-pixiu/pkg/cluster/loadbalancer"
@@ -316,10 +312,27 @@ func (s *ClusterStore) assembleClusterEndpoints(c *model.ClusterConfig) {
return
}
+ // generatedIDIndex tracks the slice position where each generated ID was
+ // first observed within this cluster. A duplicate means two endpoints
+ // share (cluster_name, address, provider, api_key) and collapse onto a
+ // single identifier, which is silent in the runtime path; warn loudly so
+ // the operator can disambiguate by setting an explicit `id:`.
+ generatedIDIndex := make(map[string]int, len(c.Endpoints))
+
for i, endpoint := range c.Endpoints {
- // If the endpoint ID is not set, set it to the index + 1
+ // If the endpoint ID is not set, derive a deterministic one so the same
+ // endpoint hashes to the same ID across process restarts. Identifiers
+ // must be stable for metrics/log correlation and runtime lookups
+ // (PickNextEndpoint, GetEndpointByID, DeleteEndpoint).
if endpoint.ID == "" {
- endpoint.ID, _ = uuid.GenerateUUID()
+ endpoint.ID = model.GenerateEndpointID(c.Name, endpoint)
+ if prev, dup := generatedIDIndex[endpoint.ID]; dup {
+ logger.Warnf("[cluster=%s] endpoints[%d] and endpoints[%d] share generated ID %s; "+
+ "set an explicit `id:` on one of them to keep them distinct",
+ c.Name, prev, i, endpoint.ID)
+ } else {
+ generatedIDIndex[endpoint.ID] = i
+ }
}
// If the endpoint has no name, set a default name
diff --git a/pkg/server/cluster_manager_test.go b/pkg/server/cluster_manager_test.go
index ef90bd3c..e72b3c87 100644
--- a/pkg/server/cluster_manager_test.go
+++ b/pkg/server/cluster_manager_test.go
@@ -500,6 +500,91 @@ func TestClusterManager_Race_RoundRobinPickEndpoint(t *testing.T) {
wg.Wait()
}
+func TestClusterManager_AssembleEndpointsAssignsDeterministicID(t *testing.T) {
+ build := func() *model.ClusterConfig {
+ return &model.ClusterConfig{
+ Name: "assemble-id",
+ LbStr: model.LoadBalancerRoundRobin,
+ Endpoints: []*model.Endpoint{
+ {Address: model.SocketAddress{Address: "127.0.0.1", Port: 21080}},
+ {Address: model.SocketAddress{Address: "127.0.0.1", Port: 21081}},
+ },
+ }
+ }
+
+ first := testClusterManager(build())
+ defer stopStoreRuntimes(first.store)
+ second := testClusterManager(build())
+ defer stopStoreRuntimes(second.store)
+
+ firstEPs := first.store.Config[0].Endpoints
+ secondEPs := second.store.Config[0].Endpoints
+
+ if !assert.Len(t, firstEPs, 2) || !assert.Len(t, secondEPs, 2) {
+ return
+ }
+
+ // Deterministic: identical static config produces identical IDs across
+ // independent constructions, so dashboards keyed on endpoint.ID survive
+ // process restart.
+ assert.Equal(t, firstEPs[0].ID, secondEPs[0].ID)
+ assert.Equal(t, firstEPs[1].ID, secondEPs[1].ID)
+
+ // Generated prefix indicates the deterministic helper, not the legacy
+ // random UUID fallback.
+ assert.Contains(t, firstEPs[0].ID, "pixiu-generated-endpoint-")
+
+ // Endpoints differing only by port must not collide within the same cluster.
+ assert.NotEqual(t, firstEPs[0].ID, firstEPs[1].ID)
+}
+
+func TestClusterManager_AssembleEndpointsPreservesExplicitID(t *testing.T) {
+ cluster := &model.ClusterConfig{
+ Name: "explicit-id",
+ LbStr: model.LoadBalancerRoundRobin,
+ Endpoints: []*model.Endpoint{
+ {ID: "operator-pinned", Address: model.SocketAddress{Address: "127.0.0.1", Port: 21082}},
+ },
+ }
+
+ cm := testClusterManager(cluster)
+ defer stopStoreRuntimes(cm.store)
+
+ if assert.Len(t, cm.store.Config[0].Endpoints, 1) {
+ assert.Equal(t, "operator-pinned", cm.store.Config[0].Endpoints[0].ID)
+ }
+}
+
+// TestClusterManager_AssembleEndpointsCollapseDuplicateGenerated locks the
+// documented collapse behavior: two static endpoints sharing every input that
+// feeds GenerateEndpointID (cluster name, address, LLM credential) end up with
+// the same generated ID, and operators must set an explicit `id:` to keep
+// them distinct.
+func TestClusterManager_AssembleEndpointsCollapseDuplicateGenerated(t *testing.T) {
+ cluster := &model.ClusterConfig{
+ Name: "collapse-id",
+ LbStr: model.LoadBalancerRoundRobin,
+ Endpoints: []*model.Endpoint{
+ {Address: model.SocketAddress{Address: "127.0.0.1", Port: 21083}},
+ {Address: model.SocketAddress{Address: "127.0.0.1", Port: 21083}},
+ },
+ }
+
+ cm := testClusterManager(cluster)
+ defer stopStoreRuntimes(cm.store)
+
+ endpoints := cm.store.Config[0].Endpoints
+ if !assert.Len(t, endpoints, 2) {
+ return
+ }
+
+ // Both endpoints survived in the slice (we do not silently dedupe) but
+ // share the same generated identifier.
+ assert.Equal(t, endpoints[0].ID, endpoints[1].ID,
+ "endpoints with identical hash material must collapse to the same ID")
+ assert.Contains(t, endpoints[0].ID, "pixiu-generated-endpoint-")
+}
+
func testClusterManager(clusters ...*model.ClusterConfig) *ClusterManager {
return CreateDefaultClusterManager(&model.Bootstrap{
StaticResources: model.StaticResources{