This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 0facf7b7e9 fix(admin): enforce unique instance identity across 
concurrent writers (#7885)
0facf7b7e9 is described below

commit 0facf7b7e9cc9babb17e4a30400b6029ae86444c
Author: cloudbase-ai <[email protected]>
AuthorDate: Wed Oct 21 10:18:50 2026 +0800

    fix(admin): enforce unique instance identity across concurrent writers 
(#7885)
    
    Signed-off-by: cloudbase-ai <[email protected]>
    Co-authored-by: cloudbase-ai 
<[email protected]>
---
 .github/scripts/native-sql-matrix.py               |  66 ++++++
 .github/scripts/test-native-sql-matrix.py          |  32 +++
 .github/workflows/native-sql-matrix.yml            |   2 +-
 db/init/mysql/schema.sql                           |   3 +-
 db/init/oracle/schema.sql                          |   3 +-
 db/init/pg/create-table.sql                        |   3 +-
 db/upgrade/2.7.2-upgrade-2.7.3-h2.sql              |  42 ++++
 db/upgrade/2.7.2-upgrade-2.7.3-mysql.sql           |  21 ++
 db/upgrade/2.7.2-upgrade-2.7.3-ob.sql              |  21 ++
 db/upgrade/2.7.2-upgrade-2.7.3-og.sql              |  23 ++
 db/upgrade/2.7.2-upgrade-2.7.3-oracle.sql          |  23 ++
 db/upgrade/2.7.2-upgrade-2.7.3-pg.sql              |  23 ++
 db/upgrade/native-sql-matrix.md                    |   9 +
 db/upgrade/upgrade-guide.md                        |  31 +++
 .../service/impl/InstanceInfoServiceImpl.java      |  15 +-
 .../resources/mappers/instance-info-sqlmap.xml     |   1 +
 .../src/main/resources/sql-script/h2/schema.sql    |   3 +-
 .../admin/mapper/InstanceInfoMapperTest.java       |   7 +-
 .../admin/mapper/InstanceInfoUpsertTest.java       | 247 +++++++++++++++++++++
 .../service/impl/InstanceInfoServiceImplTest.java  |  22 ++
 20 files changed, 589 insertions(+), 8 deletions(-)

diff --git a/.github/scripts/native-sql-matrix.py 
b/.github/scripts/native-sql-matrix.py
index 2567729d28..570cc7b00a 100644
--- a/.github/scripts/native-sql-matrix.py
+++ b/.github/scripts/native-sql-matrix.py
@@ -449,6 +449,68 @@ class Engine:
             raise AssertionError("Duplicate data permission was accepted")
 
 
+    def insert_instance_observations(self, duplicates=False):
+        # Exercise every winner tie-break and every identity component 
independently.
+        rows = [
+            ("heartbeat", "ns", "ip", "port", "type", 200, 2, 1),
+            ("updated", "ns-updated", "ip", "port", "type", 0, 2, 1),
+            ("created", "ns-created", "ip", "port", "type", 0, 2, 2),
+            ("tie-z", "ns-tie", "ip", "port", "type", 0, 2, 1),
+            ("namespace", "ns-other", "ip", "port", "type", 50, 2, 1),
+            ("ip", "ns", "ip-other", "port", "type", 50, 2, 1),
+            ("port", "ns", "ip", "port-other", "type", 50, 2, 1),
+            ("type", "ns", "ip", "port", "type-other", 50, 2, 1),
+            ("max", "n" * 50, "i" * 128, "p" * 128, "t" * 128, 50, 2, 1),
+        ]
+        if duplicates:
+            rows += [
+                ("old-heartbeat", "ns", "ip", "port", "type", 100, 3, 1),
+                ("old-updated", "ns-updated", "ip", "port", "type", 0, 1, 1),
+                ("old-created", "ns-created", "ip", "port", "type", 0, 2, 1),
+                ("tie-a", "ns-tie", "ip", "port", "type", 0, 2, 1),
+            ]
+        self.sql("\n".join(
+            "INSERT INTO instance_info 
(id,namespace_id,instance_ip,instance_port,instance_type,"
+            
"instance_info,instance_state,last_heart_beat_time,date_updated,date_created) "
+            f"VALUES 
('sql-instance-{identifier}','{namespace}','{ip}','{port}','{kind}','{identifier}',1,{heartbeat},"
+            f"TIMESTAMP '2026-01-0{updated} 00:00:00',TIMESTAMP 
'2026-01-0{created} 00:00:00');"
+            for identifier, namespace, ip, port, kind, heartbeat, updated, 
created in rows))
+
+    def check_instance_identity(self, indexes, mapper):
+        index = "uk_instance_info_identity"
+        if indexes.get(("instance_info", index)) != ["namespace_id", 
"instance_ip", "instance_port", "instance_type"]:
+            raise AssertionError("Wrong instance identity index columns")
+        identifiers = set(self.sql("SELECT id FROM instance_info WHERE id LIKE 
'sql-instance-%';").split())
+        expected = {"heartbeat", "updated", "created", "tie-z", "namespace", 
"ip", "port", "type", "max"}
+        if identifiers != {"sql-instance-" + name for name in expected}:
+            raise AssertionError("Instance migration lost distinct identities 
or retained the wrong observation")
+        try:
+            self.sql("INSERT INTO instance_info 
(id,namespace_id,instance_ip,instance_port,instance_type,instance_info,instance_state)
 "
+                     "VALUES 
('sql-instance-conflict','ns','ip','port','type','conflict',2);")
+        except RuntimeError as error:
+            if index not in str(error).lower():
+                raise
+        else:
+            raise AssertionError("Duplicate instance identity was accepted")
+        document = ET.parse(mapper).getroot()
+        statement = 
"".join(document.find("update[@id='updateById']").itertext())
+        values = {"id": "'sql-instance-heartbeat'", "dateUpdated": "TIMESTAMP 
'2026-02-01 00:00:00'",
+                  "instanceIp": "'ip'", "instancePort": "'port'", 
"instanceType": "'type'",
+                  "instanceInfo": "'updated-info'", "instanceState": "2"}
+        for heartbeat, expected_heartbeat in ((100, 200), (0, 200), (300, 
300), (300, 300)):
+            values["lastHeartBeatTime"] = str(heartbeat)
+            sql = re.sub(r"#\{(\w+)[^}]*}", lambda match: values[match[1]], 
statement)
+            self.sql(sql + ";")
+            if scalar(self.sql("SELECT last_heart_beat_time FROM instance_info 
WHERE id='sql-instance-heartbeat';")) != str(expected_heartbeat):
+                raise AssertionError("Instance update overwrote a newer 
heartbeat or lost a newer report")
+            state = "1" if heartbeat < 200 else "2"
+            if scalar(self.sql("SELECT instance_state FROM instance_info WHERE 
id='sql-instance-heartbeat';")) != state:
+                raise AssertionError("Instance state does not match the 
retained heartbeat")
+        if scalar(self.sql("SELECT COUNT(*) FROM instance_info WHERE 
id='sql-instance-heartbeat' "
+                           "AND date_created=TIMESTAMP '2026-01-01 
00:00:00';")) != "1":
+            raise AssertionError("Instance update changed the canonical row 
creation time")
+
+
 def execute_matrix(dialect, root, output):
     output.mkdir(parents=True, exist_ok=True)
     listing, detail = fragment_columns(root / 
"shenyu-admin/src/main/resources/mappers/plugin-sqlmap.xml")
@@ -470,10 +532,13 @@ def execute_matrix(dialect, root, output):
             if flow == "upgrade":
                 engine.insert_data_permissions(duplicates=True)
                 for step in MIGRATIONS_FROM_BASELINE:
+                    if step == "2.7.2-upgrade-2.7.3":
+                        engine.insert_instance_observations(duplicates=True)
                     migration = root / f"db/upgrade/{step}-{dialect}.sql"
                     engine.sql(migration.read_text(encoding="utf-8"))
             else:
                 engine.insert_data_permissions()
+                engine.insert_instance_observations()
             engine.check_data(listing, detail)
             current_listing = engine.sql("SELECT " + ",".join(listing) + f" 
FROM plugin WHERE id='{SENTINEL}';")
             if current_listing != original_listing:
@@ -484,6 +549,7 @@ def execute_matrix(dialect, root, output):
                     raise AssertionError(f"Migration removed rows from 
{table}: {sorted(identifiers - after)}")
             indexes = engine.indexes()
             engine.check_data_permission_uniqueness(indexes)
+            engine.check_instance_identity(indexes, root / 
"shenyu-admin/src/main/resources/mappers/instance-info-sqlmap.xml")
             if flow == "fresh" and dialect in ("mysql", "ob"):
                 engine.check_fresh_mysql_schema(schema, indexes)
             if flow == "fresh" and dialect in ("pg", "og"):
diff --git a/.github/scripts/test-native-sql-matrix.py 
b/.github/scripts/test-native-sql-matrix.py
index a07291f2f9..e9b1263569 100644
--- a/.github/scripts/test-native-sql-matrix.py
+++ b/.github/scripts/test-native-sql-matrix.py
@@ -257,6 +257,38 @@ class NativeSqlMatrixTest(unittest.TestCase):
                         self.assertRaisesRegex(AssertionError, "lost distinct 
grants or retained duplicate"):
                     engine.check_data_permission_uniqueness(indexes)
 
+    def 
test_instance_identity_check_rejects_missing_uniqueness_and_stale_updates(self):
+        indexes = {("instance_info", "uk_instance_info_identity"): 
["namespace_id", "instance_ip", "instance_port", "instance_type"]}
+        names = "heartbeat updated created tie-z namespace ip port type 
max".split()
+        mapper = ROOT / 
"shenyu-admin/src/main/resources/mappers/instance-info-sqlmap.xml"
+        for dialect in matrix.DIALECTS:
+            for mode in ("success", "missing-key", "wrong-winner", 
"stale-overwrite", "unrelated-error"):
+                heartbeats = iter((100, 200, 300, 300) if mode == 
"stale-overwrite" else (200, 200, 300, 300))
+                states = iter((1, 1, 2, 2))
+
+                def sql(query):
+                    if query.startswith("SELECT id"):
+                        kept = names + (["old-heartbeat"] if mode == 
"wrong-winner" else [])
+                        return "\n".join("sql-instance-" + name for name in 
kept)
+                    if query.startswith("INSERT"):
+                        if mode == "missing-key":
+                            return ""
+                        raise RuntimeError("other error" if mode == 
"unrelated-error" else "duplicate UK_INSTANCE_INFO_IDENTITY")
+                    if query.startswith("SELECT last_heart"):
+                        return str(next(heartbeats))
+                    if query.startswith("SELECT instance_state"):
+                        return str(next(states))
+                    return "1"
+
+                with self.subTest(dialect=dialect, mode=mode), 
tempfile.TemporaryDirectory() as directory:
+                    engine = matrix.Engine(dialect, "upgrade", Path(directory))
+                    with patch.object(engine, "sql", side_effect=sql):
+                        if mode == "success":
+                            engine.check_instance_identity(indexes, mapper)
+                        else:
+                            with self.assertRaises(RuntimeError if mode == 
"unrelated-error" else AssertionError):
+                                engine.check_instance_identity(indexes, mapper)
+
     def test_actual_mapper_projects_metadata_and_preserves_detail_jar(self):
         listing, detail = matrix.fragment_columns(ROOT / 
"shenyu-admin/src/main/resources/mappers/plugin-sqlmap.xml")
         self.assertNotIn("plugin_jar", listing)
diff --git a/.github/workflows/native-sql-matrix.yml 
b/.github/workflows/native-sql-matrix.yml
index 5c5477d2a6..7c97f8797e 100644
--- a/.github/workflows/native-sql-matrix.yml
+++ b/.github/workflows/native-sql-matrix.yml
@@ -77,7 +77,7 @@ jobs:
       - name: Verify list/detail/export and index contracts
         run: >-
           exec bash .github/scripts/maven-with-retry.sh ./mvnw -B -ntp -pl 
shenyu-admin -am test
-          
-Dtest=PluginMapperTest,PluginServiceTest,PluginControllerTest,AdminQueryIndexTest,PluginIndexUpgradeContractTest,DataPermissionMapperTest,PermissionListenerIntegrationTest
+          
-Dtest=PluginMapperTest,PluginServiceTest,PluginControllerTest,AdminQueryIndexTest,PluginIndexUpgradeContractTest,DataPermissionMapperTest,PermissionListenerIntegrationTest,InstanceInfoUpsertTest
           -Dsurefire.failIfNoSpecifiedTests=false -Dmaven.javadoc.skip=true
 
   native-sql:
diff --git a/db/init/mysql/schema.sql b/db/init/mysql/schema.sql
index 6465f0246f..b3c78a36ad 100644
--- a/db/init/mysql/schema.sql
+++ b/db/init/mysql/schema.sql
@@ -2379,7 +2379,8 @@ CREATE TABLE IF NOT EXISTS `instance_info` (
     `date_created`   timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 
'date_created',
     `date_updated`   timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 
CURRENT_TIMESTAMP COMMENT 'date_updated',
     `last_heart_beat_time`   bigint  NOT NULL DEFAULT 0 COMMENT 'last heart 
beat time',
-    PRIMARY KEY (`id`)
+    PRIMARY KEY (`id`),
+    CONSTRAINT uk_instance_info_identity UNIQUE (namespace_id, instance_ip, 
instance_port, instance_type)
 );
 
 DROP TABLE IF EXISTS `registry_config`;
diff --git a/db/init/oracle/schema.sql b/db/init/oracle/schema.sql
index ec385b4c89..543a0a0cc9 100644
--- a/db/init/oracle/schema.sql
+++ b/db/init/oracle/schema.sql
@@ -2952,7 +2952,8 @@ CREATE TABLE instance_info (
     date_created  timestamp(3)   DEFAULT SYSTIMESTAMP NOT NULL,
     date_updated  timestamp(3)   DEFAULT SYSTIMESTAMP NOT NULL,
     last_heart_beat_time number     DEFAULT 0 NOT NULL,
-    CONSTRAINT instance_info_pk PRIMARY KEY (id)
+    CONSTRAINT instance_info_pk PRIMARY KEY (id),
+    CONSTRAINT uk_instance_info_identity UNIQUE (namespace_id, instance_ip, 
instance_port, instance_type)
 );
 
 COMMENT ON TABLE instance_info IS 'Instance information table';
diff --git a/db/init/pg/create-table.sql b/db/init/pg/create-table.sql
index d0aadfc4ff..7f6179e235 100644
--- a/db/init/pg/create-table.sql
+++ b/db/init/pg/create-table.sql
@@ -2693,7 +2693,8 @@ CREATE TABLE "public"."instance_info" (
     "date_created"  timestamp(3)   NOT NULL DEFAULT timezone('UTC-8'::text, 
(now())::timestamp(0) without time zone),
     "date_updated"  timestamp(3)   NOT NULL DEFAULT timezone('UTC-8'::text, 
(now())::timestamp(0) without time zone),
     "last_heart_beat_time" int8          NOT NULL DEFAULT 0,
-    PRIMARY KEY ("id")
+    PRIMARY KEY ("id"),
+    CONSTRAINT uk_instance_info_identity UNIQUE (namespace_id, instance_ip, 
instance_port, instance_type)
 );
 COMMENT ON COLUMN "public"."instance_info"."id" IS 'primary key';
 COMMENT ON COLUMN "public"."instance_info"."namespace_id" IS 'namespace_id';
diff --git a/db/upgrade/2.7.2-upgrade-2.7.3-h2.sql 
b/db/upgrade/2.7.2-upgrade-2.7.3-h2.sql
new file mode 100644
index 0000000000..541a12baf7
--- /dev/null
+++ b/db/upgrade/2.7.2-upgrade-2.7.3-h2.sql
@@ -0,0 +1,42 @@
+-- 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 works for H2.
+ALTER TABLE selector RENAME COLUMN loged TO logged;
+ALTER TABLE rule RENAME COLUMN loged TO logged;
+
+-- Stop all Admin writers and back up instance_info before this one-time 
migration.
+-- Retain the latest heartbeat, then latest update/create timestamp, then 
greatest id.
+-- Rows upgraded from 2.7.1 have heartbeat 0 and are ordered by timestamps and 
id.
+DELETE FROM instance_info duplicate_instance
+WHERE EXISTS (
+    SELECT 1 FROM instance_info retained_instance WHERE
+    retained_instance.namespace_id = duplicate_instance.namespace_id
+    AND retained_instance.instance_ip = duplicate_instance.instance_ip
+    AND retained_instance.instance_port = duplicate_instance.instance_port
+    AND retained_instance.instance_type = duplicate_instance.instance_type
+    AND (retained_instance.last_heart_beat_time > 
duplicate_instance.last_heart_beat_time
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated > 
duplicate_instance.date_updated)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created > 
duplicate_instance.date_created)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created = 
duplicate_instance.date_created
+            AND retained_instance.id > duplicate_instance.id))
+);
+CREATE UNIQUE INDEX uk_instance_info_identity ON instance_info (namespace_id, 
instance_ip, instance_port, instance_type);
diff --git a/db/upgrade/2.7.2-upgrade-2.7.3-mysql.sql 
b/db/upgrade/2.7.2-upgrade-2.7.3-mysql.sql
index b0a7fdc256..097c3c8f83 100644
--- a/db/upgrade/2.7.2-upgrade-2.7.3-mysql.sql
+++ b/db/upgrade/2.7.2-upgrade-2.7.3-mysql.sql
@@ -17,3 +17,24 @@
 -- this file works for MySQL.
 ALTER TABLE `selector` RENAME COLUMN `loged` TO `logged`;
 ALTER TABLE `rule` RENAME COLUMN `loged` TO `logged`;
+
+-- Stop all Admin writers and back up instance_info before this one-time 
migration.
+-- Retain the latest heartbeat, then latest update/create timestamp, then 
greatest id.
+-- Rows upgraded from 2.7.1 have heartbeat 0 and are ordered by timestamps and 
id.
+DELETE duplicate_instance FROM instance_info duplicate_instance
+JOIN instance_info retained_instance ON
+    retained_instance.namespace_id = duplicate_instance.namespace_id
+    AND retained_instance.instance_ip = duplicate_instance.instance_ip
+    AND retained_instance.instance_port = duplicate_instance.instance_port
+    AND retained_instance.instance_type = duplicate_instance.instance_type
+    AND (retained_instance.last_heart_beat_time > 
duplicate_instance.last_heart_beat_time
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated > 
duplicate_instance.date_updated)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created > 
duplicate_instance.date_created)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created = 
duplicate_instance.date_created
+            AND retained_instance.id > duplicate_instance.id));
+CREATE UNIQUE INDEX uk_instance_info_identity ON instance_info (namespace_id, 
instance_ip, instance_port, instance_type);
diff --git a/db/upgrade/2.7.2-upgrade-2.7.3-ob.sql 
b/db/upgrade/2.7.2-upgrade-2.7.3-ob.sql
index cc0d06bc94..06090fd91d 100644
--- a/db/upgrade/2.7.2-upgrade-2.7.3-ob.sql
+++ b/db/upgrade/2.7.2-upgrade-2.7.3-ob.sql
@@ -17,3 +17,24 @@
 -- this file works for OceanBase (MySQL mode).
 ALTER TABLE `selector` RENAME COLUMN `loged` TO `logged`;
 ALTER TABLE `rule` RENAME COLUMN `loged` TO `logged`;
+
+-- Stop all Admin writers and back up instance_info before this one-time 
migration.
+-- Retain the latest heartbeat, then latest update/create timestamp, then 
greatest id.
+-- Rows upgraded from 2.7.1 have heartbeat 0 and are ordered by timestamps and 
id.
+DELETE duplicate_instance FROM instance_info duplicate_instance
+JOIN instance_info retained_instance ON
+    retained_instance.namespace_id = duplicate_instance.namespace_id
+    AND retained_instance.instance_ip = duplicate_instance.instance_ip
+    AND retained_instance.instance_port = duplicate_instance.instance_port
+    AND retained_instance.instance_type = duplicate_instance.instance_type
+    AND (retained_instance.last_heart_beat_time > 
duplicate_instance.last_heart_beat_time
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated > 
duplicate_instance.date_updated)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created > 
duplicate_instance.date_created)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created = 
duplicate_instance.date_created
+            AND retained_instance.id > duplicate_instance.id));
+CREATE UNIQUE INDEX uk_instance_info_identity ON instance_info (namespace_id, 
instance_ip, instance_port, instance_type);
diff --git a/db/upgrade/2.7.2-upgrade-2.7.3-og.sql 
b/db/upgrade/2.7.2-upgrade-2.7.3-og.sql
index 2709542ccc..58657c681e 100644
--- a/db/upgrade/2.7.2-upgrade-2.7.3-og.sql
+++ b/db/upgrade/2.7.2-upgrade-2.7.3-og.sql
@@ -17,3 +17,26 @@
 -- this file works for openGauss.
 ALTER TABLE "public"."selector" RENAME COLUMN "loged" TO "logged";
 ALTER TABLE "public"."rule" RENAME COLUMN "loged" TO "logged";
+
+-- Stop all Admin writers and back up instance_info before this one-time 
migration.
+-- Retain the latest heartbeat, then latest update/create timestamp, then 
greatest id.
+-- Rows upgraded from 2.7.1 have heartbeat 0 and are ordered by timestamps and 
id.
+DELETE FROM instance_info duplicate_instance
+WHERE EXISTS (
+    SELECT 1 FROM instance_info retained_instance WHERE
+    retained_instance.namespace_id = duplicate_instance.namespace_id
+    AND retained_instance.instance_ip = duplicate_instance.instance_ip
+    AND retained_instance.instance_port = duplicate_instance.instance_port
+    AND retained_instance.instance_type = duplicate_instance.instance_type
+    AND (retained_instance.last_heart_beat_time > 
duplicate_instance.last_heart_beat_time
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated > 
duplicate_instance.date_updated)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created > 
duplicate_instance.date_created)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created = 
duplicate_instance.date_created
+            AND retained_instance.id > duplicate_instance.id))
+);
+CREATE UNIQUE INDEX uk_instance_info_identity ON instance_info (namespace_id, 
instance_ip, instance_port, instance_type);
diff --git a/db/upgrade/2.7.2-upgrade-2.7.3-oracle.sql 
b/db/upgrade/2.7.2-upgrade-2.7.3-oracle.sql
index a6cfbafda0..b730778015 100644
--- a/db/upgrade/2.7.2-upgrade-2.7.3-oracle.sql
+++ b/db/upgrade/2.7.2-upgrade-2.7.3-oracle.sql
@@ -17,3 +17,26 @@
 -- this file works for Oracle.
 ALTER TABLE selector RENAME COLUMN loged TO logged;
 ALTER TABLE rule RENAME COLUMN loged TO logged;
+
+-- Stop all Admin writers and back up instance_info before this one-time 
migration.
+-- Retain the latest heartbeat, then latest update/create timestamp, then 
greatest id.
+-- Rows upgraded from 2.7.1 have heartbeat 0 and are ordered by timestamps and 
id.
+DELETE FROM instance_info duplicate_instance
+WHERE EXISTS (
+    SELECT 1 FROM instance_info retained_instance WHERE
+    retained_instance.namespace_id = duplicate_instance.namespace_id
+    AND retained_instance.instance_ip = duplicate_instance.instance_ip
+    AND retained_instance.instance_port = duplicate_instance.instance_port
+    AND retained_instance.instance_type = duplicate_instance.instance_type
+    AND (retained_instance.last_heart_beat_time > 
duplicate_instance.last_heart_beat_time
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated > 
duplicate_instance.date_updated)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created > 
duplicate_instance.date_created)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created = 
duplicate_instance.date_created
+            AND retained_instance.id > duplicate_instance.id))
+);
+CREATE UNIQUE INDEX uk_instance_info_identity ON instance_info (namespace_id, 
instance_ip, instance_port, instance_type);
diff --git a/db/upgrade/2.7.2-upgrade-2.7.3-pg.sql 
b/db/upgrade/2.7.2-upgrade-2.7.3-pg.sql
index a63fb80b85..375740e936 100644
--- a/db/upgrade/2.7.2-upgrade-2.7.3-pg.sql
+++ b/db/upgrade/2.7.2-upgrade-2.7.3-pg.sql
@@ -17,3 +17,26 @@
 -- this file works for PostgreSQL.
 ALTER TABLE "public"."selector" RENAME COLUMN "loged" TO "logged";
 ALTER TABLE "public"."rule" RENAME COLUMN "loged" TO "logged";
+
+-- Stop all Admin writers and back up instance_info before this one-time 
migration.
+-- Retain the latest heartbeat, then latest update/create timestamp, then 
greatest id.
+-- Rows upgraded from 2.7.1 have heartbeat 0 and are ordered by timestamps and 
id.
+DELETE FROM instance_info duplicate_instance
+WHERE EXISTS (
+    SELECT 1 FROM instance_info retained_instance WHERE
+    retained_instance.namespace_id = duplicate_instance.namespace_id
+    AND retained_instance.instance_ip = duplicate_instance.instance_ip
+    AND retained_instance.instance_port = duplicate_instance.instance_port
+    AND retained_instance.instance_type = duplicate_instance.instance_type
+    AND (retained_instance.last_heart_beat_time > 
duplicate_instance.last_heart_beat_time
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated > 
duplicate_instance.date_updated)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created > 
duplicate_instance.date_created)
+        OR (retained_instance.last_heart_beat_time = 
duplicate_instance.last_heart_beat_time
+            AND retained_instance.date_updated = 
duplicate_instance.date_updated
+            AND retained_instance.date_created = 
duplicate_instance.date_created
+            AND retained_instance.id > duplicate_instance.id))
+);
+CREATE UNIQUE INDEX uk_instance_info_identity ON instance_info (namespace_id, 
instance_ip, instance_port, instance_type);
diff --git a/db/upgrade/native-sql-matrix.md b/db/upgrade/native-sql-matrix.md
index a702559ec2..c69c789aa7 100644
--- a/db/upgrade/native-sql-matrix.md
+++ b/db/upgrade/native-sql-matrix.md
@@ -82,6 +82,15 @@ of every production table. No gateway request or Docker e2e 
coverage is claimed.
 Per-engine artifacts contain flow results, durations and startup logs, 
including
 failed attempts. Cleanup is restricted to that job's generated test containers.
 
+Instance identity checks seed duplicate observations immediately before the
+2.7.2-to-2.7.3 migration. They require the newest heartbeat, update timestamp,
+creation timestamp and stable ID tie-break, retain different 
namespaces/IPs/ports/
+types, and exercise maximum declared identity lengths. Both flows reject a real
+duplicate insert on `uk_instance_info_identity` and execute the actual mapper
+update SQL with stale, zero, newer and equal heartbeat timestamps. The H2 
service
+suite additionally forces two independent writers to observe absence before
+either insert, in both insertion orders, and verifies caller-transaction 
suspension.
+
 ## Local execution
 
 With a Linux Docker daemon available, from the repository root:
diff --git a/db/upgrade/upgrade-guide.md b/db/upgrade/upgrade-guide.md
index 94d8249721..dbddf8da8a 100644
--- a/db/upgrade/upgrade-guide.md
+++ b/db/upgrade/upgrade-guide.md
@@ -27,6 +27,37 @@ Fresh installations create the renamed columns directly.
 
   > this file is the Shenyu upgrade sql from v2.7.2 to v2.7.3
 
+### Instance identity uniqueness in 2.7.3
+
+The `2.7.2-upgrade-2.7.3-<dialect>.sql` scripts also enforce one
+`instance_info` row per `(namespace_id, instance_ip, instance_port, 
instance_type)`.
+Stop **all** Admin writers and back up `instance_info` before running the
+one-time migration. Existing duplicate rows are deleted, retaining the complete
+row with the greatest heartbeat timestamp, then greatest `date_updated`,
+`date_created`, and finally `id`. Distinct identities and namespaces are 
preserved;
+there are no references from gateway configuration to these diagnostic row IDs.
+Rows upgraded from 2.7.1 initially have heartbeat `0`, so timestamps and ID 
decide
+the winner until a heartbeat has been persisted.
+
+Apply the schema migration before starting the updated Admin. Fresh 
installations
+create `uk_instance_info_identity` directly. An existing persistent H2 database
+requires `2.7.2-upgrade-2.7.3-h2.sql`; rerunning `CREATE TABLE IF NOT EXISTS` 
does
+not upgrade an existing table. H2 databases predating 2.7.2 must first have the
+`last_heart_beat_time BIGINT DEFAULT 0 NOT NULL` column added.
+
+The unique key uses all four columns without prefix truncation (434 characters,
+at most 1,736 bytes with MySQL utf8mb4). MySQL installations using a legacy
+767-byte InnoDB index limit must enable the modern large-prefix/DYNAMIC format
+or upgrade before applying it. Prefix indexes would conflate distinct 
identities.
+
+Concurrent insert conflicts reload and update the canonical row. Updates accept
+only an equal or newer heartbeat; equal timestamps still allow liveness state
+changes without a fresh heartbeat. Zero or older heartbeats cannot overwrite a
+positive newer observation. This orders observations by their reported 
timestamps;
+it does not establish a global clock or break ties between simultaneous states.
+Instance diagnostic writes run independently of caller transactions so a 
duplicate
+insert does not leave PostgreSQL conflict recovery in an aborted transaction.
+
 ### Data permission uniqueness in 2.7.2
 
 Stop Admin instances before running the `2.7.1-upgrade-2.7.2-<dialect>.sql`
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImpl.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImpl.java
index d3ff27c1a1..aba2fa4193 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImpl.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImpl.java
@@ -29,7 +29,10 @@ import org.apache.shenyu.admin.utils.InstanceInfoUtils;
 import org.apache.shenyu.common.utils.GsonUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.sql.Timestamp;
 import java.time.Instant;
@@ -51,6 +54,7 @@ public class InstanceInfoServiceImpl implements 
InstanceInfoService {
     }
 
     @Override
+    @Transactional(propagation = Propagation.NOT_SUPPORTED)
     public void createOrUpdate(final InstanceInfoVO instanceInfoVO) {
         InstanceQuery instanceQuery = new InstanceQuery();
         instanceQuery.setInstanceIp(instanceInfoVO.getInstanceIp());
@@ -63,10 +67,19 @@ public class InstanceInfoServiceImpl implements 
InstanceInfoService {
             InstanceInfoDO instanceInfoDO = 
InstanceInfoDO.buildInstanceInfoDO(instanceInfoVO);
             try {
                 instanceInfoMapper.insert(instanceInfoDO);
+                return;
+            } catch (DuplicateKeyException e) {
+                // Another Admin may have registered this identity after our 
lookup.
+                // Each mapper call runs outside a transaction so PostgreSQL 
can recover
+                // from the failed insert without leaving an aborted 
transaction.
+                infoDO = instanceInfoMapper.selectOneByQuery(instanceQuery);
+                if (Objects.isNull(infoDO)) {
+                    throw e;
+                }
             } catch (Exception e) {
                 LOG.error("Failed to register instance info", e);
+                return;
             }
-            return;
         }
         LOG.debug("Update instance info: {}", 
GsonUtils.getInstance().toJson(instanceInfoVO));
         infoDO.setInstanceIp(instanceInfoVO.getInstanceIp());
diff --git a/shenyu-admin/src/main/resources/mappers/instance-info-sqlmap.xml 
b/shenyu-admin/src/main/resources/mappers/instance-info-sqlmap.xml
index 61e4379f0a..32f8b1321d 100644
--- a/shenyu-admin/src/main/resources/mappers/instance-info-sqlmap.xml
+++ b/shenyu-admin/src/main/resources/mappers/instance-info-sqlmap.xml
@@ -59,6 +59,7 @@
           last_heart_beat_time = #{lastHeartBeatTime, jdbcType=BIGINT}
         WHERE
           id = #{id, jdbcType=VARCHAR}
+          AND last_heart_beat_time &lt;= #{lastHeartBeatTime, jdbcType=BIGINT}
     </update>
     
     <select id="findAllByNamespaceId" resultMap="BaseResultMap">
diff --git a/shenyu-admin/src/main/resources/sql-script/h2/schema.sql 
b/shenyu-admin/src/main/resources/sql-script/h2/schema.sql
index 23a4bdb726..13dc381e2d 100644
--- a/shenyu-admin/src/main/resources/sql-script/h2/schema.sql
+++ b/shenyu-admin/src/main/resources/sql-script/h2/schema.sql
@@ -1418,7 +1418,8 @@ CREATE TABLE IF NOT EXISTS instance_info
     `date_created`   timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 
'date_created',
     `date_updated`   timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 
CURRENT_TIMESTAMP COMMENT 'date_updated',
     `last_heart_beat_time`   bigint  NOT NULL DEFAULT 0 COMMENT 'last heart 
beat time',
-    PRIMARY KEY (`id`)
+    PRIMARY KEY (`id`),
+    CONSTRAINT uk_instance_info_identity UNIQUE (namespace_id, instance_ip, 
instance_port, instance_type)
 );
 
 INSERT IGNORE INTO `permission` (`id`, `object_id`, `resource_id`, 
`date_created`, `date_updated`) VALUES ('1697141926281382720', 
'1346358560427216896', '1346778036402483300', '2023-08-31 06:59:01', 
'2023-08-31 06:59:01');
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoMapperTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoMapperTest.java
index 9b0520854b..c35fb3e143 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoMapperTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoMapperTest.java
@@ -31,6 +31,7 @@ import jakarta.annotation.Resource;
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static 
org.apache.shenyu.common.constant.Constants.SYS_DEFAULT_NAMESPACE_ID;
 import static org.hamcrest.MatcherAssert.assertThat;
@@ -45,6 +46,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  */
 public final class InstanceInfoMapperTest extends 
AbstractSpringIntegrationTest {
 
+    private static final AtomicInteger NEXT_PORT = new AtomicInteger(10000);
+
     private static final long FIXED_HEART_BEAT_TIME = 1700000000000L;
 
     @Resource
@@ -198,12 +201,12 @@ public final class InstanceInfoMapperTest extends 
AbstractSpringIntegrationTest
         data.setDateCreated(now);
         data.setDateUpdated(now);
         data.setInstanceIp("127.0.0.1");
-        data.setInstancePort("8080");
+        data.setInstancePort(Integer.toString(NEXT_PORT.getAndIncrement()));
         data.setInstanceType("grpc");
         data.setInstanceInfo("info");
         data.setInstanceState(1);
         data.setNamespaceId(SYS_DEFAULT_NAMESPACE_ID);
-        data.setLastHeartBeatTime(System.currentTimeMillis());
+        data.setLastHeartBeatTime(FIXED_HEART_BEAT_TIME - 1);
         return data;
     }
 }
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoUpsertTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoUpsertTest.java
new file mode 100644
index 0000000000..e001c9d568
--- /dev/null
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/mapper/InstanceInfoUpsertTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.
+ */
+
+package org.apache.shenyu.admin.mapper;
+
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.io.Resources;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.Configuration;
+import org.apache.ibatis.session.SqlSessionFactoryBuilder;
+import org.apache.shenyu.admin.model.entity.InstanceInfoDO;
+import org.apache.shenyu.admin.model.query.InstanceQuery;
+import org.apache.shenyu.admin.model.vo.InstanceInfoVO;
+import org.apache.shenyu.admin.service.InstanceInfoService;
+import org.apache.shenyu.admin.service.impl.InstanceInfoServiceImpl;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
+import org.springframework.aop.framework.ProxyFactory;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import 
org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.AdditionalAnswers.delegatesTo;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Exercises concurrent writers with real SQL sessions and the production H2 
schema.
+ */
+public final class InstanceInfoUpsertTest {
+
+    private JdbcTemplate jdbc;
+
+    private InstanceInfoMapper mapper;
+
+    private DataSourceTransactionManager transactionManager;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        JdbcDataSource dataSource = new JdbcDataSource();
+        dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + 
";MODE=MySQL;DB_CLOSE_DELAY=-1");
+        jdbc = new JdbcTemplate(dataSource);
+        transactionManager = new DataSourceTransactionManager(dataSource);
+        try (InputStream schema = 
Resources.getResourceAsStream("sql-script/h2/schema.sql")) {
+            String sql = new String(schema.readAllBytes(), 
StandardCharsets.UTF_8);
+            Matcher table = Pattern.compile("(?is)CREATE TABLE IF NOT EXISTS 
instance_info\\s*\\(.*?\\);").matcher(sql);
+            assertTrue(table.find());
+            jdbc.execute(table.group());
+        }
+        Configuration configuration = new Configuration(new 
Environment("test", new SpringManagedTransactionFactory(), dataSource));
+        configuration.setMapUnderscoreToCamelCase(true);
+        try (InputStream in = 
Resources.getResourceAsStream("mappers/instance-info-sqlmap.xml")) {
+            new XMLMapperBuilder(in, configuration, 
"mappers/instance-info-sqlmap.xml", configuration.getSqlFragments()).parse();
+        }
+        mapper = new SqlSessionTemplate(new 
SqlSessionFactoryBuilder().build(configuration)).getMapper(InstanceInfoMapper.class);
+    }
+
+    @AfterEach
+    void tearDown() {
+        jdbc.execute("SHUTDOWN");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    void concurrentRegistrationRetainsOneLatestObservation(final boolean 
newerFirst) throws Exception {
+        CyclicBarrier absentLookups = new CyclicBarrier(2);
+        AtomicInteger lookups = new AtomicInteger();
+        CountDownLatch firstInsert = new CountDownLatch(1);
+        InstanceInfoMapper concurrentMapper = mock(InstanceInfoMapper.class, 
delegatesTo(mapper));
+        doAnswer(invocation -> {
+            InstanceInfoDO found = 
mapper.selectOneByQuery(invocation.getArgument(0));
+            if (lookups.incrementAndGet() <= 2) {
+                assertNull(found);
+                absentLookups.await(10, TimeUnit.SECONDS);
+            }
+            return found;
+        }).when(concurrentMapper).selectOneByQuery(any(InstanceQuery.class));
+        doAnswer(invocation -> {
+            InstanceInfoDO observation = invocation.getArgument(0);
+            boolean first = (observation.getLastHeartBeatTime() == 200) == 
newerFirst;
+            if (!first) {
+                assertTrue(firstInsert.await(10, TimeUnit.SECONDS));
+            }
+            try {
+                return mapper.insert(observation);
+            } finally {
+                if (first) {
+                    firstInsert.countDown();
+                }
+            }
+        }).when(concurrentMapper).insert(any(InstanceInfoDO.class));
+        InstanceInfoServiceImpl service = new 
InstanceInfoServiceImpl(concurrentMapper);
+        ExecutorService writers = Executors.newFixedThreadPool(2);
+        try {
+            Future<?> older = writers.submit(() -> 
service.createOrUpdate(report("ns", 100, 2)));
+            Future<?> newer = writers.submit(() -> 
service.createOrUpdate(report("ns", 200, 1)));
+            older.get(15, TimeUnit.SECONDS);
+            newer.get(15, TimeUnit.SECONDS);
+            List<InstanceInfoDO> rows = mapper.findAllByNamespaceId("ns");
+            assertEquals(1, rows.size(), "The database must enforce logical 
identity across writers");
+            assertEquals(200, rows.get(0).getLastHeartBeatTime());
+            assertEquals(1, rows.get(0).getInstanceState());
+            assertEquals("info-200", rows.get(0).getInstanceInfo());
+        } finally {
+            writers.shutdownNow();
+            assertTrue(writers.awaitTermination(10, TimeUnit.SECONDS));
+        }
+    }
+
+    @Test
+    void 
staleUpdateDoesNotOverwriteNewerReportAndNamespacesRemainIndependent() {
+        InstanceInfoServiceImpl service = new InstanceInfoServiceImpl(mapper);
+        service.createOrUpdate(report("ns", 200, 1));
+        final InstanceInfoDO initial = 
mapper.findAllByNamespaceId("ns").get(0);
+        service.createOrUpdate(report("other", 50, 2));
+        service.createOrUpdate(report("ns", 100, 2));
+        service.createOrUpdate(report("ns", 0, 0));
+        InstanceInfoDO latest = mapper.findAllByNamespaceId("ns").get(0);
+        assertEquals(initial.getId(), latest.getId());
+        assertEquals(initial.getDateCreated(), latest.getDateCreated());
+        assertEquals(200, latest.getLastHeartBeatTime());
+        assertEquals(1, latest.getInstanceState());
+        assertEquals("info-200", latest.getInstanceInfo());
+        assertEquals(50, 
mapper.findAllByNamespaceId("other").get(0).getLastHeartBeatTime());
+
+        // Liveness can transition to offline without a new heartbeat.
+        service.createOrUpdate(report("ns", 200, 2));
+        assertEquals(2, 
mapper.findAllByNamespaceId("ns").get(0).getInstanceState());
+        service.createOrUpdate(report("ns", 300, 1));
+        assertEquals(300, 
mapper.findAllByNamespaceId("ns").get(0).getLastHeartBeatTime());
+        assertEquals(2, mapper.selectAll().size());
+    }
+
+    @Test
+    void conflictRecoverySuspendsTheCallersTransaction() {
+        new InstanceInfoServiceImpl(mapper).createOrUpdate(report("ns", 100, 
2));
+        AtomicInteger lookups = new AtomicInteger();
+        InstanceInfoMapper conflictingMapper = mock(InstanceInfoMapper.class, 
delegatesTo(mapper));
+        doAnswer(invocation -> lookups.incrementAndGet() == 1 ? null : 
mapper.selectOneByQuery(invocation.getArgument(0)))
+                
.when(conflictingMapper).selectOneByQuery(any(InstanceQuery.class));
+        ProxyFactory factory = new ProxyFactory(new 
InstanceInfoServiceImpl(conflictingMapper));
+        factory.addAdvice(new TransactionInterceptor(transactionManager, new 
AnnotationTransactionAttributeSource()));
+        InstanceInfoService service = (InstanceInfoService) factory.getProxy();
+        new 
TransactionTemplate(transactionManager).executeWithoutResult(status -> {
+            service.createOrUpdate(report("ns", 200, 1));
+            assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM 
instance_info", Integer.class));
+            status.setRollbackOnly();
+        });
+        assertEquals(200, 
mapper.findAllByNamespaceId("ns").get(0).getLastHeartBeatTime());
+    }
+
+    @Test
+    void migrationRetainsLatestCompleteRowsAndRejectsDuplicates() {
+        jdbc.execute("ALTER TABLE instance_info DROP CONSTRAINT 
uk_instance_info_identity");
+        jdbc.execute("CREATE TABLE selector (loged INT)");
+        jdbc.execute("CREATE TABLE rule (loged INT)");
+        insertLegacy("old-heartbeat", "ns", 100, "2026-01-03 00:00:00");
+        insertLegacy("latest-heartbeat", "ns", 200, "2026-01-01 00:00:00");
+        insertLegacy("tie-a", "ns-tie", 0, "2026-01-01 00:00:00");
+        insertLegacy("tie-z", "ns-tie", 0, "2026-01-01 00:00:00");
+        insertLegacy("other", "ns-other", 100, "2026-01-01 00:00:00");
+        Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath();
+        while (Objects.nonNull(root) && 
!Files.isDirectory(root.resolve("db/upgrade"))) {
+            root = root.getParent();
+        }
+        assertNotNull(root);
+        new ResourceDatabasePopulator(new 
FileSystemResource(root.resolve("db/upgrade/2.7.2-upgrade-2.7.3-h2.sql")))
+                .execute(Objects.requireNonNull(jdbc.getDataSource()));
+        assertEquals(Set.of("latest-heartbeat", "tie-z", "other"), 
mapper.selectAll().stream().map(InstanceInfoDO::getId).collect(Collectors.toSet()));
+        InstanceInfoDO retained = mapper.selectById("latest-heartbeat");
+        assertEquals(200, retained.getLastHeartBeatTime());
+        assertEquals("info-200", retained.getInstanceInfo());
+        assertEquals(Timestamp.valueOf("2026-01-01 00:00:00"), 
retained.getDateUpdated());
+        assertThrows(DuplicateKeyException.class, () -> 
insertLegacy("conflict", "ns", 300, "2026-01-04 00:00:00"));
+    }
+
+    private void insertLegacy(final String id, final String namespace, final 
long heartbeat, final String updated) {
+        InstanceInfoDO row = 
InstanceInfoDO.buildInstanceInfoDO(report(namespace, heartbeat, 1));
+        row.setId(id);
+        row.setDateCreated(Timestamp.valueOf("2026-01-01 00:00:00"));
+        row.setDateUpdated(Timestamp.valueOf(updated));
+        mapper.insert(row);
+    }
+
+    private InstanceInfoVO report(final String namespace, final long 
heartbeat, final int state) {
+        InstanceInfoVO report = new InstanceInfoVO();
+        report.setNamespaceId(namespace);
+        report.setInstanceIp("127.0.0.1");
+        report.setInstancePort("9195");
+        report.setInstanceType("bootstrap");
+        report.setInstanceInfo("info-" + heartbeat);
+        report.setLastHeartBeatTime(heartbeat);
+        report.setInstanceState(state);
+        return report;
+    }
+}
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImplTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImplTest.java
index 1bc42618ed..693ea84e6c 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImplTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceInfoServiceImplTest.java
@@ -31,15 +31,18 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
+import org.springframework.dao.DuplicateKeyException;
 
 import java.util.Arrays;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
 
 /**
  * Test case for {@link InstanceInfoServiceImpl}.
@@ -96,6 +99,25 @@ public final class InstanceInfoServiceImplTest {
         assertEquals(FIXED_HEART_BEAT_TIME, 
captor.getValue().getLastHeartBeatTime());
     }
 
+    @Test
+    public void testDuplicateRegistrationUpdatesTheCanonicalRow() {
+        
given(instanceInfoMapper.selectOneByQuery(any(InstanceQuery.class))).willReturn(null,
 buildDO("winner"));
+        
given(instanceInfoMapper.insert(any(InstanceInfoDO.class))).willThrow(new 
DuplicateKeyException("identity conflict"));
+        instanceInfoService.createOrUpdate(buildReport());
+        ArgumentCaptor<InstanceInfoDO> captor = 
ArgumentCaptor.forClass(InstanceInfoDO.class);
+        verify(instanceInfoMapper).updateById(captor.capture());
+        assertEquals("winner", captor.getValue().getId());
+        assertEquals(FIXED_HEART_BEAT_TIME, 
captor.getValue().getLastHeartBeatTime());
+    }
+
+    @Test
+    public void 
testUnrelatedPrimaryKeyConflictIsNotTreatedAsAnIdentityConflict() {
+        
given(instanceInfoMapper.selectOneByQuery(any(InstanceQuery.class))).willReturn(null);
+        
given(instanceInfoMapper.insert(any(InstanceInfoDO.class))).willThrow(new 
DuplicateKeyException("primary key conflict"));
+        assertThrows(DuplicateKeyException.class, () -> 
instanceInfoService.createOrUpdate(buildReport()));
+        verify(instanceInfoMapper, never()).updateById(any());
+    }
+
     private InstanceInfoVO buildReport() {
         InstanceInfoVO report = new InstanceInfoVO();
         report.setInstanceIp("127.0.0.1");

Reply via email to