This is an automated email from the ASF dual-hosted git repository. dmsolr pushed a commit to branch docs/plugin-graph-contract in repository https://gitbox.apache.org/repos/asf/shenyu.git
commit f2839049ca3873cde0b0c8fe6ff519c040dc905a Author: Luke Haochao Zhuang <[email protected]> AuthorDate: Fri Oct 23 11:35:13 2026 +0800 docs: add plugin graph orchestration contract and compatibility audit Add the Phase-1 design artifacts for optional DAG-based plugin orchestration (parent proposal #7993): - docs/plugin-graph-orchestration.md: versioned graph schema, node/condition semantics, Phase-1 structural restrictions, graph limits, wrapper-scope classes, the dual-executor (LEGACY/GRAPH) contract with sequence diagrams, and explicit decisions for repeated continuations, mandatory dependencies (fail closed) and the no-legacy-fallback rule. - docs/plugin-graph-compatibility-audit.md: category-level classification of every PluginEnum-shipped plugin into wrapper-scope class + continuation behaviour + Phase-1 verdict, with representative deep-dives (global/context init, Sentinel-style wrapper, response owner/writer, proxy terminal + retry, fork() internal dispatch) and the validation rules derived for #7995. These are design documents for community review; no runtime behaviour changes. Fixes #7994 Refs #7993 --- docs/plugin-graph-compatibility-audit.md | 333 ++++++++++++++++++++++++++ docs/plugin-graph-orchestration.md | 397 +++++++++++++++++++++++++++++++ 2 files changed, 730 insertions(+) diff --git a/docs/plugin-graph-compatibility-audit.md b/docs/plugin-graph-compatibility-audit.md new file mode 100644 index 0000000000..1935f82605 --- /dev/null +++ b/docs/plugin-graph-compatibility-audit.md @@ -0,0 +1,333 @@ +<!-- +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. +--> + +# Plugin Graph Orchestration — Compatibility Audit (Phase 1) + +Status: **design artifact for community review**, produced for issue #7994 under the parent proposal +#7993. This is the companion to [plugin-graph-orchestration.md](./plugin-graph-orchestration.md) +(the contract). Where the contract fixes *semantics*, this audit fixes *placement*: it classifies every +plugin currently shipped in `shenyu-plugin` into a wrapper-scope class, records how each plugin uses +the `ShenyuPluginChain` continuation, and derives the concrete placement and validation rules that +#7995 (validator/compiler) and #7996 (graph runtime) must enforce. + +The audit is **category-level with representative deep-dives**: §3 gives the full per-plugin matrix +(one row per `PluginEnum` name), §4 digs into the five cases that actually decide the design +(global/context init, Sentinel-style wrapper, response owner/writer, proxy terminal + retry, +`fork()` internal dispatch), and §5–§6 turn the findings into machine-checkable rules. + +## 1. Method and evidence base + +Classification is grounded in the code as it exists on `master` at the time of writing: + +- Plugin identity and default ordering come from `org.apache.shenyu.common.enums.PluginEnum` + (`code` = sort key, `name` = the string a graph node binds via `pluginName`). +- Continuation behaviour is read from each plugin's `execute`/`doExecute`: whether it calls + `chain.execute(exchange)` exactly once, decorates the returned `Mono` + (`.transform`/`.then`/`.onErrorResume`/`.doFinally`), resubscribes internally, or calls + `chain.fork()`. +- Selector/rule matching is **out of scope** for placement: `AbstractShenyuPlugin` already owns it and + the graph never replaces it (contract §2 invariant). A graph edge is not a rule. + +This audit does **not** assert that a plugin is correct in LEGACY mode; it only asks whether the +plugin's chain usage survives being placed on a graph node under the Phase-1 restrictions +(contract §7). + +## 2. Classification dimensions + +Each plugin gets three orthogonal labels. + +**Wrapper-scope class** (contract §9) — decides *where* a node may sit: + +| Class | Placement rule | +|-------|----------------| +| Global / context init | Mandatory **common prefix**; cannot be branched, skipped, or duplicated. | +| Auth / security | **Common prefix**; mandatory deps fail closed (contract §12); never bypassed by graph config. | +| Wrapper (observability / resilience / logging) | **Wrapper scope**: must fully enclose the sub-path it measures. A whole-pipeline wrapper belongs in the common prefix/suffix, not inside one branch. | +| Response owner / writer | **Common suffix**; exactly one response owner per root→END path. | +| Transform | **Branch-safe** if idempotent and not duplicated on a path; order-sensitive. | +| Proxy / terminal | **Exclusive** per path; side-effecting (upstream calls) — must not be duplicated or re-executed on fallback. | +| Short-circuit | **Branch-safe** terminal alternative (commits a response without an upstream). | + +**Continuation behaviour** (contract §11) — decides whether the GRAPH executor may cache/re-run a +node's output `Mono`: + +| Code | Meaning | Executor constraint | +|------|---------|---------------------| +| (a) | calls the continuation **exactly once** | free placement within its class rules | +| (b) | may **resubscribe** its own output (retry / failover) | executor must **not** insert a silent `cache()`; node output must stay re-subscribable | +| (c) | **wraps / decorates** the continuation (`transform`/`then`/response decoration) | wrapper scope must be preserved; cannot be split across a branch boundary | + +**Phase-1 verdict**: + +| Verdict | Meaning | +|---------|---------| +| Supported | may appear on a graph node in Phase 1 under its placement rule. | +| Prefix/Suffix-only | supported but constrained to the common prefix or suffix (cannot live inside a CHOICE branch). | +| Restricted | supported only in a single-execution position; validator rejects duplicate placement on a path. | +| Rejected (Phase 1) | referencing it in a graph node fails validation with an actionable error (contract §15); still fully usable in LEGACY. | + +## 3. Full per-plugin matrix + +Ordered by `PluginEnum.code` (the LEGACY sort key). "Cont." is the continuation behaviour from §2. + +| code | name | class | Cont. | Phase-1 verdict | +|------|------|-------|-------|-----------------| +| -1 | `global` | Global / context init | (a) | **Prefix-only** (mandatory, exactly once) | +| 0 | `tracing` | Wrapper (observability) | (c) | Prefix/Suffix-only (whole-pipeline wrapper) | +| 1 | `mock` | Short-circuit | (a) | Supported (branch-safe terminal) | +| 10 | `cache` | Response owner / writer | (c) | Prefix/Suffix-only (wraps response write) | +| 15 | `metrics` | Wrapper (observability) | (c) | Prefix/Suffix-only | +| 20 | `sign` | Auth / security | (a) | **Prefix-only** (mandatory dep, fail closed) | +| 30 | `jwt` | Auth / security | (a) | **Prefix-only** | +| 40 | `oauth2` | Auth / security | (a) | **Prefix-only** | +| 40 | `casdoor` | Auth / security | (a) | **Prefix-only** | +| 50 | `waf` | Auth / security | (a) | **Prefix-only** | +| 60 | `rateLimiter` | Wrapper (resilience) | (c) | Prefix/Suffix-only | +| 65 | `aiRequestTransformer` | Transform (request) | (a) | Supported (branch-safe, no duplicate) | +| 66 | `aiResponseTransformer` | Transform (response) | (c) | Prefix/Suffix-only (response side) | +| 70 | `paramMapping` | Transform (request) | (a) | Supported | +| 80 | `contextPath` | Transform (request) | (a) | Supported | +| 90 | `rewrite` | Transform (request) | (a) | Supported | +| 100 | `cryptorRequest` | Transform (request) | (a) | Supported | +| 110 | `redirect` | Short-circuit | (a) | Supported (branch-safe terminal) | +| 120 | `request` | Transform (request) | (a) | Supported | +| 125 | `generalContext` | Transform (context) | (a) | Supported | +| 125 | `mqtt` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 130 | `hystrix` | Wrapper (resilience) | (c) | Prefix/Suffix-only | +| 140 | `sentinel` | Wrapper (resilience) | (c) | Prefix/Suffix-only (see §4.2) | +| 150 | `keyAuth` | Auth / security | (a) | **Prefix-only** | +| 150 | `basicAuth` | Auth / security | (a) | **Prefix-only** | +| 160 | `loggingConsole` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 170 | `aiPrompt` | Transform (request) | (a) | Supported | +| 170 | `loggingRocketMQ` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 171 | `loggingRabbitMQ` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 171 | `aiTokenLimiter` | Wrapper (resilience) | (c) | Prefix/Suffix-only | +| 175 | `loggingAliyunSls` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 176 | `loggingTencentCls` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 177 | `loggingHuaweiLts` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 180 | `loggingKafka` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 180 | `mcpServer` | Proxy / terminal | (a) | **Restricted** (exclusive; uses `fork()`, see §4.5) | +| 185 | `loggingPulsar` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 190 | `loggingElasticSearch` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 195 | `loggingClickHouse` | Wrapper (logging) | (c) | Prefix/Suffix-only | +| 197 | `sensitiveWord` | Transform (request/response) | (c) | Prefix/Suffix-only | +| 198 | `agentGateway` | Proxy / terminal | (a) | **Restricted** (exclusive; uses `fork()`, see §4.5) | +| 199 | `aiProxy` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 200 | `divide` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting; see §4.4) | +| 200 | `springCloud` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 200 | `websocket` | Proxy / terminal | (a) | **Restricted** (exclusive, long-lived stream) | +| 205 | `uri` | Transform (routing) | (a) | Supported | +| 210 | `webClient` | Proxy / terminal (HTTP client) | (b) | **Restricted** (internal resubscription; see §4.4) | +| 210 | `nettyHttpClient` | Proxy / terminal (HTTP client) | (b) | **Restricted** (internal resubscription; see §4.4) | +| 220 | `modifyResponse` | Transform (response) | (c) | Prefix/Suffix-only (response side) | +| 300 | `paramTransform` | Transform (RPC request) | (a) | Supported | +| 310 | `dubbo` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 310 | `sofa` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 310 | `tars` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 310 | `grpc` | Proxy / terminal | (a) | **Restricted** (exclusive, side-effecting) | +| 310 | `resilience4j` | Wrapper (resilience) | (c) | Prefix/Suffix-only | +| 320 | `tcp` | Proxy / terminal | (a) | **Restricted** (exclusive, long-lived stream) | +| 410 | `cryptorResponse` | Transform (response) | (c) | Prefix/Suffix-only (response side) | +| 420 | `response` | Response owner / writer | (c) | **Suffix-only** (mandatory single owner; see §4.3) | + +Notes on the matrix: + +- Several `code` values collide (e.g. `divide`/`springCloud`/`websocket` all 200; the RPC proxies and + `resilience4j` all 310). LEGACY breaks ties by registration/insertion order; a graph makes the order + **explicit**, which is one of the concrete benefits of #7993 and removes a real source of ambiguity. +- The HTTP clients (`webClient`, `nettyHttpClient`) are the only **(b)** plugins: they resubscribe + their own upstream `Mono` for retry/failover. This is the single most important executor constraint + (§4.4, contract §11). +- `mock` and `redirect` are the clean **short-circuit** terminals: they commit a response without an + upstream, so they are natural exclusive branch leaves. +- Plugins marked "Prefix/Suffix-only" are wrappers (class (c)). They are *supported*, but a whole- + pipeline wrapper placed inside one CHOICE branch would only measure that branch, silently changing + its semantics — hence the restriction. + +## 4. Representative deep-dives + +### 4.1 Global / context init — `GlobalPlugin` (mandatory prefix, exactly once) + +`GlobalPlugin#execute` builds the `ShenyuContext` and publishes it before delegating: + +```java +ShenyuContext shenyuContext = builder.build(exchange); +exchange.getAttributes().put(Constants.CONTEXT, shenyuContext); +return chain.execute(exchange); +``` + +Every downstream plugin reads `exchange.getAttribute(Constants.CONTEXT)` and calls +`Objects.requireNonNull(shenyuContext)` (e.g. `SentinelPlugin`, `ResponsePlugin`). Consequences for +the graph: + +- `global` is a **hard mandatory dependency** (contract §12): a graph whose entry path does not run + `global` before any context-reading node is rejected at validation. +- It must run **exactly once per request** and **before** the dispatcher hands off to any branch. The + GRAPH executor therefore pins context init as the first node of the common prefix; it is never a + CHOICE target and never duplicated on a path. +- Continuation class (a): the executor may treat its output normally. + +### 4.2 Sentinel-style wrapper — `SentinelPlugin` (wrapper scope must be preserved) + +`SentinelPlugin#doExecute` wraps the *entire downstream* and attaches fallback + metrics on the way +out: + +```java +return chain.execute(exchange) + .transform(new SentinelReactorTransformer<>(resourceName)) + .onErrorResume(throwable -> fallbackHandler.fallback(exchange, ...)) + .doFinally(monoV -> { /* report status to metrics consumer */ }); +``` + +This is the canonical class (c) decorator. The wrapper's meaning depends on **what it encloses**: + +- If `sentinel` sits in the common prefix, it wraps the whole pipeline (the LEGACY-equivalent + semantics) — circuit breaking and fallback see the real downstream outcome. +- If `sentinel` were placed inside one CHOICE branch, it would only guard that branch; the other + branches and the shared suffix would run un-guarded. That is a **silent semantic change**, so the + validator restricts whole-pipeline wrappers to prefix/suffix placement (§5, rule W1). +- The same shape applies to `resilience4j`, `hystrix`, `rateLimiter`, `tracing`, `metrics`, the + `logging*` family, and `cache`: all decorate `chain.execute(...)` rather than merely calling it. + +`sentinel` also installs a `Constants.WATCHER_HTTP_STATUS` consumer and reads +`Constants.METRICS_SENTINEL` in `doFinally` — request-scoped attributes that are safe under the +per-subscription state model (contract §11) but would be corrupted by any cross-branch state sharing. + +### 4.3 Response owner / writer — `ResponsePlugin` + `MessageWriter` (single owner, suffix) + +`ResponsePlugin#execute` delegates by RPC type to a `MessageWriter` that owns the actual write: + +```java +return writerMap.get(shenyuContext.getRpcType()).writeWith(exchange, chain); +``` + +`MessageWriter#writeWith(exchange, chain)` receives the chain and decorates the response write +(`NettyClientMessageWriter`, `RPCMessageWriter`, `WebClientMessageWriter`). `CachePlugin` participates +in the same ownership boundary (it caches/replays the written response — see +[cache-request-eligibility.md](./cache-request-eligibility.md)). Consequences: + +- The response owner is a **common-suffix** concern: exactly one owner per root→END path. Two response + owners on one path (e.g. `response` and a second writer) would double-write the response. The + validator enforces "≤1 response-owner node per path" (§5, rule R1). +- Because the writer wraps the terminal write, the owner is class (c): it cannot be split across a + branch boundary, and the proxy terminal it writes for must be on the same path (§4.4). +- `modifyResponse` / `cryptorResponse` / `aiResponseTransformer` mutate the response and must sit + **before** the owner on the suffix; they are response-side transforms, not owners. + +### 4.4 Proxy terminal + retry — `divide` → HTTP client (exclusive, side-effecting, re-subscribable) + +The HTTP proxy path is `divide` (selects the upstream, runs the load balancer) followed by an HTTP +client (`webClient` / `nettyHttpClient`) that performs the actual call. The client is the only class +**(b)** plugin: on timeout/failure it **resubscribes its own upstream `Mono`** to retry or fail over. +`DefaultRetryStrategy#resend` re-invokes `httpClientPlugin.doRequest(...)` and, for failover, re-runs +the load balancer to pick a different upstream (this is the path fixed in #7974/#7975 for P2C inflight +accounting). Consequences for the graph: + +- **No silent `cache()`.** If the GRAPH executor cached a proxy node's output `Mono` to "reuse" it, the + retry/failover resubscription would be short-circuited and the plugin would stop retrying. The + executor must keep proxy node output re-subscribable (contract §11). +- **Exclusive, non-duplicated.** A proxy node performs a side-effecting upstream call. The validator + rejects any path that reaches the same proxy plugin twice, and rejects a proxy appearing in both the + common prefix and a branch (contract §16.3 "duplicate side-effecting plugin on a path"). +- **No cross-executor fallback.** Because a retry already resubscribes inside the node, a graph-level + "fall back to LEGACY on error" would duplicate the upstream call. This is exactly why the contract + forbids implicit fallback (§13) and mid-request executor switches (§10, rule 8). +- `websocket` / `tcp` / `mqtt` are long-lived streams: the executor pins the plan snapshot for the life + of the stream; a revision swap affects new connections only (contract §10, rule 8; §14). + +### 4.5 `fork()` internal dispatch — `AgentHttpToolExecutor` / `mcpServer` + +`agentGateway` (via `AgentHttpToolExecutor`) and `mcpServer` re-enter the chain for an internal +sub-request: + +```java +return chain.fork().execute(child).then(Mono.fromCallable(response::result)).timeout(remaining); +``` + +`ShenyuPluginChain#fork()` is documented as "an independent cursor over this request's plugin snapshot +from the first plugin … custom chains must explicitly support internal dispatch rather than sharing a +mutable cursor." Consequences: + +- **Executor identity is inherited** (contract §10, rule 9): a `fork()` from a GRAPH node starts an + independent GRAPH execution from the entry over the same pinned plan snapshot; it does **not** + consult the global dispatcher again and must not flip to LEGACY. +- **Recursion is bounded.** Because a fork re-enters from the entry, an unbounded fork chain could + recurse indefinitely; the contract caps fork/internal-dispatch recursion depth (default 8, contract + §8). The validator/runtime reject graphs whose fork depth exceeds the cap. +- The internal marker attributes (`HTTP_TOOL_CALL_ATTRIBUTE`, `MCP_TOOL_CALL`) are request-scoped and + safe under per-subscription state, but the forked child must not share mutable traversal state with + the parent — which is precisely the property `fork()`'s independent-cursor contract guarantees. + +## 5. Validation rules derived from the audit (input to #7995) + +The validator (#7995) must reject, with the offending `nodeId`/`pluginName` in the error: + +- **G1 — mandatory prefix.** The entry path runs `global` before any context-reading node; `global` + appears exactly once and is never a CHOICE target. +- **A1 — auth fail closed.** Any node marked `required` (and any plugin in the Auth/security class) + that is missing/disabled at activation rejects the plan (contract §12); auth nodes live in the + common prefix and are never bypassed by a branch. +- **W1 — wrapper scope.** A class (c) whole-pipeline wrapper (tracing, metrics, sentinel, resilience4j, + hystrix, rateLimiter, the `logging*` family, cache) is placed in the common prefix/suffix; it cannot + be a node inside a CHOICE branch unless the branch is the entire enclosed sub-path. +- **R1 — single response owner.** At most one response-owner node (`response`, plus the cache writer + role) per root→END path; response-side transforms (`modifyResponse`, `cryptorResponse`, + `aiResponseTransformer`) precede the owner. +- **P1 — proxy exclusivity.** A proxy/terminal plugin appears at most once per path and never in both + the common prefix and a branch. +- **B1 — no `cache()` on (b) nodes.** The compiler tags HTTP-client nodes as re-subscribable so the + runtime does not memoize their output (contract §11). +- **F1 — bounded fork.** Fork/internal-dispatch recursion depth ≤ configured cap (default 8). +- **S1 — structural restrictions & limits.** The §7 shape (single entry, common prefix, simple-chain + CHOICE branches, single convergence, common suffix) and the §8 limits hold; every reachable path + terminates at an `END` or a response-committing terminal. +- **X1 — unsupported plugin.** Referencing a plugin the audit marks "Rejected (Phase 1)" fails + validation (contract §15). No plugin in the current tree is fully rejected; the restriction set is + the "Restricted"/"Prefix/Suffix-only" verdicts above, which are placement constraints rather than + outright bans. This rule exists so future plugins that mutate the shared cursor can be rejected. + +## 6. Phase-1 supported graph shape (concrete) + +Combining §3–§5, the initial supported graphs are exactly: + +```text +[common prefix] global (mandatory) -> auth/security* -> whole-pipeline wrapper* +[CHOICE region] zero or more CHOICE nodes whose branches are simple chains of + transform / proxy-terminal / short-circuit nodes (exclusive, non-duplicated) +[convergence] a single designated convergence node +[common suffix] response-side transform* -> response owner (single) -> END +``` + +This shape keeps path analysis polynomial (contract §7), preserves every wrapper's scope, guarantees a +single response owner, and never duplicates a side-effecting proxy — while still delivering the core +#7993 value: explicit ordering (no more `code` ties), conditional exclusive routing, and declarative +mandatory dependencies. + +## 7. Residual risks and open questions + +1. **Cache + response owner interaction.** `cache` both wraps the response write and replays cached + responses; its exact node placement (prefix vs suffix, and relative to the owner) needs a dedicated + decision before #7996. Tracked as an open question here rather than guessed. +2. **AI transformer ordering.** `aiRequestTransformer`/`aiResponseTransformer`/`aiPrompt`/ + `sensitiveWord` straddle the request/response boundary; the audit classifies them by the side they + mutate, but a graph that mixes AI and non-AI proxies may need finer ordering rules. +3. **Long-lived streams under revision swap.** `websocket`/`tcp`/`mqtt` pin the plan for the life of the + connection; the precise "new connections only" cutover semantics are a #7996/#7998 concern. +4. **Plugin-store / migrated plugins.** Some `PluginEnum` names (e.g. the `logging*` compatibility + entries, `hystrix`) point at implementations that live in the plugin store. A graph node binding such + a name must resolve against the *installed* catalog at activation; the fail-closed rule (A1/X1) + covers the missing case, but the catalog-resolution contract is finalized in #7995/#7999. diff --git a/docs/plugin-graph-orchestration.md b/docs/plugin-graph-orchestration.md new file mode 100644 index 0000000000..2f5a5dd20e --- /dev/null +++ b/docs/plugin-graph-orchestration.md @@ -0,0 +1,397 @@ +<!-- +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. +--> + +# Plugin Graph Orchestration — Contract (Phase 1) + +Status: **design contract for community review**, produced for issue #7994 under the parent +proposal #7993 (*optional DAG-based plugin orchestration*). Nothing here is an accepted design; +this document is the artifact intended to drive that review. It defines the versioned graph +schema, node and condition semantics, structural restrictions, graph limits, plugin requirement +and wrapper-scope classes, the dual-executor boundary, and explicit decisions for the hard cases. +The companion compatibility audit lives in [plugin-graph-compatibility-audit.md](./plugin-graph-compatibility-audit.md). + +## 1. Scope and non-goals + +Phase 1 adds an **optional, serial** plugin execution graph as a *second execution mode* beside the +existing sorted-list chain. It preserves the current `ShenyuPlugin.execute(exchange, chain)` +contract verbatim. + +In scope: explicit dependency constraints, conditional **exclusive** branches, convergence of +mutually exclusive paths, one serial path per request. + +Out of scope (future proposals): parallel execution, fan-out/fan-in join scheduling, duplicate +same-plugin invocation on one path, arbitrary executable scripts, a universal workflow engine, and +any HTTP/RPC **API aggregation** or backend method composition. Per-request executor routing is +deferred; only deployment-level mode selection ships first. + +## 2. Grounding in the current runtime + +The contract is written against the code as it exists today: + +- `org.apache.shenyu.web.handler.ShenyuWebHandler#handle` builds a fresh + `DefaultShenyuPluginChain(plugins)` per subscription and runs it, optionally on a scheduler, + wrapped by `before`/`after` chain timing. +- `DefaultShenyuPluginChain` is a mutable `index` cursor over an immutable sorted + `List<ShenyuPlugin>`; `execute` honours `plugin.skip`, then calls `plugin.before`, + `plugin.execute(exchange, this)`, and `plugin.after` in a `finally`. +- `ShenyuPluginChain#fork()` returns an **independent cursor over the same snapshot** for internal + dispatch. +- Plugin ordering comes from `PluginData.sort` (else `ShenyuPlugin#getOrder()`), re-sorted on + `PluginHandlerEvent` (ENABLED / DISABLED / DELETE / SORTED). +- Selector/rule matching and condition evaluation already exist in `shenyu-plugin-base` + (`AbstractShenyuPlugin`, `condition/judge/PredicateJudge`, `condition/strategy/MatchStrategy`, + `condition/data/ParameterData`) over `org.apache.shenyu.common.dto.ConditionData`. + +**Invariant:** graph orchestration chooses *which plugins run and in what order*; it never replaces +a plugin's own selector/rule matching. A graph edge is not a rule. + +## 3. Terminology + +| Term | Meaning | +|------|---------| +| Graph definition | Versioned, editable source-of-truth for a graph (draft → published revision). | +| Revision | Immutable, monotonically numbered publication of a graph definition. | +| Plan | Compiled, immutable, request-path-free form of a revision (validated + bound successors). | +| Executor | One of two runtimes: `LEGACY` (sorted cursor) or `GRAPH` (plan traversal). | +| Dispatcher | Selects the executor **before any** `ShenyuPlugin` runs and pins it for the subscription. | +| Node | A graph vertex: `PLUGIN`, `CHOICE`, or `END`. | +| Wrapper scope | The sub-path a wrapping plugin must fully enclose to preserve its semantics. | + +## 4. Versioned graph schema + +A graph definition carries the following fields. Types are indicative; the DTOs are defined in +`shenyu-common` by #7995. + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `schemaVersion` | int | yes | Contract version of this document; validators reject unknown majors. | +| `namespaceId` | string | yes | Must match the deployment's configured namespace. | +| `deploymentBinding` | string | no | Optional explicit deployment/group id; else namespace-wide. | +| `graphId` | string | yes | Stable identity across revisions. | +| `revision` | long | yes | Monotonic per `graphId`; activation ordering key. | +| `contentHash` | string | yes | Hash of the normalized node/edge content; change detection + idempotent sync. | +| `entryNodeId` | string | yes | Must reference a node in `nodes`. | +| `nodes` | list<Node> | yes | Non-empty; unique `nodeId`s. | + +`Node` fields: + +| Field | Type | Applies to | Notes | +|-------|------|-----------|-------| +| `nodeId` | string | all | Unique within the graph. | +| `type` | enum `PLUGIN\|CHOICE\|END` | all | See §5. | +| `pluginName` | string | PLUGIN | Binds an installed plugin **by name** (`ShenyuPlugin#named()` / `PluginData.name`). | +| `next` | string (nodeId) | PLUGIN | Exactly one successor. | +| `conditions` | list<Branch> | CHOICE | Ordered; first match wins. | +| `defaultTarget` | string (nodeId) | CHOICE | Required fallback when no condition matches. | +| `required` | bool | PLUGIN | If true, the plugin is a mandatory dependency (fail-closed, §12). | + +`Branch` = `{ List<ConditionData> conditions; MatchStrategy match; String target; }` — reusing the +existing `ConditionData` model so operators author conditions exactly as they do for selectors/rules. + +## 5. Node model and semantics + +- **PLUGIN** — binds one installed plugin by name and has exactly one successor (`next`). At runtime + the executor invokes `plugin.skip`, `before`, `execute(exchange, continuation)`, `after`, where the + `continuation` executes **the successor node** rather than advancing a shared cursor. The plugin's + own selector/rule matching is untouched. +- **CHOICE** — evaluates `conditions` **in declaration order** and selects the **first** branch whose + conditions match (under the branch's `MatchStrategy`); if none match it selects `defaultTarget`. + `defaultTarget` is mandatory, which makes selection total and deterministic. CHOICE has no plugin + side effects of its own. +- **END** — terminal; completes the traversal. Every reachable path must terminate at an `END` + (or at a plugin that owns/commits the response, e.g. a proxy — see the audit). + +## 6. Condition semantics and determinism + +- Conditions reuse `ConditionData` (`paramType`/`operator`/`paramName`/`paramValue`) and are + evaluated by the existing `PredicateJudge`/`MatchStrategy` SPI in `shenyu-plugin-base` at request + time (in the GRAPH runtime, #7996). #7995 only validates and *compiles* conditions structurally. +- **Determinism rules (validated by #7995):** ordered branches, first-match wins, mandatory + `defaultTarget`, all branch targets reference existing nodes, and no two branches are + byte-identical duplicates (ambiguous authoring). + +## 7. Phase-1 structural restrictions + +The audit (§9 and the companion doc) shows that arbitrary DAG re-convergence makes *path-wise* +guarantees (no duplicate plugin on a path; well-defined wrapper scope) expensive and error-prone. +Phase 1 therefore **restricts the graph shape** so that path analysis stays polynomial: + +1. Exactly one `entryNodeId`. +2. A **common prefix** of zero or more PLUGIN nodes from the entry. +3. Zero or more **CHOICE** nodes whose branches are **simple chains** (no nested re-convergence + inside a branch); a branch may itself contain a CHOICE, but branches must not merge back into a + shared node other than a single, well-defined **convergence point**. +4. A **common suffix** (may be empty) leading to `END`. +5. No node is reachable by two different CHOICE branches at the same convergence unless it is the + designated convergence point (exclusive-branch + single-convergence model). + +Consequence: the set of distinct root→END paths equals the product of branch fan-outs, but because +branches are simple chains that converge once, **path-wise duplicate/scope checks reduce to checking +each chain segment plus the shared prefix/suffix** — polynomial in graph size. Arbitrary DAGs are a +future extension once the audit and runtime mature. + +## 8. Graph limits + +Validated by #7995; defaults are conservative and configurable. Rationale: bound compile cost, +plan memory, retained-revision memory, and internal-dispatch recursion. + +| Limit | Default | Purpose | +|-------|---------|---------| +| max nodes per graph | 128 | bound validation/compile + plan size | +| max out-degree (CHOICE branches) | 16 | bound branch fan-out | +| max conditions per branch | 32 | bound condition compile | +| max path length (prefix+branch+suffix) | 64 | bound traversal depth | +| max fork/internal-dispatch recursion depth | 8 | bound `fork()` re-entry | +| max retained revisions per graph | 8 | bound admin/gateway memory | +| max graphs per namespace | 32 | bound sync + registry cardinality | + +## 9. Plugin requirements and wrapper-scope classes + +Plugins fall into wrapper-scope classes that determine whether they may live inside a branch or must +sit in the common prefix/suffix. The full per-category matrix and representative deep-dives are in +[plugin-graph-compatibility-audit.md](./plugin-graph-compatibility-audit.md); the classes are: + +| Class | Examples | Placement rule | +|-------|----------|----------------| +| Global / context init | `GlobalPlugin` (builds `ShenyuContext`) | **Mandatory common prefix**; cannot be branched or skipped. | +| Auth / security | sign, jwt, oauth2, basic-auth, key-auth, waf | **Common prefix**; mandatory deps fail closed; never bypassed by graph config. | +| Wrapper (observability/resilience) | tracing, metrics, fault-tolerance, logging | **Wrapper scope**: must fully enclose the sub-path it measures; if it wraps the whole pipeline it belongs in the common prefix/suffix, not inside one branch. | +| Response owner / writer | response plugin, cache response writer | **Common suffix**; exactly one response owner per path. | +| Transform | context-path, rewrite, param-mapping, request, modify-response, redirect, uri | Branch-safe if idempotent and not duplicated on a path; order-sensitive. | +| Proxy / terminal | divide+httpclient, dubbo, grpc, spring-cloud, sofa, tars, websocket, mqtt | Exclusive per path; side-effecting (upstream calls) — must not be duplicated or re-executed on fallback. | +| Short-circuit | mock | Branch-safe terminal alternative. | + +**Decision (initial supported graphs):** enforce a structured common prefix (global + auth + any +whole-pipeline wrapper) and common suffix (response owner), with CHOICE branches limited to +transform/proxy/short-circuit alternatives. This is the restriction that keeps §7 polynomial and +preserves wrapper semantics. + +## 10. Dual-executor contract + +Orchestration is a **separate execution mode**, never a plugin inserted into the legacy chain and +never an extension of its mutable cursor. + +```text +Request / existing shared WebFilters + -> execution-mode dispatcher (runs BEFORE any ShenyuPlugin) + -> LEGACY : existing sorted List<ShenyuPlugin> + DefaultShenyuPluginChain cursor + -> GRAPH : immutable compiled GraphPlan + independent GraphExecution state + (both executors reuse the SAME ShenyuPlugin instances) +``` + +Rules: + +1. **Dispatch before plugins.** The dispatcher chooses the executor before the first `ShenyuPlugin` + runs. No shared plugin prefix is executed and then re-entered by an executor. +2. **Deployment-level mode.** Initial selection is configuration, e.g. + `shenyu.execution.mode=legacy|graph` and `shenyu.execution.graph-id`; `legacy` is the default. + Namespace graph binding must match the deployment's namespace. Per-request routing is deferred. +3. **Pin per subscription.** The chosen executor is pinned for the whole subscription/request + execution and for anything it `fork()`s. +4. **Separate lifecycle ownership.** Each executor owns its complete plugin lifecycle: exactly one + global/context init, one auth wrapper pass, one proxy, one response owner per request. +5. **No graph state in the legacy cursor.** `DefaultShenyuPluginChain` and its `index` are unchanged; + graph traversal state lives only in `GraphExecution`. Legacy sorting/semantics are untouched + (any minimal entry refactoring to introduce the dispatcher must carry regression coverage). +6. **Shared WebFilters.** Existing `WebFilter`s remain shared and run before the dispatcher. +7. **Graph-not-ready rejection.** A GRAPH deployment without a ready required plan **rejects** the + request (and affects readiness); it never silently runs LEGACY. +8. **No implicit fallback / no mid-request switch.** A failed graph execution never restarts on the + legacy chain (that could duplicate upstream calls and side effects). Mode changes affect **new + requests only**; in-flight requests, long-lived streams and forks keep their original + executor/plan. +9. **fork() identity.** `fork()` inherits the executor kind and the structural plan snapshot and + starts independent execution from the entry without consulting the global dispatcher again. +10. **Rollback.** Operational rollback to LEGACY is an explicit deployment configuration change (or + restart). Graph *revision* rollback (publish old content under a new revision) is a **different** + operation from executor-mode rollback. An optional live mode switch, if ever added, applies + atomically to new requests only. + +### 10.1 Sequence — LEGACY (unchanged) + +```mermaid +sequenceDiagram + participant WF as Shared WebFilters + participant D as Mode Dispatcher + participant H as ShenyuWebHandler + participant C as DefaultShenyuPluginChain (cursor) + participant P as ShenyuPlugin[i] + WF->>D: request + D->>D: mode = LEGACY (deployment config) + D->>H: handle(exchange) + H->>C: new chain(plugins).execute(exchange) + loop index < plugins.size + C->>P: skip? before; execute(exchange, chain); after + P-->>C: Mono<Void> (may call chain.execute / chain.fork) + end + C-->>H: Mono.empty() + H-->>WF: response +``` + +### 10.2 Sequence — GRAPH (new) + +```mermaid +sequenceDiagram + participant WF as Shared WebFilters + participant D as Mode Dispatcher + participant G as GraphExecution (per-request state) + participant PL as Compiled GraphPlan (immutable) + participant P as ShenyuPlugin (reused) + WF->>D: request + D->>D: mode = GRAPH; pin plan snapshot + alt no ready required plan + D-->>WF: reject (no legacy fallback) + else plan ready + D->>G: start(entryNodeId, exchange, plan) + loop traverse successor / CHOICE branch + G->>P: skip? before; execute(exchange, continuation->successor); after + P-->>G: Mono<Void> (continuation runs the bound successor) + end + G-->>WF: END -> response (single owner) + end +``` + +## 11. Repeated continuation and resubscription semantics + +**Decision:** the continuation passed to a plugin executes its bound successor. Whether a plugin may +invoke the continuation more than once (or resubscribe the returned `Mono`) is **per the compatibility +contract**, not blanket-cached: + +- Plugins that legitimately resubscribe (retry / failover in `shenyu-plugin-httpclient`) keep working: + the GRAPH executor must not insert a silent `cache()` that would change retry semantics. +- The audit records, per plugin, whether it (a) calls the continuation exactly once, (b) may call it + multiple times (retry), or (c) wraps it (decorator). Phase 1 supports (a) and (b); a plugin that + would re-enter a side-effecting proxy successor more than once is flagged and restricted to a + single-execution position. +- Repeated **subscription** of the request `Mono` re-runs traversal from the entry with fresh + per-subscription state (matching today's `Mono.defer` behaviour in `ShenyuWebHandler#handle`). + +## 12. Mandatory dependencies fail closed + +**Decision:** a PLUGIN node marked `required` (and any plugin the audit classifies as a mandatory +security prerequisite) is a hard dependency. If it is missing, disabled, or unavailable at activation +time, the GRAPH plan is **rejected** for new requests rather than executed without it. Preserving an +old plan must not bypass an explicit disable. Graph configuration can never be used to skip mandatory +authentication. (Runtime enforcement and lifecycle re-evaluation are #7999; this contract fixes the +semantics.) + +## 13. Legacy fallback + +**Decision:** none. Graph mode never falls back to the legacy chain — not on missing plan, not on +compile failure, not on in-flight execution error. Rationale: cross-executor fallback can duplicate +upstream calls and side effects and hides misconfiguration. LEGACY deployments are unaffected by +graph publication/validation state. + +## 14. Runtime configuration update + +- Graph revisions are published immutably and synced (WebSocket/HTTP first, #7998) with + full/incremental state, digests, deletion, reconnect and out-of-order/duplicate tolerance. +- The gateway compiles candidates **before** an atomic plan swap; each request holds one topology + snapshot. A topology snapshot does **not** freeze selector/rule/plugin caches — those keep their + existing live-update behaviour (documented limitation). +- Publication alone never changes executor mode; receiving/publishing a graph never switches a LEGACY + deployment to GRAPH. + +## 15. Unsupported-plugin behavior + +A plugin that the audit marks as **graph-incompatible** for Phase 1 (e.g. one that mutates the shared +cursor, or cannot preserve wrapper scope under branching) is rejected at validation when referenced by +a graph node, with an actionable error naming the plugin and node id. Such plugins remain fully usable +in LEGACY mode. The supported-plugin list and constraints are published with the compatibility matrix. + +## 16. Schema examples + +### 16.1 Valid — dependency-only linear graph (compiles to a linear plan) + +```json +{ + "schemaVersion": 1, + "namespaceId": "ns-prod", + "graphId": "g-linear", + "revision": 3, + "contentHash": "a1b2c3", + "entryNodeId": "n-global", + "nodes": [ + { "nodeId": "n-global", "type": "PLUGIN", "pluginName": "global", "required": true, "next": "n-sign" }, + { "nodeId": "n-sign", "type": "PLUGIN", "pluginName": "sign", "required": true, "next": "n-divide" }, + { "nodeId": "n-divide", "type": "PLUGIN", "pluginName": "divide", "next": "n-end" }, + { "nodeId": "n-end", "type": "END" } + ] +} +``` + +### 16.2 Valid — exclusive CHOICE branch with convergence + +```json +{ + "schemaVersion": 1, + "namespaceId": "ns-prod", + "graphId": "g-choice", + "revision": 1, + "contentHash": "d4e5f6", + "entryNodeId": "n-global", + "nodes": [ + { "nodeId": "n-global", "type": "PLUGIN", "pluginName": "global", "required": true, "next": "n-choice" }, + { "nodeId": "n-choice", "type": "CHOICE", "defaultTarget": "n-http", + "conditions": [ + { "match": "all", "target": "n-grpc", + "conditions": [ { "paramType": "req_method", "operator": "match", "paramName": "", "paramValue": "grpc" } ] } + ] }, + { "nodeId": "n-grpc", "type": "PLUGIN", "pluginName": "grpc", "next": "n-resp" }, + { "nodeId": "n-http", "type": "PLUGIN", "pluginName": "divide", "next": "n-resp" }, + { "nodeId": "n-resp", "type": "PLUGIN", "pluginName": "response", "next": "n-end" }, + { "nodeId": "n-end", "type": "END" } + ] +} +``` + +### 16.3 Invalid examples (each must be rejected with the node id) + +| Defect | Why rejected | +|--------|--------------| +| `entryNodeId` references a missing node | reference integrity | +| PLUGIN `next` → unknown node | dangling reference | +| CHOICE without `defaultTarget` | non-total / non-deterministic selection | +| `n-a → n-b → n-a` | cycle | +| node not reachable from entry | unreachable node | +| same proxy plugin on prefix **and** a branch | duplicate side-effecting plugin on a path | +| `nodes.size` > max | resource limit | +| a `required` plugin absent from the catalog | mandatory dependency (fail closed) | +| branch re-converges into two different shared nodes | violates §7 single-convergence restriction | + +## 17. Acceptance mapping (#7994) + +| Acceptance item | Where addressed | +|-----------------|-----------------| +| reviewed contract + compatibility matrix | this doc + [audit](./plugin-graph-compatibility-audit.md) | +| representative wrapper/response/retry cases | audit deep-dives (Sentinel-style wrapper, response owner, httpclient retry) | +| explicit decisions: repeated continuations, mandatory deps, legacy fallback | §11, §12, §13 | +| schema examples and invalid examples | §16 | +| dual-executor contract, dispatch-before-plugin, separate lifecycle, no graph state in cursor | §10 | +| namespace/deployment binding + bootstrap boundary | §4, §10.2 | +| graph-not-ready rejection, fork() executor identity, deployment/restart rollback, optional live switch = new requests only | §10 (rules 3,7,8,9,10) | +| sequence diagrams for both executors | §10.1, §10.2 | +| ownership of global/auth/proxy/response plugins | §9 | +| no automatic mode change on publication; explicit no-fallback / no-mid-request-switch | §10 (rules 8,10), §13, §14 | + +## 18. Open questions for review + +1. Are the §7 structural restrictions acceptable for Phase 1, or is arbitrary-DAG support required + before adoption (at the cost of exponential path analysis)? +2. Should `deploymentBinding` be mandatory (safer canary story) or optional (namespace-wide default)? +3. Is the default limit set (§8) reasonable, and which limits must be hot-reconfigurable? +4. Do we want a machine-readable JSON Schema published alongside these examples in Phase 1?
