Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
911f043
feat: add manual discovery upstream status
Aias00 Apr 1, 2026
1041025
Update shenyu-admin/src/main/resources/mappers/discovery-upstream-sql…
Aias00 Apr 1, 2026
a0e40b2
Update shenyu-admin/src/main/java/org/apache/shenyu/admin/model/dto/U…
Aias00 Apr 1, 2026
9648ac5
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 1, 2026
e74205b
fix: add discovery upstream manual status ddl
Aias00 Apr 2, 2026
f111247
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 2, 2026
315cec2
fix discovery upstream manual status fallback
Aias00 Apr 2, 2026
59ada22
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 14, 2026
ae77e1e
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 14, 2026
1722504
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 15, 2026
54bcd99
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 15, 2026
8993aca
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 24, 2026
53b999b
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 24, 2026
ba2aad1
Merge branch 'master' into feat/upstream-manual-status
Aias00 Apr 27, 2026
cbb516e
Merge branch 'master' into feat/upstream-manual-status
Aias00 May 8, 2026
47a3ff8
Merge branch 'master' into feat/upstream-manual-status
Aias00 May 12, 2026
d1ec4f4
Merge branch 'master' into feat/upstream-manual-status
Aias00 Jun 8, 2026
281cb25
Merge branch 'master' into feat/upstream-manual-status
Aias00 Jun 11, 2026
2fe4494
Merge branch 'master' into feat/upstream-manual-status
Aias00 Jun 11, 2026
846c77e
Merge branch 'master' into feat/upstream-manual-status
Aias00 Jun 12, 2026
1246512
Merge branch 'master' into feat/upstream-manual-status
Aias00 Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/superpowers/plans/2026-04-01-upstream-manual-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Upstream Manual Status Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add persisted manual upstream offline control and make Admin, sync payloads, and Gateway selection honor it end to end.

**Architecture:** Introduce a shared `UpstreamManualStatusEnum`, persist it on `discovery_upstream`, update Admin service/controller flows to publish sync events after manual changes, and carry the new field through sync DTOs into Gateway cache objects where load-balancer selection filters forced-offline upstreams.

**Tech Stack:** Java, Spring MVC, MyBatis, Maven, JUnit 5, Mockito

---

### Task 1: Add Failing Admin Tests

**Files:**
- Modify: `shenyu-admin/src/test/java/org/apache/shenyu/admin/service/DiscoveryUpstreamServiceTest.java`
- Modify: `shenyu-admin/src/test/java/org/apache/shenyu/admin/service/SyncDataServiceTest.java`
- Test: `shenyu-admin/src/test/java/org/apache/shenyu/admin/service/DiscoveryUpstreamServiceTest.java`

- [ ] **Step 1: Write failing tests for manual status update and status short-circuit**
- [ ] **Step 2: Write failing assertions that sync payload exposes `manualStatus`**
- [ ] **Step 3: Run admin tests to verify they fail for missing field and behavior**
- [ ] **Step 4: Keep failures focused on the new contract**

### Task 2: Add Failing Gateway Tests

**Files:**
- Modify: `shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/factory/LoadBalancerFactoryTest.java`
- Modify: `shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/test/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandlerTest.java`
- Test: `shenyu-loadbalancer/src/test/java/org/apache/shenyu/loadbalancer/factory/LoadBalancerFactoryTest.java`

- [ ] **Step 1: Add a failing load-balancer test that excludes `FORCE_OFFLINE` upstreams**
- [ ] **Step 2: Add a failing divide handler test that maps sync payload `manualStatus` into cached upstreams**
- [ ] **Step 3: Run targeted gateway tests to verify red state**

### Task 3: Implement Shared Enum And DTO Changes

**Files:**
- Create: `shenyu-common/src/main/java/org/apache/shenyu/common/enums/UpstreamManualStatusEnum.java`
- Modify: `shenyu-common/src/main/java/org/apache/shenyu/common/dto/DiscoveryUpstreamData.java`
- Modify: `shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/entity/Upstream.java`

- [ ] **Step 1: Add the shared enum with `NONE` and `FORCE_OFFLINE`**
- [ ] **Step 2: Extend sync DTO and cached upstream entity with `manualStatus`**
- [ ] **Step 3: Keep defaults backward compatible with `NONE`**

### Task 4: Implement Admin Persistence And API

**Files:**
- Modify: `shenyu-admin/src/main/resources/sql-script/h2/schema.sql`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/model/entity/DiscoveryUpstreamDO.java`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/model/dto/DiscoveryUpstreamDTO.java`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/model/vo/DiscoveryUpstreamVO.java`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/mapper/DiscoveryUpstreamMapper.java`
- Modify: `shenyu-admin/src/main/resources/mappers/discovery-upstream-sqlmap.xml`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/transfer/DiscoveryTransfer.java`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/service/DiscoveryUpstreamService.java`
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/DiscoveryUpstreamServiceImpl.java`
- Create: `shenyu-admin/src/main/java/org/apache/shenyu/admin/model/dto/UpstreamManualStatusDTO.java`
- Create: `shenyu-admin/src/main/java/org/apache/shenyu/admin/controller/UpstreamController.java`

- [ ] **Step 1: Persist `manual_status` and map it through DO/DTO/VO/Mapper**
- [ ] **Step 2: Add service methods to change manual status and publish fresh discovery events**
- [ ] **Step 3: Add `/upstream/offline` and `/upstream/online` controller endpoints**

### Task 5: Implement Heartbeat Short-Circuit And Gateway Filtering

**Files:**
- Modify: `shenyu-admin/src/main/java/org/apache/shenyu/admin/service/register/AbstractShenyuClientRegisterServiceImpl.java`
- Modify: `shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide/src/main/java/org/apache/shenyu/plugin/divide/handler/DivideUpstreamDataHandler.java`
- Modify: `shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-websocket/src/main/java/org/apache/shenyu/plugin/websocket/handler/WebSocketUpstreamDataHandler.java`
- Modify: `shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-rpc/shenyu-plugin-grpc/src/main/java/org/apache/shenyu/plugin/grpc/handler/GrpcDiscoveryUpstreamDataHandler.java`
- Modify: `shenyu-loadbalancer/src/main/java/org/apache/shenyu/loadbalancer/factory/LoadBalancerFactory.java`

- [ ] **Step 1: Prevent alive/status recovery when the DB record is `FORCE_OFFLINE`**
- [ ] **Step 2: Map synced `manualStatus` into plugin-specific upstream cache objects**
- [ ] **Step 3: Filter forced-offline upstreams before selection**

### Task 6: Verify Green State

**Files:**
- Modify: `docs/superpowers/specs/2026-04-01-upstream-manual-status-design.md`
- Modify: `docs/superpowers/plans/2026-04-01-upstream-manual-status.md`

- [ ] **Step 1: Run targeted Maven tests for admin, loadbalancer, and divide modules**
- [ ] **Step 2: Run a focused compile if any cross-module breakage appears**
- [ ] **Step 3: Review git diff for unintended changes**
- [ ] **Step 4: Commit with one feature commit**
92 changes: 92 additions & 0 deletions docs/superpowers/specs/2026-04-01-upstream-manual-status-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Upstream Manual Status Design

## Goal

Add a persisted manual upstream control flag that lets Admin force a discovery upstream offline without being overwritten by heartbeat recovery, and make Gateway honor that flag during upstream selection.

## Background

Today `discovery_upstream.upstream_status` is used for automatic liveness. Admin-triggered manual offline and automatic health recovery share the same status channel, so a heartbeat or recovery path can bring a manually disabled upstream back into traffic.

## Chosen Approach

Use a separate manual status field.

- Persist `manual_status` on `discovery_upstream` with default `NONE`.
- Represent manual control with a shared enum `NONE` and `FORCE_OFFLINE`.
- Keep `upstream_status` for automatic health only.
- Make Admin manual APIs write only `manualStatus`.
- Let heartbeat or recovery logic skip `status=true` updates when `manualStatus == FORCE_OFFLINE`.
- Include `manualStatus` in discovery sync payloads and Gateway cache objects.
- Filter `FORCE_OFFLINE` upstreams before load-balancer selection.

This keeps automatic and manual state independent and avoids hidden coupling.

## Alternatives Considered

### Reuse `upstream_status`

Rejected because heartbeat and health check would continue to overwrite manual operations.

### Keep manual state only in Gateway memory

Rejected because it would not survive restarts or sync across Admin and Gateway nodes.

## Data Model

Add `manual_status varchar(32) not null default 'NONE'` to `discovery_upstream`.

Shared enum:

- `NONE`
- `FORCE_OFFLINE`

`/upstream/online` resets the field to `NONE`.

## Admin API

Add a new Admin controller rooted at `/upstream` with:

- `POST /upstream/offline`
- `POST /upstream/online`

Request body will identify the upstream by `selectorId` and `url`.

Behavior:

- Look up the related discovery handler by selector id.
- Update only `manual_status`.
- Publish a fresh `DISCOVER_UPSTREAM` event built from current DB data so gateways receive the new flag immediately.

## Status Update Rules

Automatic writers keep their current responsibility for `upstream_status`.

Additional rule:

- If a write intends to mark an upstream alive (`status=true`) and the record is `FORCE_OFFLINE`, skip the status update.

This protects the manual offline decision from heartbeat recovery without blocking automatic offline transitions.

## Sync Contract

Extend `DiscoveryUpstreamData` and all transfer paths to include `manualStatus`.

Admin event producers and Gateway sync consumers will continue to use the same payload shape, now with one extra field.

## Gateway Behavior

Extend cached upstream objects with `manualStatus`.

Gateway will filter out `FORCE_OFFLINE` upstreams before selection. This ensures:

- Manually offline nodes are never chosen.
- Existing health-check metadata can still be retained.
- Re-enabling an upstream only requires Admin to push a new sync event with `manualStatus=NONE`.

## Testing Strategy

- Admin service tests for manual status update and status recovery short-circuit.
- Sync/transfer tests for `manualStatus` propagation.
- Load-balancer tests for filtering `FORCE_OFFLINE`.
- Divide discovery handler test for mapping sync payload to cached upstream manual status.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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.controller;

import jakarta.validation.Valid;
import org.apache.shenyu.admin.aspect.annotation.RestApi;
import org.apache.shenyu.admin.model.dto.UpstreamManualStatusDTO;
import org.apache.shenyu.admin.model.result.ShenyuAdminResult;
import org.apache.shenyu.admin.service.DiscoveryUpstreamService;
import org.apache.shenyu.admin.utils.ShenyuResultMessage;
import org.apache.shenyu.common.enums.UpstreamManualStatusEnum;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

/**
* Upstream controller.
*/
@RestApi("/upstream")
public class UpstreamController {

private final DiscoveryUpstreamService discoveryUpstreamService;

public UpstreamController(final DiscoveryUpstreamService discoveryUpstreamService) {
this.discoveryUpstreamService = discoveryUpstreamService;
}

/**
* manual offline.
*
* @param upstreamManualStatusDTO upstream request
* @return result
*/
@PostMapping("/offline")
public ShenyuAdminResult offline(@Valid @RequestBody final UpstreamManualStatusDTO upstreamManualStatusDTO) {
discoveryUpstreamService.changeManualStatusBySelectorIdAndUrl(
upstreamManualStatusDTO.getSelectorId(),
upstreamManualStatusDTO.getUrl(),
UpstreamManualStatusEnum.FORCE_OFFLINE);
return ShenyuAdminResult.success(ShenyuResultMessage.UPDATE_SUCCESS);
}

/**
* manual online.
*
* @param upstreamManualStatusDTO upstream request
* @return result
*/
@PostMapping("/online")
public ShenyuAdminResult online(@Valid @RequestBody final UpstreamManualStatusDTO upstreamManualStatusDTO) {
discoveryUpstreamService.changeManualStatusBySelectorIdAndUrl(
upstreamManualStatusDTO.getSelectorId(),
upstreamManualStatusDTO.getUrl(),
UpstreamManualStatusEnum.NONE);
return ShenyuAdminResult.success(ShenyuResultMessage.UPDATE_SUCCESS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,15 @@ public interface DiscoveryUpstreamMapper extends ExistProvider {
*/
int updateStatusByUrl(@Param("discoveryHandlerId") String discoveryHandlerId, @Param("upstreamUrl") String upstreamUrl, @Param("upstreamStatus") int upstreamStatus);

/**
* update manual status by url.
*
* @param discoveryHandlerId discoveryHandlerId
* @param upstreamUrl upstreamUrl
* @param manualStatus manualStatus
* @return effect
*/
int updateManualStatusByUrl(@Param("discoveryHandlerId") String discoveryHandlerId, @Param("upstreamUrl") String upstreamUrl,
@Param("manualStatus") String manualStatus);

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
import org.apache.shenyu.admin.mapper.DiscoveryUpstreamMapper;
import org.apache.shenyu.admin.mapper.NamespaceMapper;
import org.apache.shenyu.admin.validation.annotation.Existed;
import org.apache.shenyu.common.enums.UpstreamManualStatusEnum;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.io.Serializable;
import java.sql.Timestamp;
import java.util.Objects;

/**
* discovery upstream dto.
Expand Down Expand Up @@ -92,6 +94,11 @@ public class DiscoveryUpstreamDTO implements Serializable {
*/
private Timestamp dateUpdated;

/**
* manual status.
*/
private String manualStatus;

/**
* getId.
*
Expand Down Expand Up @@ -284,4 +291,22 @@ public String getNamespaceId() {
public void setNamespaceId(final String namespaceId) {
this.namespaceId = namespaceId;
}

/**
* get manualStatus.
*
* @return manualStatus
*/
public String getManualStatus() {
return manualStatus;
}

/**
* set manualStatus.
*
* @param manualStatus manualStatus
*/
public void setManualStatus(final String manualStatus) {
this.manualStatus = Objects.isNull(manualStatus) ? null : UpstreamManualStatusEnum.normalize(manualStatus);
}
Comment on lines +309 to +311

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setter treats an empty string as a provided value (it will be normalized, likely to NONE), while other parts of the service use StringUtils.hasLength(...) to detect 'not provided' and preserve existing DB state. To keep request semantics consistent, consider treating blank/empty as null here as well (so callers can omit the field without accidentally overwriting to NONE).

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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.model.dto;

import jakarta.validation.constraints.NotBlank;

/**
* Manual upstream status request.
*/
public class UpstreamManualStatusDTO {

/**
* selector id.
*/
@NotBlank(message = "selectorId can't be null")
private String selectorId;

/**
* upstream url.
*/
@NotBlank(message = "url can't be null")

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NotBlank validates both null and blank/whitespace. The message currently says "can't be null", which is misleading for empty or whitespace values. Consider changing the messages to "can't be blank" (or "can't be null or blank") to match the actual constraint behavior.

Suggested change
@NotBlank(message = "selectorId can't be null")
private String selectorId;
/**
* upstream url.
*/
@NotBlank(message = "url can't be null")
@NotBlank(message = "selectorId can't be null or blank")
private String selectorId;
/**
* upstream url.
*/
@NotBlank(message = "url can't be null or blank")

Copilot uses AI. Check for mistakes.
Comment thread
Aias00 marked this conversation as resolved.
Outdated
private String url;

/**
* get selectorId.
*
* @return selectorId
*/
public String getSelectorId() {
return selectorId;
}

/**
* set selectorId.
*
* @param selectorId selectorId
*/
public void setSelectorId(final String selectorId) {
this.selectorId = selectorId;
}

/**
* get url.
*
* @return url
*/
public String getUrl() {
return url;
}

/**
* set url.
*
* @param url url
*/
public void setUrl(final String url) {
this.url = url;
}
}
Loading
Loading