Compare commits

...
16 Commits
Author SHA1 Message Date
ZyyandCodex 8c8618aa0f chore(release): v3.33.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 18:44:00 +08:00
c43a8872db fix(frontend): highlight selected models missing upstream (#528)
* fix(frontend): highlight unavailable selected models

* fix(frontend): refine model catalog warnings and refresh control

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 18:39:08 +08:00
ZyyandCodex 51c5caed0e fix(deploy): disable account routing override in example config
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 18:00:53 +08:00
a7f95c3a78 fix(openai): preserve Retry-After when converting WS errors to SSE (#531)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 17:08:57 +08:00
ZyyandCodex 421e37896a fix(tests): avoid RPC admission callback deadlock
Drive the ordinary callback call to completion before consuming its notification so delayed suspension cannot strand an unpolled admission future. Bound next callback startup and resumption waits.

Co-authored-by: Codex <noreply@openai.com>
2026-10-26 17:06:45 +08:00
1d1ccda42a refactor(plugins): consolidate host lifecycle and diagnostics (#529)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 17:06:44 +08:00
e38cf31323 refactor(plugins): separate client identity from model execution ports (#527)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 13:21:31 +08:00
614c81c375 refactor(plugins)!: separate identity queries and cooperative scheduling (#525)
Resolve identity and frozen access facts without constructing model execution
contexts. Coordinate parent task suspension, resumption and retained callbacks
at the SDK and Runtime boundary, with bounded admission and cancellation.
Expose account HTTP, client verification, access facts and quota details through
the existing host owners.

BREAKING CHANGE: Plugin sessions require RPC v7; rebuild and repackage plugins
with the matching SDK. No legacy protocol negotiation or fallback is provided.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 13:05:08 +08:00
f0484c4e11 chore(frontend): upgrade ESLint config to v10 and migrate formatting (#522)
* chore(deps-dev): bump @antfu/eslint-config in /frontend

Bumps [@antfu/eslint-config](https://github.com/antfu/eslint-config) from 9.5.1 to 10.0.0.
- [Release notes](https://github.com/antfu/eslint-config/releases)
- [Commits](https://github.com/antfu/eslint-config/compare/v9.5.1...v10.0.0)

---
updated-dependencies:
- dependency-name: "@antfu/eslint-config"
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(frontend): migrate formatting to ESLint v10

Co-authored-by: Codex <noreply@openai.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 11:34:32 +08:00
dependabot[bot] dffc417b9f chore(deps): bump rust from e12566d to 48b7f43 in /deploy (#524)
Bumps rust from `e12566d` to `48b7f43`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.99.1-bookworm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:04:28 +08:00
dependabot[bot] 2d4294c3c9 chore(deps): bump debian from 7c7b2c9 to 7ff6ba3 in /deploy (#523)
Bumps debian from `7c7b2c9` to `7ff6ba3`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:04:23 +08:00
dependabot[bot] 3d9906facf chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#520)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.59.0` | `1.61.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.52.0` | `1.53.0` |
| [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) | `2.6.2` | `2.6.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.4` | `8.3.5` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.11` | `3.3.13` |


Updates `@lucide/vue` from 1.59.0 to 1.61.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.61.0/packages/vue)

Updates `es-toolkit` from 1.52.0 to 1.53.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.52.0...v1.53.0)

Updates `eslint-plugin-vuejs-accessibility` from 2.6.2 to 2.6.3
- [Release notes](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/releases)
- [Changelog](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/compare/v2.6.2...v2.6.3)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

Updates `vue-tsc` from 3.3.11 to 3.3.13
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.13/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint-plugin-vuejs-accessibility
  dependency-version: 2.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:04:18 +08:00
dependabot[bot] da9c2b92b0 chore(deps): bump jsonschema from 0.58.6 to 0.60.0 in /backend (#519)
Bumps [jsonschema](https://github.com/Stranger6667/jsonschema) from 0.58.6 to 0.60.0.
- [Release notes](https://github.com/Stranger6667/jsonschema/releases)
- [Changelog](https://github.com/Stranger6667/jsonschema/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/jsonschema/compare/ruby-v0.58.6...ruby-v0.60.0)

---
updated-dependencies:
- dependency-name: jsonschema
  dependency-version: 0.60.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:02:43 +08:00
dependabot[bot] de5c10c4e4 chore(deps): bump scraper from 0.27.0 to 0.29.0 in /backend (#518)
Bumps [scraper](https://github.com/rust-scraper/scraper) from 0.27.0 to 0.29.0.
- [Release notes](https://github.com/rust-scraper/scraper/releases)
- [Commits](https://github.com/rust-scraper/scraper/compare/v0.27.0...v0.29.0)

---
updated-dependencies:
- dependency-name: scraper
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:02:33 +08:00
dependabot[bot] 939cb99985 chore(deps): bump the minor-and-patch group in /backend with 7 updates (#517)
Bumps the minor-and-patch group in /backend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [zip](https://github.com/zip-rs/zip2) | `9.0.4` | `9.0.5` |
| [async-compression](https://github.com/Nullus157/async-compression) | `0.4.50` | `0.4.51` |
| [aws-credential-types](https://github.com/smithy-lang/smithy-rs) | `1.3.0` | `1.3.1` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.152.0` | `1.155.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.19.0` | `1.19.1` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.8.1` | `1.9.0` |
| [crc32fast](https://github.com/srijs/rust-crc32fast) | `1.5.2` | `1.5.3` |


Updates `zip` from 9.0.4 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.4...v9.0.5)

Updates `async-compression` from 0.4.50 to 0.4.51
- [Release notes](https://github.com/Nullus157/async-compression/releases)
- [Commits](https://github.com/Nullus157/async-compression/compare/async-compression-v0.4.50...async-compression-v0.4.51)

Updates `aws-credential-types` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `aws-sdk-s3` from 1.152.0 to 1.155.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `aws-smithy-runtime-api` from 1.19.0 to 1.19.1
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `aws-smithy-types` from 1.8.1 to 1.9.0
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `crc32fast` from 1.5.2 to 1.5.3
- [Commits](https://github.com/srijs/rust-crc32fast/compare/v1.5.2...v1.5.3)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: async-compression
  dependency-version: 0.4.51
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-credential-types
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.155.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: crc32fast
  dependency-version: 1.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:02:21 +08:00
dependabot[bot] 2bb8ebc01c chore(deps): bump the minor-and-patch group with 3 updates (#516)
Bumps the minor-and-patch group with 3 updates: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache), [taiki-e/install-action](https://github.com/taiki-e/install-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0)

Updates `taiki-e/install-action` from 2.87.29 to 2.87.31
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/8cca0918f8471c33515202b73f56ab27f2bed283...1e29709c0ec63babbe8f266397a465f29a825f91)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: taiki-e/install-action
  dependency-version: 2.87.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:01:07 +08:00
344 changed files with 13735 additions and 5200 deletions
+2 -2
View File
@@ -108,7 +108,7 @@ jobs:
components: rustfmt,clippy
- name: Cache Rust
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
@@ -122,7 +122,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
with:
tool: cargo-nextest@0.9.143
fallback: none
+2 -2
View File
@@ -312,7 +312,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -409,7 +409,7 @@ jobs:
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
with:
tool: cargo-audit@0.22.2
fallback: none
+90 -100
View File
@@ -147,9 +147,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.50"
version = "0.4.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
dependencies = [
"compression-codecs",
"compression-core",
@@ -224,9 +224,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-credential-types"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -260,9 +260,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.10.0"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.152.0"
version = "1.155.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
checksum = "5a542f0e9261b9ce56708d591a62062079fa3c58a362ffbf49a2e48a4f6ced16"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -322,9 +322,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.6.0"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -344,9 +344,9 @@ dependencies = [
[[package]]
name = "aws-smithy-async"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
dependencies = [
"futures-util",
"pin-project-lite",
@@ -376,9 +376,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.61.4"
version = "0.61.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609"
checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -387,9 +387,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.1"
version = "0.64.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -403,15 +403,14 @@ dependencies = [
"http-body-util",
"percent-encoding",
"pin-project-lite",
"pin-utils",
"tracing",
]
[[package]]
name = "aws-smithy-http-client"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -433,9 +432,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.63.1"
version = "0.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18"
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -453,9 +452,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.16.0"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -470,16 +469,15 @@ dependencies = [
"http-body",
"http-body-util",
"pin-project-lite",
"pin-utils",
"tokio",
"tracing",
]
[[package]]
name = "aws-smithy-runtime-api"
version = "1.19.0"
version = "1.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -505,9 +503,9 @@ dependencies = [
[[package]]
name = "aws-smithy-schema"
version = "0.2.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -516,9 +514,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
dependencies = [
"base64-simd",
"bytes",
@@ -530,7 +528,6 @@ dependencies = [
"itoa",
"num-integer",
"pin-project-lite",
"pin-utils",
"ryu",
"serde",
"time",
@@ -540,9 +537,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.62.1"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf"
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -552,9 +549,9 @@ dependencies = [
[[package]]
name = "aws-types"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -945,9 +942,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
dependencies = [
"compression-core",
"flate2",
@@ -956,9 +953,9 @@ dependencies = [
[[package]]
name = "compression-core"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]]
name = "config"
@@ -1084,9 +1081,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.2"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
@@ -1166,14 +1163,14 @@ dependencies = [
[[package]]
name = "cssparser"
version = "0.37.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf 0.14.0",
"smallvec",
]
@@ -2125,12 +2122,13 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.39.0"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
dependencies = [
"log",
"markup5ever",
"memchr",
]
[[package]]
@@ -2483,9 +2481,9 @@ dependencies = [
[[package]]
name = "jsonschema"
version = "0.58.6"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499"
dependencies = [
"ahash",
"bytecount",
@@ -2511,23 +2509,24 @@ dependencies = [
[[package]]
name = "jsonschema-regex"
version = "0.58.6"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
checksum = "6f8a848331614144c03e815fb931f238c9e342bd7ab501b29e35d647e7cd5688"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.58.6"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
checksum = "5a5a947ef798eeacacb43edf29b1d0ab64bdf5e2c9fbcaad10ee8663d922b544"
dependencies = [
"ahash",
"bytecount",
"fraction",
"getrandom 0.3.4",
"memchr",
"num-cmp",
"num-traits",
"serde_json",
@@ -2642,9 +2641,9 @@ dependencies = [
[[package]]
name = "markup5ever"
version = "0.39.0"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
dependencies = [
"log",
"tendril",
@@ -3101,23 +3100,23 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros 0.14.0",
"phf_shared 0.14.0",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
]
[[package]]
@@ -3132,12 +3131,12 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
]
[[package]]
@@ -3155,12 +3154,12 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -3186,9 +3185,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3199,12 +3198,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.11.0"
@@ -3636,9 +3629,9 @@ dependencies = [
[[package]]
name = "referencing"
version = "0.58.6"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
checksum = "922268812b6f9e20b4ab0f1efe8e07036958eed7346ceddb31c777b5531fd11f"
dependencies = [
"ahash",
"fluent-uri",
@@ -3875,9 +3868,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.27.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff"
dependencies = [
"cssparser",
"ego-tree",
@@ -3885,7 +3878,6 @@ dependencies = [
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
@@ -3950,17 +3942,15 @@ dependencies = [
[[package]]
name = "selectors"
version = "0.38.0"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -4067,9 +4057,9 @@ dependencies = [
[[package]]
name = "servo_arc"
version = "0.4.3"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198"
dependencies = [
"stable_deref_trait",
]
@@ -4401,24 +4391,24 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
]
@@ -5214,11 +5204,11 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.6"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
dependencies = [
"phf 0.13.1",
"phf 0.14.0",
"phf_codegen",
"string_cache",
"string_cache_codegen",
@@ -5651,9 +5641,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.4"
version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"crc32fast",
"flate2",
@@ -103,6 +103,10 @@ async fn execute(
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let core = or_shutdown!(plugin_runtime, core.activate().await);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
@@ -38,6 +38,10 @@ pub async fn run() -> Result<(), BootstrapError> {
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
@@ -84,6 +84,7 @@ pub struct PluginInstanceMutation {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginInstanceRuntimeStatus {
Disabled,
Unknown,
AwaitingPublication,
Preparing,
Running,
@@ -108,6 +109,21 @@ pub struct PluginInstanceRuntime {
pub actual_artifact_sha256: Option<String>,
pub failure: Option<PluginInstanceRuntimeFailure>,
pub draining_revisions: Vec<u64>,
pub load: Option<PluginInstanceRuntimeLoad>,
}
/// 当前发布会话的资源占用采样,计数可以重叠且不构成原子快照
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstanceRuntimeLoad {
/// 总在途名额,包含排队、取消确认和仍由响应流持有的调用
pub active_calls: usize,
pub executing_calls: usize,
pub queued_calls: usize,
pub resuming_calls: usize,
/// 包含已返回但尚未由插件消费的回调结果
pub pending_callbacks: usize,
/// 已交付首结果但尚未收到 End 的流
pub active_streams: usize,
}
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
@@ -1,773 +0,0 @@
//! 插件实例的配置、停用、删除与私有状态迁移编排
use std::collections::{BTreeMap, BTreeSet};
use gateway_core::{
policy::ClientApiKeyId,
routing::{AccountGroupId, extensions::ExtensionSetReference},
};
use secrecy::ExposeSecret;
use super::PluginsService;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
PluginInstanceSnapshot, PluginInstanceView,
},
plugins::state::{PluginStateCommit, PluginStateConfiguration},
},
use_case::{map_store_error, publish_committed},
};
#[derive(Clone, Default)]
pub(super) struct PackageStatus {
warning: Option<String>,
load_error: Option<String>,
}
impl PluginsService {
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
async fn package_status(&self, digest: &str) -> Result<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
let mut cache = self.compatibility.lock().await;
if let Some(warning) = cache.get(digest) {
return Ok(warning.clone());
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let warning = match self
.inspector
.compatibility_warning(artifact.archive, digest.to_owned())
.await
{
Ok(warning) => PackageStatus {
warning,
load_error: None,
},
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
warning: None,
load_error: Some(error.message().to_owned()),
},
Err(error) => return Err(error),
};
if cache.len() < 128 {
cache.insert(digest.to_owned(), warning.clone());
}
Ok(warning)
}
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let published = self.published.snapshot_for_diagnostics();
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
let published_ready = published
.as_ref()
.is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()));
let mut diagnostics = self
.preparation
.runtime_diagnostics(
&snapshot,
published_revision,
published
.as_ref()
.and_then(|snapshot| snapshot.extensions()),
)
.await
.unwrap_or_default();
let target_revision = snapshot.config_revision.get();
let artifacts = self
.list()
.await?
.into_iter()
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
.collect::<BTreeMap<_, _>>();
let mut views = Vec::with_capacity(snapshot.instances.len());
for instance in snapshot.instances {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let package_status = self.package_status(&instance.artifact_sha256).await?;
let configuration_required = package_status.load_error.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
let running = instance.enabled
&& published_ready
&& published_revision == Some(target_revision);
PluginInstanceRuntime {
status: if !instance.enabled {
PluginInstanceRuntimeStatus::Disabled
} else if running {
PluginInstanceRuntimeStatus::Running
} else {
PluginInstanceRuntimeStatus::AwaitingPublication
},
actual_revision: running.then_some(instance.revision.get()),
actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()),
failure: None,
draining_revisions: Vec::new(),
}
});
views.push(PluginInstanceView {
api_deprecations: if package_status.load_error.is_none() {
self.inspector.api_deprecations(metadata)?
} else {
Vec::new()
},
configuration_required,
compatibility_warning: package_status.warning,
load_error: package_status.load_error,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
instance,
});
}
Ok(views)
}
pub async fn configure_instance(
&self,
id: Option<&str>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
if id.is_some() && input.creation_id.is_some() {
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
}
if id.is_none() && input.expected_revision.is_some() {
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
}
if let Some(creation_id) = &input.creation_id {
let parsed = uuid::Uuid::parse_str(creation_id)
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
if parsed.to_string() != *creation_id {
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
}
}
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let existing = match id {
Some(id) => Some(
snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
),
None => input.creation_id.as_ref().and_then(|creation_id| {
snapshot
.instances
.iter()
.find(|instance| instance.id == *creation_id)
.cloned()
}),
};
if let Some(existing) = &existing {
if id.is_none() && !same_creation_request(existing, &input) {
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
}
if input
.expected_revision
.is_some_and(|revision| revision != existing.revision.get())
{
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
}
}
self.configure_from_snapshot(snapshot, existing, input, context)
.await
}
pub(super) async fn configure_from_snapshot(
&self,
mut snapshot: PluginInstanceSnapshot,
existing: Option<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let expected_revision = snapshot.config_revision;
let candidate_revision = next_revision(expected_revision)?;
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let artifact = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
}
if input.enabled
&& let Some(warning) = self
.package_status(&input.artifact_sha256)
.await?
.load_error
{
return Err(AdminError::invalid(format!(
"{warning},无法启动,请安装兼容版本"
)));
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
&& item.metadata.plugin_id == artifact.metadata.plugin_id
})
};
if existing
.as_ref()
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
{
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
}
if input.enabled
&& snapshot.instances.iter().any(|other| {
other.enabled
&& existing
.as_ref()
.is_none_or(|current| current.id != other.id)
&& same_plugin(&other.artifact_sha256)
&& !input
.replace_instances
.iter()
.any(|replacement| replacement.id == other.id)
})
{
return Err(AdminError::conflict(
"此插件已有启用配置,请先切换或停用旧配置",
));
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
.await
.map_err(|error| map_store_error(error, "plugin"))?
} else {
None
};
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
input
.creation_id
.clone()
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
},
|instance| instance.id.clone(),
),
name: input.name.trim().to_owned(),
artifact_sha256: input.artifact_sha256,
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
bindings: input.bindings,
revision: candidate_revision,
};
validate_input(&instance)?;
if existing
.as_ref()
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
&& !self
.preparation
.configuration_ready(instance.clone(), &artifact.metadata)
.await?
{
return Err(AdminError::invalid(
"目标版本仍缺少必填设置,请补充后再切换",
));
}
let replacements = input.replace_instances;
if !replacements.is_empty() {
if !instance.enabled || replacements.len() > 256 {
return Err(AdminError::invalid(
"仅启用配置时可以切换其他配置,单次最多 256 项",
));
}
let mut ids = BTreeSet::new();
for replacement in &replacements {
if replacement.id == instance.id || !ids.insert(&replacement.id) {
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
}
let previous = snapshot
.instances
.iter()
.find(|item| item.id == replacement.id)
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
}
if !artifacts.iter().any(|item| {
item.metadata.sha256 == previous.artifact_sha256
&& item.metadata.plugin_id == artifact.metadata.plugin_id
}) {
return Err(AdminError::invalid("只能切换同一插件的配置"));
}
}
}
let state = self.preparation.validate(instance.clone()).await?;
if let Some(existing) = &existing
&& self.state.transition_required(&existing.id, &state).await?
{
return self
.configure_instance_with_state_transition(
snapshot,
existing.clone(),
instance,
state,
&replacements,
context,
)
.await;
}
snapshot.instances.retain(|item| item.id != instance.id);
snapshot.instances.push(instance.clone());
snapshot.config_revision = candidate_revision;
apply_replacements(&mut snapshot, &replacements);
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let result = self
.store
.save_instance_replacing(
instance,
expected_revision,
PluginStateCommit {
configuration: state,
transition_id: None,
},
&replacements,
context,
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
self.publish_instance(snapshot, prepared, &result, &replacements)
.await?;
Ok(result)
}
async fn configure_instance_with_state_transition(
&self,
mut snapshot: PluginInstanceSnapshot,
previous: PluginInstance,
mut target: PluginInstance,
target_state: PluginStateConfiguration,
replacements: &[PluginInstanceReplacement],
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let previous_state = self.preparation.validate(previous.clone()).await?;
let original_revision = snapshot.config_revision;
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
let mut migration_instance = target.clone();
migration_instance.enabled = true;
snapshot.config_revision = migration_instance.revision;
snapshot
.instances
.retain(|instance| instance.id != migration_instance.id);
snapshot.instances.push(migration_instance);
apply_replacements(&mut snapshot, replacements);
let migration_prepared = self.preparation.prepare(snapshot).await?;
let mut disabled_revision = None;
let disabled = if previous.enabled {
let mut disabled = previous.clone();
disabled.enabled = false;
let mutation = self
.store
.save_instance_with_state(
disabled,
original_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
self.preparation
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
.await;
disabled_revision = Some(mutation.config_revision);
mutation.instance
} else {
previous.clone()
};
let expected_revision = disabled_revision.unwrap_or(original_revision);
let transition = match self
.state
.begin_transition(
&disabled.id,
disabled.revision,
&target.artifact_sha256,
target_state.clone(),
)
.await
{
Ok(transition) => transition,
Err(error) => {
return Err(self
.recover_failed_state_transition(
None,
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
let committed = async {
self.preparation
.migrate_state(&migration_prepared, transition.clone())
.await?;
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = self.preparation.prepare(current.clone()).await?;
let result = self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
Ok((current, prepared, result))
}
.await;
let (current, prepared, result) = match committed {
Ok(committed) => committed,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
self.publish_instance(current, prepared, &result, replacements)
.await?;
drop(migration_prepared);
Ok(result)
}
async fn recover_failed_state_transition(
&self,
transition_id: Option<&str>,
previous: &PluginInstance,
previous_state: &PluginStateConfiguration,
disabled_revision: Option<Revision>,
context: &MutationContext,
original_error: AdminError,
) -> AdminError {
if let Some(transition_id) = transition_id {
self.state.abort_transition(transition_id).await;
}
let Some(disabled_revision) = disabled_revision else {
return original_error;
};
let restored = async {
let mut snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if snapshot.config_revision != disabled_revision {
return Err(AdminError::conflict(
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
));
}
let candidate_revision = next_revision(disabled_revision)?;
let mut restore = previous.clone();
restore.enabled = true;
restore.revision = candidate_revision;
snapshot.instances.retain(|item| item.id != restore.id);
snapshot.instances.push(restore.clone());
snapshot.config_revision = candidate_revision;
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let mutation = self
.store
.save_instance_with_state(
restore,
disabled_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
self.publish_instance(snapshot, prepared, &mutation, &[])
.await?;
Ok::<(), AdminError>(())
}
.await;
match restored {
Ok(()) => original_error,
Err(error) => {
tracing::warn!(
error_kind = ?error.kind(),
"plugin state migration failed and the previous instance could not be restored"
);
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
}
}
}
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
async fn publish_instance(
&self,
mut snapshot: PluginInstanceSnapshot,
prepared: ExtensionSetReference,
mutation: &PluginInstanceMutation,
replacements: &[PluginInstanceReplacement],
) -> Result<(), AdminError> {
let prepared = if mutation.config_revision == snapshot.config_revision
&& mutation.instance.revision == snapshot.config_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
apply_replacements(&mut snapshot, replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
drop(prepared);
Ok(())
}
pub async fn delete_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if instance.enabled {
return Err(AdminError::conflict("请先停用插件实例"));
}
let revision = self
.store
.delete_instance(id, snapshot.config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
pub async fn disable_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let PluginInstanceSnapshot {
config_revision,
instances,
} = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let mut instance = instances
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
instance.enabled = false;
let result = self
.store
.save_instance(instance, config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
Ok(result)
}
}
fn apply_replacements(
snapshot: &mut PluginInstanceSnapshot,
replacements: &[PluginInstanceReplacement],
) {
for instance in &mut snapshot.instances {
if replacements.iter().any(|item| item.id == instance.id) {
instance.enabled = false;
instance.revision = snapshot.config_revision;
}
}
}
fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool {
let same_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
&& existing.enabled == input.enabled
&& existing.configuration == input.configuration
&& existing.bindings == input.bindings
&& same_secrets
}
fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
if instance.name.is_empty()
|| instance.name.len() > 128
|| instance.name.chars().any(char::is_control)
|| instance.artifact_sha256.len() != 64
|| !instance
.artifact_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| !instance.configuration.is_object()
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|| instance.secrets.len() > 64
|| instance.bindings.len() > 64
{
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
}
use secrecy::ExposeSecret as _;
if instance
.secrets
.iter()
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|| instance
.secrets
.values()
.map(|value| value.expose_secret().len())
.sum::<usize>()
> 48 * 1024
{
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
}
if instance.bindings.iter().any(|binding| {
binding.contribution.is_empty()
|| binding.contribution.len() > 128
|| !binding
.contribution
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|| binding.client_key_ids.len() > 256
|| binding.account_group_ids.len() > 256
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.len()
!= binding.account_group_ids.len()
|| binding
.client_key_ids
.iter()
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|| binding
.account_group_ids
.iter()
.any(|id| AccountGroupId::new(id.clone()).is_err())
|| binding.identity_bindings.len() > 256
|| binding
.identity_bindings
.iter()
.map(|identity| &identity.principal)
.collect::<BTreeSet<_>>()
.len()
!= binding.identity_bindings.len()
|| binding.identity_bindings.iter().any(|identity| {
identity.principal.trim() != identity.principal
|| identity.principal.is_empty()
|| identity.principal.len() > 256
|| identity.principal.chars().any(char::is_control)
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
})
}) {
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
}
Ok(())
}
fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -0,0 +1,135 @@
//! 插件配置的输入约束、创建幂等比较与候选快照变换
use std::collections::BTreeSet;
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
use secrecy::ExposeSecret;
use crate::model::{
AdminError, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceReplacement, PluginInstanceSnapshot,
},
};
pub(super) fn apply_replacements(
snapshot: &mut PluginInstanceSnapshot,
replacements: &[PluginInstanceReplacement],
) {
for instance in &mut snapshot.instances {
if replacements.iter().any(|item| item.id == instance.id) {
instance.enabled = false;
instance.revision = snapshot.config_revision;
}
}
}
pub(super) fn same_creation_request(
existing: &PluginInstance,
input: &ConfigurePluginInstance,
) -> bool {
let same_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
&& existing.enabled == input.enabled
&& existing.configuration == input.configuration
&& existing.bindings == input.bindings
&& same_secrets
}
pub(super) fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
if instance.name.is_empty()
|| instance.name.len() > 128
|| instance.name.chars().any(char::is_control)
|| instance.artifact_sha256.len() != 64
|| !instance
.artifact_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| !instance.configuration.is_object()
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|| instance.secrets.len() > 64
|| instance.bindings.len() > 64
{
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
}
use secrecy::ExposeSecret as _;
if instance
.secrets
.iter()
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|| instance
.secrets
.values()
.map(|value| value.expose_secret().len())
.sum::<usize>()
> 48 * 1024
{
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
}
if instance.bindings.iter().any(|binding| {
binding.contribution.is_empty()
|| binding.contribution.len() > 128
|| !binding
.contribution
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|| binding.client_key_ids.len() > 256
|| binding.account_group_ids.len() > 256
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.len()
!= binding.account_group_ids.len()
|| binding
.client_key_ids
.iter()
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|| binding
.account_group_ids
.iter()
.any(|id| AccountGroupId::new(id.clone()).is_err())
|| binding.identity_bindings.len() > 256
|| binding
.identity_bindings
.iter()
.map(|identity| &identity.principal)
.collect::<BTreeSet<_>>()
.len()
!= binding.identity_bindings.len()
|| binding.identity_bindings.iter().any(|identity| {
identity.principal.trim() != identity.principal
|| identity.principal.is_empty()
|| identity.principal.len() > 256
|| identity.principal.chars().any(char::is_control)
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
})
}) {
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
}
Ok(())
}
pub(super) fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -0,0 +1,216 @@
//! 插件私有状态迁移的排空、暂存提交与失败恢复编排
use super::{
PluginsService,
configuration::{apply_replacements, next_revision},
};
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::{
instances::{
PluginInstance, PluginInstanceMutation, PluginInstanceReplacement,
PluginInstanceSnapshot,
},
state::{PluginStateCommit, PluginStateConfiguration},
},
},
use_case::{map_store_error, publish_committed},
};
impl PluginsService {
pub(super) async fn configure_instance_with_state_transition(
&self,
mut snapshot: PluginInstanceSnapshot,
previous: PluginInstance,
mut target: PluginInstance,
target_state: PluginStateConfiguration,
replacements: &[PluginInstanceReplacement],
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let previous_state = self.preparation.validate(previous.clone()).await?;
let original_revision = snapshot.config_revision;
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
let mut migration_instance = target.clone();
migration_instance.enabled = true;
snapshot.config_revision = migration_instance.revision;
snapshot
.instances
.retain(|instance| instance.id != migration_instance.id);
snapshot.instances.push(migration_instance);
apply_replacements(&mut snapshot, replacements);
let migration_prepared = self.preparation.prepare(snapshot).await?;
let mut disabled_revision = None;
let disabled = if previous.enabled {
let mut disabled = previous.clone();
disabled.enabled = false;
let mutation = self
.store
.save_instance_with_state(
disabled,
original_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
self.preparation
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
.await;
disabled_revision = Some(mutation.config_revision);
mutation.instance
} else {
previous.clone()
};
let expected_revision = disabled_revision.unwrap_or(original_revision);
let transition = match self
.state
.begin_transition(
&disabled.id,
disabled.revision,
&target.artifact_sha256,
target_state.clone(),
)
.await
{
Ok(transition) => transition,
Err(error) => {
return Err(self
.recover_failed_state_transition(
None,
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
let committed = async {
self.preparation
.migrate_state(&migration_prepared, transition.clone())
.await?;
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = self.preparation.prepare(current.clone()).await?;
let result = self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
Ok((current, prepared, result))
}
.await;
let (current, prepared, result) = match committed {
Ok(committed) => committed,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
self.publish_instance(current, prepared, &result, replacements)
.await?;
drop(migration_prepared);
Ok(result)
}
async fn recover_failed_state_transition(
&self,
transition_id: Option<&str>,
previous: &PluginInstance,
previous_state: &PluginStateConfiguration,
disabled_revision: Option<Revision>,
context: &MutationContext,
original_error: AdminError,
) -> AdminError {
if let Some(transition_id) = transition_id {
self.state.abort_transition(transition_id).await;
}
let Some(disabled_revision) = disabled_revision else {
return original_error;
};
let restored = async {
let mut snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if snapshot.config_revision != disabled_revision {
return Err(AdminError::conflict(
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
));
}
let candidate_revision = next_revision(disabled_revision)?;
let mut restore = previous.clone();
restore.enabled = true;
restore.revision = candidate_revision;
snapshot.instances.retain(|item| item.id != restore.id);
snapshot.instances.push(restore.clone());
snapshot.config_revision = candidate_revision;
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let mutation = self
.store
.save_instance_with_state(
restore,
disabled_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
self.publish_instance(snapshot, prepared, &mutation, &[])
.await?;
Ok::<(), AdminError>(())
}
.await;
match restored {
Ok(()) => original_error,
Err(error) => {
tracing::warn!(
error_kind = ?error.kind(),
"plugin state migration failed and the previous instance could not be restored"
);
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
}
}
}
}
@@ -0,0 +1,15 @@
//! 插件实例用例的职责边界与静态检查结果
mod configuration;
mod migration;
mod mutations;
mod publication;
mod queries;
use super::PluginsService;
#[derive(Clone, Default)]
pub(super) struct PackageStatus {
pub(super) warning: Option<String>,
pub(super) load_error: Option<String>,
}
@@ -0,0 +1,312 @@
//! 插件实例的配置提交、发布、停用与删除用例
use std::collections::BTreeSet;
use super::{
PluginsService,
configuration::{apply_replacements, next_revision, same_creation_request, validate_input},
};
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::{
instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceSnapshot,
},
state::PluginStateCommit,
},
},
use_case::{map_store_error, publish_committed},
};
impl PluginsService {
pub async fn configure_instance(
&self,
id: Option<&str>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
if id.is_some() && input.creation_id.is_some() {
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
}
if id.is_none() && input.expected_revision.is_some() {
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
}
if let Some(creation_id) = &input.creation_id {
let parsed = uuid::Uuid::parse_str(creation_id)
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
if parsed.to_string() != *creation_id {
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
}
}
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let existing = match id {
Some(id) => Some(
snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
),
None => input.creation_id.as_ref().and_then(|creation_id| {
snapshot
.instances
.iter()
.find(|instance| instance.id == *creation_id)
.cloned()
}),
};
if let Some(existing) = &existing {
if id.is_none() && !same_creation_request(existing, &input) {
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
}
if input
.expected_revision
.is_some_and(|revision| revision != existing.revision.get())
{
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
}
}
self.configure_from_snapshot(snapshot, existing, input, context)
.await
}
pub(in crate::use_case::plugins) async fn configure_from_snapshot(
&self,
mut snapshot: PluginInstanceSnapshot,
existing: Option<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let expected_revision = snapshot.config_revision;
let candidate_revision = next_revision(expected_revision)?;
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let artifact = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并确认完整信任该版本"));
}
if input.enabled
&& let Some(warning) = self
.package_status(&input.artifact_sha256)
.await?
.load_error
{
return Err(AdminError::invalid(format!(
"{warning},无法启动,请安装兼容版本"
)));
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
&& item.metadata.plugin_id == artifact.metadata.plugin_id
})
};
if existing
.as_ref()
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
{
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
}
if input.enabled
&& snapshot.instances.iter().any(|other| {
other.enabled
&& existing
.as_ref()
.is_none_or(|current| current.id != other.id)
&& same_plugin(&other.artifact_sha256)
&& !input
.replace_instances
.iter()
.any(|replacement| replacement.id == other.id)
})
{
return Err(AdminError::conflict(
"此插件已有启用配置,请先切换或停用旧配置",
));
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
.await
.map_err(|error| map_store_error(error, "plugin"))?
} else {
None
};
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
input
.creation_id
.clone()
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
},
|instance| instance.id.clone(),
),
name: input.name.trim().to_owned(),
artifact_sha256: input.artifact_sha256,
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
bindings: input.bindings,
revision: candidate_revision,
};
validate_input(&instance)?;
if existing
.as_ref()
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
&& !self
.preparation
.configuration_ready(instance.clone(), &artifact.metadata)
.await?
{
return Err(AdminError::invalid(
"目标版本仍缺少必填设置,请补充后再切换",
));
}
let replacements = input.replace_instances;
if !replacements.is_empty() {
if !instance.enabled || replacements.len() > 256 {
return Err(AdminError::invalid(
"仅启用配置时可以切换其他配置,单次最多 256 项",
));
}
let mut ids = BTreeSet::new();
for replacement in &replacements {
if replacement.id == instance.id || !ids.insert(&replacement.id) {
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
}
let previous = snapshot
.instances
.iter()
.find(|item| item.id == replacement.id)
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
}
if !artifacts.iter().any(|item| {
item.metadata.sha256 == previous.artifact_sha256
&& item.metadata.plugin_id == artifact.metadata.plugin_id
}) {
return Err(AdminError::invalid("只能切换同一插件的配置"));
}
}
}
let state = self.preparation.validate(instance.clone()).await?;
if let Some(existing) = &existing
&& self.state.transition_required(&existing.id, &state).await?
{
return self
.configure_instance_with_state_transition(
snapshot,
existing.clone(),
instance,
state,
&replacements,
context,
)
.await;
}
snapshot.instances.retain(|item| item.id != instance.id);
snapshot.instances.push(instance.clone());
snapshot.config_revision = candidate_revision;
apply_replacements(&mut snapshot, &replacements);
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let result = self
.store
.save_instance_replacing(
instance,
expected_revision,
PluginStateCommit {
configuration: state,
transition_id: None,
},
&replacements,
context,
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
self.publish_instance(snapshot, prepared, &result, &replacements)
.await?;
Ok(result)
}
pub async fn delete_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if instance.enabled {
return Err(AdminError::conflict("请先停用插件实例"));
}
let revision = self
.store
.delete_instance(id, snapshot.config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
pub async fn disable_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let PluginInstanceSnapshot {
config_revision,
instances,
} = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let mut instance = instances
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
instance.enabled = false;
let result = self
.store
.save_instance(instance, config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
Ok(result)
}
}
@@ -0,0 +1,45 @@
//! 配置提交与迁移恢复共用的状态激活和发布收尾
use gateway_core::routing::extensions::ExtensionSetReference;
use super::{PluginsService, configuration::apply_replacements};
use crate::{
model::{
AdminError,
plugins::instances::{
PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceSnapshot,
},
},
use_case::publish_committed,
};
impl PluginsService {
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
pub(super) async fn publish_instance(
&self,
mut snapshot: PluginInstanceSnapshot,
prepared: ExtensionSetReference,
mutation: &PluginInstanceMutation,
replacements: &[PluginInstanceReplacement],
) -> Result<(), AdminError> {
let prepared = if mutation.config_revision == snapshot.config_revision
&& mutation.instance.revision == snapshot.config_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
apply_replacements(&mut snapshot, replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
drop(prepared);
Ok(())
}
}
@@ -0,0 +1,122 @@
//! 插件实例列表与静态包检查的只读投影,不从发布事实推断进程状态
use std::collections::BTreeMap;
use super::{PackageStatus, PluginsService};
use crate::{
model::{
AdminError, AdminErrorKind,
plugins::instances::{
PluginInstanceRuntime, PluginInstanceRuntimeStatus, PluginInstanceView,
},
},
use_case::map_store_error,
};
impl PluginsService {
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
pub(in crate::use_case::plugins) async fn package_status(
&self,
digest: &str,
) -> Result<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
if let Some(status) = self.compatibility.lock().await.get(digest).cloned() {
return Ok(status);
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let status = match self
.inspector
.compatibility_warning(artifact.archive, digest.to_owned())
.await
{
Ok(warning) => PackageStatus {
warning,
load_error: None,
},
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
warning: None,
load_error: Some(error.message().to_owned()),
},
Err(error) => return Err(error),
};
let mut cache = self.compatibility.lock().await;
if cache.len() < 128 {
cache.insert(digest.to_owned(), status.clone());
}
Ok(status)
}
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let published = self.published.snapshot_for_diagnostics();
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
let mut diagnostics = self
.preparation
.runtime_diagnostics(
&snapshot,
published_revision,
published
.as_ref()
.and_then(|snapshot| snapshot.extensions()),
)
.await
.unwrap_or_default();
let artifacts = self
.list()
.await?
.into_iter()
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
.collect::<BTreeMap<_, _>>();
let mut views = Vec::with_capacity(snapshot.instances.len());
for instance in snapshot.instances {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let package_status = self.package_status(&instance.artifact_sha256).await?;
let configuration_required = package_status.load_error.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime =
diagnostics
.remove(&instance.id)
.unwrap_or_else(|| PluginInstanceRuntime {
status: if !instance.enabled {
PluginInstanceRuntimeStatus::Disabled
} else {
PluginInstanceRuntimeStatus::Unknown
},
actual_revision: None,
actual_artifact_sha256: None,
failure: None,
draining_revisions: Vec::new(),
load: None,
});
views.push(PluginInstanceView {
api_deprecations: if package_status.load_error.is_none() {
self.inspector.api_deprecations(metadata)?
} else {
Vec::new()
},
configuration_required,
compatibility_warning: package_status.warning,
load_error: package_status.load_error,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
instance,
});
}
Ok(views)
}
}
@@ -49,6 +49,8 @@ use serde_json::json;
use super::TestPluginPorts;
mod queries;
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const NEW_ARTIFACT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
@@ -88,6 +90,11 @@ impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture
archive: Arc<[u8]>,
digest: String,
) -> Result<Option<String>, AdminError> {
let gate = self.inspection_gate.lock().unwrap().clone();
if let Some(gate) = gate.filter(|gate| gate.digest == digest) {
gate.started.notify_one();
gate.release.notified().await;
}
self.inspect(archive, Some(digest)).await?;
Ok(self.data.lock().unwrap().compatibility_warning.clone())
}
@@ -143,11 +150,19 @@ struct LifecycleFixture {
behavior: MigrationBehavior,
data: Mutex<FixtureData>,
next_prepared: AtomicU64,
inspection_gate: Mutex<Option<Arc<InspectionGate>>>,
}
struct InspectionGate {
digest: String,
started: tokio::sync::Notify,
release: tokio::sync::Notify,
}
impl LifecycleFixture {
fn new(behavior: MigrationBehavior) -> Arc<Self> {
Arc::new(Self {
inspection_gate: Mutex::new(None),
behavior,
data: Mutex::new(FixtureData {
snapshot: PluginInstanceSnapshot {
@@ -970,49 +985,6 @@ fn state_unavailable<T>() -> PluginStateStoreResult<T> {
))
}
#[tokio::test]
async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let expected = PluginInstanceRuntime {
status: PluginInstanceRuntimeStatus::PreparationFailed,
actual_revision: Some(9),
actual_artifact_sha256: Some(OLD_ARTIFACT.into()),
failure: Some(PluginInstanceRuntimeFailure {
code: "unavailable".into(),
message: "插件候选准备失败".into(),
}),
draining_revisions: vec![8],
};
fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([(
"00000000-0000-7000-8000-000000000001".into(),
expected.clone(),
)]));
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert!(!views[0].running);
assert_eq!(views[0].runtime, expected);
}
#[tokio::test]
async fn instance_list_falls_back_without_inventing_runtime_failures() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert_eq!(
views[0].runtime.status,
PluginInstanceRuntimeStatus::AwaitingPublication
);
assert!(!views[0].running);
assert!(views[0].runtime.failure.is_none());
}
#[tokio::test]
async fn incompatible_state_upgrade_quiesces_migrates_and_promotes_the_target() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
@@ -0,0 +1,151 @@
//! 实例运行诊断与包检查缓存的查询回归测试
use super::*;
use gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad;
#[tokio::test]
async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let expected = PluginInstanceRuntime {
status: PluginInstanceRuntimeStatus::PreparationFailed,
actual_revision: Some(9),
actual_artifact_sha256: Some(OLD_ARTIFACT.into()),
failure: Some(PluginInstanceRuntimeFailure {
code: "unavailable".into(),
message: "插件候选准备失败".into(),
}),
draining_revisions: vec![8],
load: Some(PluginInstanceRuntimeLoad {
active_calls: 3,
executing_calls: 1,
queued_calls: 1,
resuming_calls: 1,
pending_callbacks: 2,
active_streams: 0,
}),
};
fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([(
"00000000-0000-7000-8000-000000000001".into(),
expected.clone(),
)]));
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert!(!views[0].running);
assert_eq!(views[0].runtime, expected);
}
#[tokio::test]
async fn instance_list_falls_back_without_inventing_runtime_failures() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert_eq!(
views[0].runtime.status,
PluginInstanceRuntimeStatus::Unknown
);
assert!(!views[0].running);
assert!(views[0].runtime.failure.is_none());
assert!(views[0].runtime.actual_revision.is_none());
assert!(views[0].runtime.load.is_none());
}
#[tokio::test]
async fn disabled_instance_without_runtime_diagnostics_has_a_known_disabled_status() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().snapshot.instances[0].enabled = false;
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(
views[0].runtime.status,
PluginInstanceRuntimeStatus::Disabled
);
assert!(!views[0].running);
assert!(views[0].runtime.failure.is_none());
assert!(views[0].runtime.load.is_none());
}
#[tokio::test]
async fn matching_publication_without_process_diagnostics_does_not_imply_running() {
for diagnostics in [None, Some(BTreeMap::new())] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().diagnostics = diagnostics;
let revision = fixture.snapshot().config_revision.get();
let published = RuntimeSnapshotHandle::new(
gateway_core::routing::RuntimeSnapshot::new(
ConfigRevision::new(revision).unwrap(),
gateway_core::settings::SettingsValues::new(
1,
"round_robin",
BTreeMap::new(),
None,
None,
),
Vec::new(),
Vec::new(),
Vec::new(),
)
.unwrap(),
);
let service = PluginsService::new(
fixture.clone(),
fixture.clone(),
PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)),
Arc::new(Published::default()),
fixture.clone(),
published,
fixture,
);
let view = service.instances().await.unwrap().remove(0);
assert_eq!(view.published_revision, Some(revision));
assert_eq!(view.runtime.status, PluginInstanceRuntimeStatus::Unknown);
assert!(!view.running);
assert!(view.runtime.actual_revision.is_none());
assert!(view.runtime.actual_artifact_sha256.is_none());
assert!(view.runtime.failure.is_none());
}
}
#[tokio::test]
async fn cached_package_query_is_not_blocked_by_another_package_inspection() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let service = Arc::new(service(fixture.clone(), Arc::new(Published::default())));
service.instances().await.unwrap();
let gate = Arc::new(InspectionGate {
digest: NEW_ARTIFACT.into(),
started: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
});
*fixture.inspection_gate.lock().unwrap() = Some(gate.clone());
fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = NEW_ARTIFACT.into();
let inspecting = {
let service = service.clone();
tokio::spawn(async move { service.instances().await })
};
tokio::time::timeout(std::time::Duration::from_secs(1), gate.started.notified())
.await
.expect("another package inspection started");
fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = OLD_ARTIFACT.into();
let cached =
tokio::time::timeout(std::time::Duration::from_millis(100), service.instances()).await;
gate.release.notify_one();
inspecting.await.unwrap().unwrap();
assert_eq!(
cached
.expect("cached query must not wait for inspection")
.unwrap()[0]
.instance
.artifact_sha256,
OLD_ARTIFACT
);
assert_eq!(fixture.data.lock().unwrap().archive_reads, 2);
}
@@ -9,8 +9,8 @@ use axum::{
};
use gateway_admin::model::plugins::instances::{
ConfigurePluginInstance, PluginCapabilityBinding, PluginInstance, PluginInstanceRuntime,
PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, PluginInstanceView,
RollbackPluginInstance,
PluginInstanceRuntimeFailure, PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus,
PluginInstanceView, RollbackPluginInstance,
};
use serde::{Deserialize, Serialize};
@@ -70,6 +70,7 @@ struct InstanceView {
#[serde(rename_all = "snake_case")]
enum InstanceRuntimeStatus {
Disabled,
Unknown,
AwaitingPublication,
Preparing,
Running,
@@ -83,6 +84,7 @@ impl From<PluginInstanceRuntimeStatus> for InstanceRuntimeStatus {
fn from(value: PluginInstanceRuntimeStatus) -> Self {
match value {
PluginInstanceRuntimeStatus::Disabled => Self::Disabled,
PluginInstanceRuntimeStatus::Unknown => Self::Unknown,
PluginInstanceRuntimeStatus::AwaitingPublication => Self::AwaitingPublication,
PluginInstanceRuntimeStatus::Preparing => Self::Preparing,
PluginInstanceRuntimeStatus::Running => Self::Running,
@@ -118,6 +120,31 @@ struct InstanceRuntimeView {
actual_artifact_sha256: Option<String>,
failure: Option<InstanceRuntimeFailure>,
draining_revisions: Vec<u64>,
load: Option<InstanceRuntimeLoad>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InstanceRuntimeLoad {
active_calls: usize,
executing_calls: usize,
queued_calls: usize,
resuming_calls: usize,
pending_callbacks: usize,
active_streams: usize,
}
impl From<PluginInstanceRuntimeLoad> for InstanceRuntimeLoad {
fn from(value: PluginInstanceRuntimeLoad) -> Self {
Self {
active_calls: value.active_calls,
executing_calls: value.executing_calls,
queued_calls: value.queued_calls,
resuming_calls: value.resuming_calls,
pending_callbacks: value.pending_callbacks,
active_streams: value.active_streams,
}
}
}
impl From<PluginInstanceRuntime> for InstanceRuntimeView {
@@ -128,6 +155,7 @@ impl From<PluginInstanceRuntime> for InstanceRuntimeView {
actual_artifact_sha256: value.actual_artifact_sha256,
failure: value.failure.map(Into::into),
draining_revisions: value.draining_revisions,
load: value.load.map(Into::into),
}
}
}
@@ -322,6 +322,17 @@ async fn instance_list_serializes_safe_runtime_diagnostics() {
assert_eq!(instance["running"], false);
assert_eq!(instance["runtime"]["status"], "preparation_failed");
assert_eq!(instance["runtime"]["actualRevision"], 6);
assert_eq!(
instance["runtime"]["load"],
serde_json::json!({
"activeCalls": 3,
"executingCalls": 1,
"queuedCalls": 1,
"resumingCalls": 1,
"pendingCallbacks": 2,
"activeStreams": 0,
})
);
assert_eq!(
instance["runtime"]["drainingRevisions"],
serde_json::json!([5])
@@ -665,6 +676,14 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts
message: "插件候选准备失败".into(),
}),
draining_revisions: vec![5],
load: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad {
active_calls: 3,
executing_calls: 1,
queued_calls: 1,
resuming_calls: 1,
pending_callbacks: 2,
active_streams: 0,
}),
},
)
})
@@ -2164,109 +2164,132 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
for (error_type, code, client_code, kind, client_status) in [
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
"service_unavailable_error",
"server_is_overloaded",
"server_error",
ProviderErrorKind::UpstreamCapacityUnavailable,
503,
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_error",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
"rate_limit_error",
"turn_rate_limit_exceeded",
"turn_rate_limit_exceeded",
ProviderErrorKind::RateLimited,
429,
),
] {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"status": 429,
"headers": {"Retry-After": "24"},
"error": {
"type": error_type,
"code": code,
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
kind,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"status": client_status,
"headers": {"Retry-After": "24"},
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": error_type,
"code": client_code,
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true},
"headers": {"retry-after": "24"}
},
"future_response_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
}
#[tokio::test]
@@ -948,8 +948,8 @@ fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websoc
json!({
"type": "error",
"status": 429,
"headers": {"x-request-id": "req_raw"},
"error": {"type": "custom_type", "code": "custom_code", "message": "raw marker"},
"headers": {"x-request-id": "req_raw", "Retry-After": "24"},
"error": {"type": "rate_limit_error", "code": "turn_rate_limit_exceeded", "message": "raw marker"},
"future": {"keep": true}
}),
);
@@ -9,15 +9,38 @@ use std::{
use futures::future::BoxFuture;
use crate::{
error::GatewayError,
policy::{ClientApiKeyId, PlaintextClientApiKey},
routing::extensions::{ExtensionSetId, ExtensionSetReference},
routing::{
extensions::{ExtensionSetId, ExtensionSetReference},
request_settings::RequestSettings,
},
};
use super::execution::AuthenticatedClient;
/// 身份解析与认证只返回冻结权限,不提供模型执行入口
pub trait ClientIdentityPort: Send + Sync {
/// 只解析身份与冻结权限,不建立模型执行图或记录使用事实
fn resolve_client(
&self,
client_key_id: ClientApiKeyId,
settings: Option<RequestSettings>,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
/// 复用入口认证计划及 principal 映射,不进行模型准入
fn verify_client(
&self,
request: ClientAuthenticationRequest,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
}
pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024;
/// API 从数据面 Authorization 头构造的有界认证信封
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone)]
pub struct ClientAuthenticationRequest {
invocation: Option<Arc<super::extensions::InvocationOrigin>>,
request_context: Option<Arc<super::extensions::RequestContext>>,
settings: Option<crate::routing::request_settings::RequestSettings>,
authorization: Arc<str>,
@@ -25,6 +48,18 @@ pub struct ClientAuthenticationRequest {
}
impl ClientAuthenticationRequest {
/// 子认证继承受信调用的来源、取消和副作用归属,不据此跳过认证器
#[must_use]
pub fn with_invocation(mut self, origin: super::extensions::InvocationOrigin) -> Self {
self.invocation = Some(Arc::new(origin));
self
}
#[must_use]
pub fn invocation(&self) -> Option<&super::extensions::InvocationOrigin> {
self.invocation.as_deref()
}
#[must_use]
pub fn with_request_context(
mut self,
@@ -35,12 +70,18 @@ impl ClientAuthenticationRequest {
}
#[must_use]
pub fn request_context(&self) -> Option<&super::extensions::RequestContext> {
self.request_context.as_deref()
self.invocation
.as_ref()
.and_then(|origin| origin.extensions.request())
.or(self.request_context.as_deref())
}
#[must_use]
pub fn shared_request_context(&self) -> Option<Arc<super::extensions::RequestContext>> {
self.request_context.clone()
self.invocation
.as_ref()
.and_then(|origin| origin.extensions.shared_request())
.or_else(|| self.request_context.clone())
}
#[must_use]
@@ -79,6 +120,7 @@ impl ClientAuthenticationRequest {
.filter(|value| !value.is_empty())
.and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok());
Ok(Self {
invocation: None,
settings: None,
request_context: None,
authorization: authorization.into(),
@@ -12,7 +12,6 @@ pub use contract::{
StartedExecution,
};
mod scope;
pub use scope::BoundModelExecutionContext;
mod service;
pub use failure::gateway_error_from_engine;
pub use service::DefaultExecutionService;
@@ -15,13 +15,10 @@ use crate::{
lifecycle::{CancellationToken, Deadline},
operation::Operation,
policy::ClientApiKeyId,
routing::{
FrozenAccountScope, PublicModelId, UpstreamModelId, request_settings::RequestSettings,
},
routing::{FrozenAccountScope, PublicModelId, UpstreamModelId},
};
use std::{
collections::BTreeMap,
fmt,
sync::{
Arc, Mutex, Weak,
atomic::{AtomicUsize, Ordering},
@@ -188,57 +185,6 @@ pub(super) struct ActiveRequestLease {
pub(super) authority: Arc<ActiveRequestAuthority>,
}
/// 管理页或 CLI 一次调用持有的显式模型执行身份
///
/// 结构不暴露 Key 明文;Runtime 只能把它原样交回 Core 发起模型请求
#[derive(Clone)]
pub struct BoundModelExecutionContext {
pub(super) authority: Arc<BoundModelExecutionAuthority>,
}
impl BoundModelExecutionContext {
/// 与实际执行共用绑定时的快照,不能回退到后续发布的配置
#[must_use]
pub fn request_body_limit(&self) -> usize {
self.authority
.client
.snapshot
.responses_max_decompressed_body_bytes()
}
#[must_use]
pub fn request_settings(&self) -> Option<&RequestSettings> {
self.authority.client.request_settings()
}
}
pub(super) struct BoundModelExecutionAuthority {
pub(super) client: AuthenticatedClient,
pub(super) account_scope: Arc<FrozenAccountScope>,
pub(super) cancellation: CancellationToken,
pub(super) extension_scope: ExtensionCallScope,
pub(super) initiating_plugin_instance_id: String,
}
impl fmt::Debug for BoundModelExecutionContext {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundModelExecutionContext")
.field("key_id", &self.authority.client.policy.key_id())
.field(
"initiating_plugin_instance_id",
&self.authority.initiating_plugin_instance_id,
)
.finish_non_exhaustive()
}
}
impl Drop for BoundModelExecutionAuthority {
fn drop(&mut self) {
self.cancellation.cancel();
}
}
impl ActiveRequestLease {
pub(super) fn release(self) {
drop(self);
@@ -12,9 +12,8 @@ use super::contract::{
use super::probe_store::{ProbeObservation, TransientExecutionStore};
use super::scope::{
ActiveRequestAuthority, ActiveRequestLease, ActiveRequestRegistry, AuthorizedExecution,
BoundExecutionFacts, BoundModelExecutionAuthority, BoundModelExecutionContext,
ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts, NestedExecutionGraph,
PendingStartExecution, PreparedExecutionStart,
BoundExecutionFacts, ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts,
NestedExecutionGraph, PendingStartExecution, PreparedExecutionStart,
};
use super::{COORDINATION_TIMEOUT, DIAGNOSTIC_TIMEOUT};
use super::{scope::ExecutionAdmission, session::DefaultExecutionSession};
@@ -28,7 +27,7 @@ use crate::{
ClientAdmissionRequest,
},
authentication::{
ClientAuthenticationRequest, FrontendAuthenticationDecision,
ClientAuthenticationRequest, ClientIdentityPort, FrontendAuthenticationDecision,
FrontendAuthenticationExtensionIndex,
},
budget::{ClientBudgetCharge, ClientBudgetPort},
@@ -41,8 +40,8 @@ use crate::{
middleware::FrozenMiddlewarePlan,
nested::{
AffinityLookupPort, AffinityLookupRequest, AffinityLookupResult,
BoundModelExecutionBinding, BoundModelExecutionRequest, ExecutionEffects,
NestedModelExecutionPort, NestedModelExecutionRequest,
BoundModelExecutionRequest, ExecutionEffects, NestedModelExecutionPort,
NestedModelExecutionRequest,
},
observation::{
FrozenRequestObservationContext, RequestObservationDispatch, RequestObservationScope,
@@ -1081,72 +1080,54 @@ impl DefaultExecutionService {
})
}
async fn bind_bound_model_inner(
&self,
binding: BoundModelExecutionBinding,
) -> Result<BoundModelExecutionContext, GatewayError> {
let client = self
.client_for_key_id(&binding.client_key_id, binding.settings)
.await?;
if binding.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
return Err(GatewayError::new(
GatewayErrorKind::PolicyDenied,
"plugin model execution depth exceeded",
));
}
let extension_scope = binding
.extension_scope
.extending(binding.initiating_plugin_instance_id.clone())
.ok_or_else(|| {
GatewayError::new(
GatewayErrorKind::Internal,
"bound plugin execution scope is invalid",
)
})?;
let account_scope = Arc::clone(client.policy.account_scope());
Ok(BoundModelExecutionContext {
authority: Arc::new(BoundModelExecutionAuthority {
client,
account_scope,
cancellation: binding.cancellation,
extension_scope,
initiating_plugin_instance_id: binding.initiating_plugin_instance_id,
}),
})
}
async fn start_bound_model_inner(
&self,
request: BoundModelExecutionRequest,
) -> Result<StartedExecution, GatewayError> {
let BoundModelExecutionRequest {
context,
client,
initiating_plugin_instance_id,
cancellation,
extension_scope,
public_model,
operation,
mut metadata,
provider,
account,
} = request;
let authority = &context.authority;
if authority.cancellation.is_cancelled() {
if cancellation.is_cancelled() {
return Err(GatewayError::new(
GatewayErrorKind::Cancelled,
"bound plugin invocation was cancelled",
));
}
let now = SystemTime::now();
let deadline_at = Deadline::from_timeout(now, authority.client.execution_timeout())
if extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
return Err(GatewayError::new(
GatewayErrorKind::PolicyDenied,
"plugin model execution depth exceeded",
));
}
let extension_scope = extension_scope
.extending(initiating_plugin_instance_id.clone())
.ok_or_else(|| {
GatewayError::new(
GatewayErrorKind::PolicyDenied,
"plugin model execution scope is recursive",
)
})?;
let now = SystemTime::now();
let deadline_at =
Deadline::from_timeout(now, client.execution_timeout()).ok_or_else(|| {
GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid")
})?;
let (account_scope, required_provider) = restrict_model_execution_scope(
authority.account_scope.as_ref(),
client.policy.account_scope().as_ref(),
provider,
account.as_ref(),
None,
)?;
// 连接承载多个独立执行;递归预算和副作用只在本次执行的子调用图内共享
// 父连接的取消仍通过 authority 传播,不能因新建执行丢失扩展调用链
// 父调用的取消继续向子执行传播,不能因新建执行丢失扩展调用链
let graph = Arc::new(NestedExecutionGraph::new(Arc::new(
ExecutionEffects::default(),
)));
@@ -1154,12 +1135,12 @@ impl DefaultExecutionService {
let execution_effects_baseline = graph.effects.epoch();
metadata.endpoint = "host.model".to_owned();
metadata.transport = ClientTransport::InternalPlugin;
metadata.inherit_request_origin(&authority.extension_scope);
metadata.inherit_request_origin(&extension_scope);
self.client_api_key_usage
.record_used(authority.client.policy.key_id());
.record_used(client.policy.key_id());
self.start_authorized(
PendingStartExecution {
client: authority.client.clone(),
client,
target: ExecutionTarget::Model(public_model),
operation,
metadata,
@@ -1168,13 +1149,13 @@ impl DefaultExecutionService {
account_scope,
response_control: None,
deadline_at,
cancellation: authority.cancellation.child_token(),
extension_scope: authority.extension_scope.clone(),
cancellation: cancellation.child_token(),
extension_scope,
required_provider,
required_account: account,
nested: None,
bound: Some(BoundExecutionFacts {
initiating_plugin_instance_id: authority.initiating_plugin_instance_id.clone(),
initiating_plugin_instance_id,
}),
graph: Some(graph),
execution_effects_baseline,
@@ -1724,23 +1705,57 @@ impl ExecutionService for DefaultExecutionService {
}
}
impl ClientIdentityPort for DefaultExecutionService {
fn resolve_client(
&self,
client_key_id: ClientApiKeyId,
settings: Option<RequestSettings>,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
Box::pin(async move { self.client_for_key_id(&client_key_id, settings).await })
}
fn verify_client(
&self,
request: ClientAuthenticationRequest,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
Box::pin(async move {
let cancellation = request
.invocation()
.map(|origin| origin.cancellation.clone());
let verification = self.verify_request(request).fuse();
pin_mut!(verification);
match cancellation {
Some(cancellation) => {
let cancelled = cancellation.cancelled().fuse();
pin_mut!(cancelled);
select_biased! {
() = cancelled => Err(GatewayError::new(GatewayErrorKind::Cancelled, "client verification was cancelled")),
result = verification => result.map_err(authentication_gateway_error),
}
}
None => verification.await.map_err(authentication_gateway_error),
}
})
}
}
impl NestedModelExecutionPort for DefaultExecutionService {
fn models(
&self,
context: BoundModelExecutionContext,
client: AuthenticatedClient,
cancellation: CancellationToken,
protocol: String,
client_version: String,
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>> {
Box::pin(async move {
let authority = &context.authority;
let cancellation = authority.cancellation.cancelled().fuse();
let cancelled = cancellation.cancelled().fuse();
let timeout = Delay::new(DIAGNOSTIC_TIMEOUT).fuse();
let catalog = self
.client_model_catalog(&authority.client, &protocol, &client_version)
.client_model_catalog(&client, &protocol, &client_version)
.fuse();
pin_mut!(cancellation, timeout, catalog);
pin_mut!(cancelled, timeout, catalog);
let models = select_biased! {
() = cancellation => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
() = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
() = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "model catalog deadline elapsed")),
result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "model catalog is unavailable"))?,
};
@@ -1754,13 +1769,6 @@ impl NestedModelExecutionPort for DefaultExecutionService {
})
}
fn bind(
&self,
binding: BoundModelExecutionBinding,
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>> {
Box::pin(async move { self.bind_bound_model_inner(binding).await })
}
fn start(
&self,
request: NestedModelExecutionRequest,
@@ -62,6 +62,17 @@ impl ExtensionCallScope {
}
}
/// 宿主签发的父调用来源;身份裁决、执行链和实际资源仍由各自 owner 持有
#[derive(Clone, Debug)]
pub struct InvocationOrigin {
pub request_id: String,
pub call_id: String,
pub cancellation: crate::lifecycle::CancellationToken,
pub extensions: ExtensionCallScope,
pub plan: Option<super::middleware::FrozenMiddlewarePlan>,
pub effects: Option<Arc<super::nested::ExecutionEffects>>,
}
/// 同一代次的具体执行计划;不拥有发布状态,运行资源由对应发布集合保活
#[derive(Debug, Default)]
pub struct ExecutionExtensionPlans {
@@ -13,13 +13,13 @@ use futures::future::BoxFuture;
use crate::{
account::ProviderAccountId, error::GatewayError, identity::ProviderKind,
lifecycle::CancellationToken, operation::Operation, policy::ClientApiKeyId,
provider_ports::ProviderSessionAffinityKey, routing::PublicModelId,
lifecycle::CancellationToken, operation::Operation, provider_ports::ProviderSessionAffinityKey,
routing::PublicModelId,
};
use super::{
ModelRequestId,
execution::{BoundModelExecutionContext, ExecutionRequestMetadata, StartedExecution},
execution::{AuthenticatedClient, ExecutionRequestMetadata, StartedExecution},
};
/// 同一请求图内不可逆外部副作用的单调水位
@@ -59,37 +59,14 @@ pub struct NestedModelExecutionRequest {
pub parent_account: Option<ProviderAccountId>,
}
/// 插件为当前调用或连接显式绑定的模型执行身份
///
/// Key ID 由插件选择,发起实例由宿主确定
/// Core 在绑定时冻结当前
/// Key 策略;每个实际模型请求仍独立经过准入、预算、账本与计费
pub struct BoundModelExecutionBinding {
pub settings: Option<crate::routing::request_settings::RequestSettings>,
pub client_key_id: ClientApiKeyId,
pub initiating_plugin_instance_id: String,
/// 身份随父调用或连接回收;每个模型请求使用独立的执行期限
pub cancellation: CancellationToken,
/// 宿主保留的扩展调用链,异步观察不能通过新建模型请求清空防递归事实
pub extension_scope: super::extensions::ExtensionCallScope,
}
impl fmt::Debug for BoundModelExecutionBinding {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundModelExecutionBinding")
.field("client_key_id", &self.client_key_id)
.field(
"initiating_plugin_instance_id",
&self.initiating_plugin_instance_id,
)
.finish_non_exhaustive()
}
}
/// 显式身份作用域内的一次模型请求
///
/// 身份与权限可先用于查询;执行链只在真正启动模型请求时建立
pub struct BoundModelExecutionRequest {
pub context: BoundModelExecutionContext,
pub client: AuthenticatedClient,
pub initiating_plugin_instance_id: String,
pub cancellation: CancellationToken,
pub extension_scope: super::extensions::ExtensionCallScope,
pub public_model: PublicModelId,
pub operation: Operation,
pub metadata: ExecutionRequestMetadata,
@@ -101,7 +78,11 @@ impl fmt::Debug for BoundModelExecutionRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundModelExecutionRequest")
.field("context", &self.context)
.field("client", &self.client)
.field(
"initiating_plugin_instance_id",
&self.initiating_plugin_instance_id,
)
.field("public_model", &self.public_model)
.field("operation", &self.operation.kind())
.field("provider", &self.provider)
@@ -132,17 +113,12 @@ impl fmt::Debug for NestedModelExecutionRequest {
pub trait NestedModelExecutionPort: Send + Sync {
fn models(
&self,
context: BoundModelExecutionContext,
client: AuthenticatedClient,
cancellation: CancellationToken,
protocol: String,
client_version: String,
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>>;
/// 为插件调用冻结显式执行身份;取消父调用会取消其所有模型执行
fn bind(
&self,
binding: BoundModelExecutionBinding,
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>>;
fn start(
&self,
request: NestedModelExecutionRequest,
+17
View File
@@ -34,6 +34,7 @@ use engine::ExecutionStore;
use engine::admission::{
ClientAdmissionPort, ClientAdmissionRecoveryPort, restore_client_admission_startup,
};
use engine::authentication::ClientIdentityPort;
use engine::continuation::NativeContinuationPort;
use engine::execution::{
ClientApiKeyUsageSink, ClientKeyVerifier, DefaultExecutionService, ExecutionService,
@@ -127,6 +128,7 @@ pub struct CoreBundle {
client_key_verifier: Arc<dyn ClientKeyVerifier>,
snapshot_control: Arc<dyn SnapshotControl>,
account_probe: Arc<dyn AccountProbe>,
client_identity: Arc<dyn ClientIdentityPort>,
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
affinity_lookup: Arc<dyn AffinityLookupPort>,
health_probes: Vec<Arc<dyn HealthProbe>>,
@@ -181,6 +183,7 @@ impl CoreStartup {
let execution: Arc<dyn ExecutionService> = service.clone();
let client_key_verifier: Arc<dyn ClientKeyVerifier> = service.clone();
let account_probe: Arc<dyn AccountProbe> = service.clone();
let client_identity: Arc<dyn ClientIdentityPort> = service.clone();
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
@@ -191,6 +194,7 @@ impl CoreStartup {
client_key_verifier,
snapshot_control,
account_probe,
client_identity,
nested_model_execution,
affinity_lookup,
health_probes,
@@ -266,6 +270,11 @@ impl CoreBundle {
Arc::clone(&self.account_probe)
}
#[must_use]
pub fn client_identity_port(&self) -> Arc<dyn ClientIdentityPort> {
Arc::clone(&self.client_identity)
}
#[must_use]
pub fn nested_model_execution_port(&self) -> Arc<dyn NestedModelExecutionPort> {
Arc::clone(&self.nested_model_execution)
@@ -395,6 +404,7 @@ pub struct CoreCommandPlaneStartup {
pub struct CoreCommandPlaneBundle {
control: CoreControlPlaneBundle,
account_probe: Arc<dyn AccountProbe>,
client_identity: Arc<dyn ClientIdentityPort>,
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
affinity_lookup: Arc<dyn AffinityLookupPort>,
}
@@ -425,11 +435,13 @@ impl CoreCommandPlaneStartup {
.with_snapshot_refresh(Arc::clone(&control.publisher)),
);
let account_probe: Arc<dyn AccountProbe> = service.clone();
let client_identity: Arc<dyn ClientIdentityPort> = service.clone();
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
Ok(CoreCommandPlaneBundle {
control,
account_probe,
client_identity,
nested_model_execution,
affinity_lookup,
})
@@ -452,6 +464,11 @@ impl CoreCommandPlaneBundle {
self.control.snapshot_control()
}
#[must_use]
pub fn client_identity_port(&self) -> Arc<dyn ClientIdentityPort> {
Arc::clone(&self.client_identity)
}
#[must_use]
pub fn nested_model_execution_port(&self) -> Arc<dyn NestedModelExecutionPort> {
Arc::clone(&self.nested_model_execution)
@@ -0,0 +1,268 @@
//! 身份、冻结权限与查询不建立模型执行图;实际执行才检查调用链并占用执行资源
use super::*;
use gateway_core::engine::{
extensions::{ExtensionCallScope, InvocationOrigin},
nested::{BoundModelExecutionRequest, NestedModelExecutionPort},
};
use gateway_core::lifecycle::CancellationToken;
#[test]
fn identity_queries_keep_frozen_permissions_without_using_execution_resources() {
block_on(async {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let store = Arc::new(TrackingExecutionStore::default());
let usage = Arc::new(RecordingClientApiKeyUsage::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
store.clone(),
ProviderRegistry::new([
Arc::new(NativeCatalogProvider { fail: false }) as Arc<dyn Provider>
])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
Arc::new(crate::RecordingDiagnostics::default()),
);
let settings = service.request_settings().unwrap();
let key = ClientApiKeyId::new("key_start_test").unwrap();
let client =
ClientIdentityPort::resolve_client(&service, key.clone(), Some(settings.clone()))
.await
.unwrap();
snapshots.publish(start_snapshot_with_policy(
2,
false,
RateLimits::unlimited(),
FastMode::Disabled,
));
assert!(
client
.policy()
.account_scope()
.allows(&ProviderAccountId::new("acct_start").unwrap(),)
);
assert!(
!client
.policy()
.account_scope()
.allows(&ProviderAccountId::new("acct_missing").unwrap(),)
);
let models = NestedModelExecutionPort::models(
&service,
client.clone(),
CancellationToken::default(),
"codex".into(),
"0.154.0".into(),
)
.await
.unwrap();
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
let frozen = ClientIdentityPort::resolve_client(&service, key.clone(), Some(settings))
.await
.unwrap();
assert_eq!(frozen.snapshot().revision().get(), 1);
let current = ClientIdentityPort::resolve_client(&service, key, None).await;
assert!(matches!(current, Err(error) if error.kind() == GatewayErrorKind::Unauthorized));
let cancellation = CancellationToken::default();
cancellation.cancel();
let cancelled = NestedModelExecutionPort::models(
&service,
client,
cancellation,
"codex".into(),
"0.154.0".into(),
)
.await;
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
assert!(usage.recorded().is_empty());
assert!(!store.touched.load(Ordering::SeqCst));
});
}
#[test]
fn execution_scope_limits_apply_only_when_starting_a_model() {
block_on(async {
let store = Arc::new(TrackingExecutionStore::default());
let usage = Arc::new(RecordingClientApiKeyUsage::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(start_snapshot()),
store.clone(),
ProviderRegistry::new([
Arc::new(NativeCatalogProvider { fail: false }) as Arc<dyn Provider>
])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
Arc::new(crate::RecordingDiagnostics::default()),
);
let repeated = ExtensionCallScope::default()
.extending("entry".into())
.unwrap();
let exhausted = (0..ExtensionCallScope::MAXIMUM_DEPTH)
.fold(ExtensionCallScope::default(), |scope, index| {
scope.extending(format!("ancestor-{index}")).unwrap()
});
for extension_scope in [repeated, exhausted] {
let client = ClientIdentityPort::resolve_client(
&service,
ClientApiKeyId::new("key_start_test").unwrap(),
None,
)
.await
.unwrap();
let result = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
client: client.clone(),
initiating_plugin_instance_id: "entry".into(),
cancellation: CancellationToken::default(),
extension_scope,
public_model: PublicModelId::new("gpt-start").unwrap(),
operation: start_operation(),
metadata: ExecutionRequestMetadata {
protocol: "openai".into(),
endpoint: "host.model".into(),
transport: ClientTransport::InternalPlugin,
stream: false,
client_ip: None,
user_agent: None,
previous_response_id: None,
},
provider: None,
account: None,
},
)
.await;
assert!(matches!(result, Err(error) if error.kind() == GatewayErrorKind::PolicyDenied));
let models = NestedModelExecutionPort::models(
&service,
client,
CancellationToken::default(),
"codex".into(),
"0.154.0".into(),
)
.await
.unwrap();
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
}
assert!(usage.recorded().is_empty());
assert!(!store.touched.load(Ordering::SeqCst));
});
}
struct ParentAwareAuthentication {
calls: AtomicUsize,
mapped: bool,
}
impl FrontendAuthenticationPlan for ParentAwareAuthentication {
fn authenticate<'a>(
&'a self,
request: &'a ClientAuthenticationRequest,
) -> BoxFuture<'a, Result<FrontendAuthenticationDecision, FrontendAuthenticationError>> {
Box::pin(async move {
self.calls.fetch_add(1, Ordering::SeqCst);
let origin = request.invocation().expect("parent invocation");
assert_eq!(origin.request_id, "http-request");
assert_eq!(origin.call_id, "http-call");
assert!(origin.extensions.contains("authentication-plugin"));
assert!(origin.effects.is_some());
assert!(!origin.cancellation.is_cancelled());
assert_eq!(request.request_context().unwrap().uri, "/plugin/proxy");
assert!(request.settings().is_some());
Ok(FrontendAuthenticationDecision::Authenticated {
principal: "external-principal".into(),
})
})
}
fn client_key_id(&self, principal: &str) -> Option<ClientApiKeyId> {
assert_eq!(principal, "external-principal");
self.mapped
.then(|| ClientApiKeyId::new("key_start_test").unwrap())
}
fn exclusive(&self) -> bool {
true
}
}
#[test]
fn verification_keeps_parent_origin_and_requires_the_frontend_principal_mapping() {
block_on(async {
for mapped in [true, false] {
let authentication = FrontendAuthenticationExtensionIndex::default();
let plan = Arc::new(ParentAwareAuthentication {
calls: AtomicUsize::new(0),
mapped,
});
let snapshot = start_snapshot().with_extensions(Some(authentication_generation(
&authentication,
"identity-verification",
plan.clone(),
)));
let store = Arc::new(TrackingExecutionStore::default());
let usage = Arc::new(RecordingClientApiKeyUsage::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
store.clone(),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
Arc::new(crate::RecordingDiagnostics::default()),
)
.with_frontend_authentication(authentication);
let settings = service.request_settings().unwrap();
let origin = InvocationOrigin {
request_id: "http-request".into(),
call_id: "http-call".into(),
cancellation: CancellationToken::default(),
extensions: plugin_origin_scope()
.extending("authentication-plugin".into())
.unwrap(),
plan: None,
effects: Some(Arc::new(
gateway_core::engine::nested::ExecutionEffects::default(),
)),
};
let mut cancelled_origin = origin.clone();
cancelled_origin.cancellation = CancellationToken::default();
cancelled_origin.cancellation.cancel();
let cancelled = ClientIdentityPort::verify_client(
&service,
ClientAuthenticationRequest::new("External fixture")
.unwrap()
.with_invocation(cancelled_origin)
.with_settings(settings.clone()),
)
.await;
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
assert_eq!(plan.calls.load(Ordering::SeqCst), 0);
let result = ClientIdentityPort::verify_client(
&service,
ClientAuthenticationRequest::new("External fixture")
.unwrap()
.with_invocation(origin)
.with_settings(settings.clone()),
)
.await;
if mapped {
let client = result.unwrap();
assert_eq!(client.policy().key_id().as_str(), "key_start_test");
assert_eq!(client.request_settings(), Some(&settings));
} else {
assert!(
matches!(result, Err(error) if error.kind() == GatewayErrorKind::Unauthorized)
);
}
assert_eq!(plan.calls.load(Ordering::SeqCst), 1);
assert!(usage.recorded().is_empty());
assert!(!store.touched.load(Ordering::SeqCst));
}
});
}
@@ -1,5 +1,6 @@
//! 执行服务的准入、预算、请求记录与终结行为测试
mod identity;
mod outbound;
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
@@ -12,6 +13,7 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::authentication::ClientIdentityPort;
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::error::GatewayError;
@@ -1215,9 +1217,7 @@ fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope
#[test]
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
use gateway_core::engine::nested::{
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
};
use gateway_core::engine::nested::{BoundModelExecutionRequest, NestedModelExecutionPort};
block_on(async {
for has_origin in [true, false] {
let store = Arc::new(TrackingExecutionStore::default());
@@ -1234,11 +1234,17 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
Arc::new(crate::RecordingDiagnostics::default()),
);
let input = request(&service, ClientTransport::InternalPlugin);
let context = NestedModelExecutionPort::bind(
let client = ClientIdentityPort::resolve_client(
&service,
BoundModelExecutionBinding {
settings: None,
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
ClientApiKeyId::new("key_start_test").unwrap(),
None,
)
.await
.unwrap();
let mut started = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
client,
initiating_plugin_instance_id: "origin-fixture".into(),
cancellation: Default::default(),
extension_scope: if has_origin {
@@ -1246,14 +1252,6 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
} else {
Default::default()
},
},
)
.await
.unwrap();
let mut started = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
context: context.clone(),
public_model: input.public_model,
operation: input.operation,
metadata: input.metadata,
+1 -1
View File
@@ -29,7 +29,7 @@ percent-encoding = "2.3.2"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
rustls = { version = "=0.23.46", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
rustls-native-certs = "=0.8.3"
scraper = "0.27.0"
scraper = "0.29.0"
semver = "1.0.27"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
@@ -24,7 +24,7 @@ gateway-plugin-sdk = { path = "../sdk", features = ["io"] }
gateway-protocol = { path = "../../gateway-protocol" }
hex = "0.4.3"
image = { version = "0.25.8", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "webp"] }
jsonschema = { version = "0.58.4", default-features = false }
jsonschema = { version = "0.60.0", default-features = false }
roxmltree = "0.21.1"
semver = "1.0.27"
secrecy = "0.10.3"
@@ -1,7 +1,7 @@
{
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [6],
"protocol_versions": [7],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
@@ -195,21 +195,29 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
let mut context =
session.context(Stage::Authentication, session.budget(Stage::Authentication));
context.request = crate::adapter::request_context(request.request_context());
context.request_id = request.invocation().map(|origin| origin.request_id.clone());
let _scope = callbacks
.prepare_frontend_authentication(&context, request.shared_request_context())
.prepare_frontend_authentication(&context, request)
.map_err(|_| FrontendAuthenticationError)?;
let input = FrontendAuthenticationRequest {
authorization: request.authorization().to_owned(),
};
let reply = session
.call(
"frontend_auth.authenticate",
context,
serde_json::json!({}),
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
)
.await
.map_err(|_| FrontendAuthenticationError)?;
let invocation = session.call(
"frontend_auth.authenticate",
context,
serde_json::json!({}),
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
);
let reply = if let Some(origin) = request.invocation() {
tokio::select! {
biased;
() = origin.cancellation.cancelled() => return Err(FrontendAuthenticationError),
reply = invocation => reply,
}
} else {
invocation.await
}
.map_err(|_| FrontendAuthenticationError)?;
let result: FrontendAuthenticationResult =
serde_json::from_slice(&reply.payload).map_err(|_| FrontendAuthenticationError)?;
match result {
@@ -51,7 +51,9 @@ pub(super) async fn invoke(
request_id: context.request_id.clone(),
call_id: context.call_id.clone(),
cancellation: context.cancellation.clone(),
plan,
plan: Some(plan),
extensions: context.extensions.clone(),
effects: None,
}),
context.extensions.clone(),
invocation.clone(),
@@ -38,7 +38,9 @@ pub(super) async fn invoke(
request_id: context.request_id.clone(),
call_id: context.call_id.clone(),
cancellation: context.cancellation.clone(),
plan: context.plan.clone(),
plan: Some(context.plan.clone()),
extensions: context.extensions.clone(),
effects: None,
}),
context.extensions.clone(),
invocation,
@@ -56,7 +56,9 @@ pub(super) async fn invoke(
request_id: context.connection_id.clone(),
call_id: call.resource_scope_id.clone(),
cancellation: context.cancellation.clone(),
plan,
plan: Some(plan),
extensions: context.extensions.clone(),
effects: None,
}),
context.extensions,
invocation.clone(),
@@ -20,8 +20,8 @@ use gateway_core::{
use gateway_plugin_sdk::{
CallContext, ErrorCode, PluginFault,
call::host::{
AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult, AuthRuntimeAccount,
AuthSaveRequest, AuthSaveResult, CredentialFacts,
self as wire, AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult,
AuthRuntimeAccount, AuthSaveRequest, AuthSaveResult, CredentialFacts,
},
};
@@ -66,6 +66,32 @@ impl PluginAccounts {
Self { slot }
}
pub(super) async fn http_proxy(
&self,
account_id: String,
credential_revision: u64,
) -> Result<Option<gateway_core::account::OutboundProxy>, PluginFault> {
let id = ProviderAccountId::new(account_id).map_err(|_| invalid())?;
let expected = Revision::new(credential_revision).map_err(|_| invalid())?;
let account = self
.slot
.upgrade()
.map_err(map_admin_error)?
.get_runtime(&id)
.await
.map_err(map_admin_error)?;
if !account.enabled {
return Err(denied());
}
if account.credential_revision != expected {
return Err(PluginFault::new(
ErrorCode::Conflict,
"account credential revision has changed",
));
}
Ok(account.effective_outbound_proxy().cloned())
}
pub(super) async fn call(
&self,
context: &CallContext,
@@ -79,7 +105,7 @@ impl PluginAccounts {
}
let access = self.slot.upgrade().map_err(map_admin_error)?;
match method {
"host.auth.list" => {
wire::AUTH_LIST => {
let request: AuthListRequest = decode(payload)?;
let limit = PageSize::new(request.limit).map_err(|_| invalid())?;
let cursor = request
@@ -105,7 +131,7 @@ impl PluginAccounts {
next_cursor: page.next_cursor.map(|cursor| cursor.as_str().to_owned()),
})
}
"host.auth.get_runtime" => {
wire::AUTH_GET_RUNTIME => {
let request: AuthGetRequest = decode(payload)?;
let account_id =
ProviderAccountId::new(request.account_id).map_err(|_| invalid())?;
@@ -115,7 +141,7 @@ impl PluginAccounts {
.map_err(map_admin_error)?;
encode(&runtime_account(&account))
}
"host.auth.get" => {
wire::AUTH_GET => {
let request: AuthGetRequest = decode(payload)?;
let account_id =
ProviderAccountId::new(request.account_id).map_err(|_| invalid())?;
@@ -131,7 +157,7 @@ impl PluginAccounts {
facts: credential_facts(account, credential.provider_material),
})
}
"host.auth.save" => {
wire::AUTH_SAVE => {
let request: AuthSaveRequest = decode(payload)?;
encode(&self.save(context, scope, request).await?)
}
@@ -16,9 +16,8 @@ pub(super) struct CallResources {
pub(super) cancellation: CancellationToken,
pub(super) scope: Arc<CallbackScope>,
pub(super) http_resources: Arc<http_middleware::resources::Resources>,
pub(super) model_bindings: tokio::sync::Mutex<
BTreeMap<String, gateway_core::engine::execution::BoundModelExecutionContext>,
>,
pub(super) client_identities:
tokio::sync::Mutex<BTreeMap<String, gateway_core::engine::execution::AuthenticatedClient>>,
pub(super) state: Mutex<CallState>,
}
@@ -32,13 +31,18 @@ impl CallResources {
Self {
deadline: deadline.clone(),
operation_timeout: Duration::from_millis(context.timeout_ms),
cancellation: CancellationToken::new(),
cancellation: scope
.origin
.as_ref()
.map_or_else(CancellationToken::new, |origin| {
origin.cancellation.child_token()
}),
scope,
http_resources: middleware
.as_ref()
.and_then(|middleware| middleware.http_resources())
.unwrap_or_default(),
model_bindings: tokio::sync::Mutex::new(BTreeMap::new()),
client_identities: tokio::sync::Mutex::new(BTreeMap::new()),
state: Mutex::new(CallState {
middleware,
..CallState::default()
@@ -46,6 +50,40 @@ impl CallResources {
}
}
/// 子调用共享受信来源;身份解析与执行准入仍留在各能力 owner
pub(super) fn child_origin(
&self,
context: &CallContext,
) -> Result<gateway_core::engine::extensions::InvocationOrigin, PluginFault> {
let parent = self.scope.origin.as_ref();
Ok(gateway_core::engine::extensions::InvocationOrigin {
request_id: parent.map_or_else(
|| {
context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone())
},
|origin| origin.request_id.clone(),
),
call_id: parent.map_or_else(
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|origin| origin.call_id.clone(),
),
cancellation: self.cancellation.child_token(),
extensions: if self.scope.extension_scope.contains(&context.instance_id) {
self.scope.extension_scope.clone()
} else {
self.scope
.extension_scope
.extending(context.instance_id.clone())
.ok_or_else(super::invalid)?
},
plan: parent.and_then(|origin| origin.plan.clone()),
effects: self.scope.execution_effects.clone(),
})
}
pub(super) fn close(&self) {
self.cancellation.cancel();
let mut state = self
@@ -114,7 +152,9 @@ impl CallResources {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.middleware
.clone();
middleware.and_then(|middleware| middleware.request_settings())
middleware
.and_then(|middleware| middleware.request_settings())
.or_else(|| self.scope.settings.clone())
}
/// 建立受管流后,网络操作各自计时;连接空闲时间不消耗后续操作的预算
@@ -0,0 +1,154 @@
//! 客户端认证、冻结权限查询与调用内身份复用
use std::sync::{Arc, OnceLock, Weak};
use gateway_admin::model::AdminError;
use gateway_core::{
account::ProviderAccountId,
engine::{
authentication::{ClientAuthenticationRequest, ClientIdentityPort},
execution::AuthenticatedClient,
},
policy::ClientApiKeyId,
routing::UpstreamModelId,
};
use gateway_plugin_sdk::{CallContext, ErrorCode, PluginFault, call::data};
use super::{CallResources, denied, invalid};
use crate::RpcReply;
const MAX_CLIENT_IDENTITIES_PER_CALL: usize = 16;
pub(crate) struct PluginClientIdentityPortSlot {
port: OnceLock<Weak<dyn ClientIdentityPort>>,
}
impl PluginClientIdentityPortSlot {
pub(crate) const fn new() -> Self {
Self {
port: OnceLock::new(),
}
}
pub(crate) fn bind(&self, port: &Arc<dyn ClientIdentityPort>) -> Result<(), AdminError> {
self.port
.set(Arc::downgrade(port))
.map_err(|_| AdminError::conflict("插件身份端口已经绑定"))
}
fn upgrade(&self) -> Result<Arc<dyn ClientIdentityPort>, PluginFault> {
self.port.get().and_then(Weak::upgrade).ok_or_else(denied)
}
}
pub(super) struct PluginClients {
slot: Arc<PluginClientIdentityPortSlot>,
}
impl PluginClients {
pub(super) fn new(slot: Arc<PluginClientIdentityPortSlot>) -> Self {
Self { slot }
}
pub(super) async fn call(
&self,
context: &CallContext,
call: &Arc<CallResources>,
method: &str,
params: serde_json::Value,
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
match method {
data::CLIENT_VERIFY => {
if params != serde_json::json!({}) {
return Err(invalid());
}
let request: data::VerifyClientRequest =
serde_json::from_slice(&payload).map_err(|_| invalid())?;
let mut authentication = ClientAuthenticationRequest::new(request.authorization)
.map_err(|_| invalid())?
.with_invocation(call.child_origin(context)?);
if let Some(settings) = call.request_settings() {
authentication = authentication.with_settings(settings);
}
let port = self.slot.upgrade()?;
call.observe_dispatch()?;
let client = port
.verify_client(authentication)
.await
.map_err(super::error::gateway)?;
super::admin::encode(&data::VerifiedClient {
client_key_id: client.policy().key_id().as_str().to_owned(),
})
}
data::ACCESS_CHECK => {
if params != serde_json::json!({}) {
return Err(invalid());
}
let request: data::AccessQuery =
serde_json::from_slice(&payload).map_err(|_| invalid())?;
let client = self.identity(call, request.client_key_id).await?;
let scope = client.policy().account_scope();
let model = request
.upstream_model
.map(UpstreamModelId::new)
.transpose()
.map_err(|_| invalid())?;
let accounts = request
.account_ids
.into_iter()
.map(|account_id| {
let account =
ProviderAccountId::new(account_id.clone()).map_err(|_| invalid())?;
let allowed = model.as_ref().map_or_else(
|| scope.allows(&account),
|model| scope.allows_model(&account, model.as_str()),
);
Ok(data::AccountAccessFacts {
account_id,
allowed,
})
})
.collect::<Result<Vec<_>, PluginFault>>()?;
super::admin::encode(&data::AccessFacts {
schema_version: 1,
client_key_id: client.policy().key_id().as_str().to_owned(),
accounts,
})
}
_ => Err(denied()),
}
}
pub(super) async fn identity(
&self,
call: &CallResources,
client_key_id: String,
) -> Result<AuthenticatedClient, PluginFault> {
let port = self.slot.upgrade()?;
let key = ClientApiKeyId::new(client_key_id.clone()).map_err(|_| invalid())?;
let settings = call.request_settings();
let mut identities = call.client_identities.lock().await;
if let Some(client) = identities
.get(&client_key_id)
.filter(|client| client.request_settings() == settings.as_ref())
{
return Ok(client.clone());
}
if !identities.contains_key(&client_key_id)
&& identities.len() >= MAX_CLIENT_IDENTITIES_PER_CALL
{
return Err(PluginFault::new(
ErrorCode::Capacity,
"client identity capacity is exhausted",
));
}
let client = port
.resolve_client(key, settings)
.await
.map_err(super::error::gateway)?;
// 查询与执行共用调用内快照,执行链和子执行取消只在真正启动时建立
identities.insert(client_key_id, client.clone());
Ok(client)
}
}
@@ -98,7 +98,7 @@ impl PluginData {
next_cursor: page.next_cursor.map(|id| id.as_str().to_owned()),
})
}
data::QUOTA_GET | data::QUOTA_REFRESH => {
data::QUOTA_GET | data::QUOTA_REFRESH | data::QUOTA_DETAILS => {
let accounts = self.accounts.upgrade().map_err(map_admin_error)?;
let query: data::QuotaFactsQuery =
serde_json::from_slice(payload).map_err(|_| invalid())?;
@@ -109,6 +109,35 @@ impl PluginData {
accounts.get_quota(&account_id).await
}
.map_err(map_admin_error)?;
if method == data::QUOTA_DETAILS {
return super::admin::encode(&data::QuotaDetails {
schema_version: 1,
account_id: account_id.as_str().to_owned(),
observed_at_ms: quota.observed_at.map(|time| time.timestamp_millis()),
limit_reached: quota.limit_reached,
windows: quota
.windows
.into_iter()
.map(|window| data::QuotaWindowDetails {
key: window.key,
group: window.group,
label: window.label,
limit_id: window.limit_id,
limit_name: window.limit_name,
role: window.role.map(|role| role.as_str().to_owned()),
window_seconds: window.window_seconds,
used_percent: window.used_percent,
reset_at_ms: window.reset_at.map(|time| time.timestamp_millis()),
limit_reached: window.limit_reached,
})
.collect(),
credits: quota.credits.map(|credits| data::QuotaCredits {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance,
}),
});
}
serde_json::to_vec(&data::QuotaFacts {
schema_version: 1,
account_id: account_id.as_str().to_owned(),
@@ -0,0 +1,245 @@
//! 将有效父调用的宿主回调分派到所属原生能力,并随调用撤销资源
use std::sync::{Arc, Weak};
use futures::future::BoxFuture;
use gateway_plugin_sdk::{
CallContext, ErrorCode, PluginFault,
call::{host, middleware as middleware_wire, upstream_adapter as upstream_wire},
client::CallDeadline,
};
use super::{CallResources, CallbackScope, PluginCallbacks, denied, http, http_dispatch, upstream};
use crate::{CallbackHandler, RpcReply};
impl CallbackHandler for PluginCallbacks {
fn begin(&self, context: &CallContext, deadline: &CallDeadline) {
let scope = self
.scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&context.resource_scope_id)
.and_then(Weak::upgrade)
.unwrap_or_else(|| {
let mut scope = CallbackScope::for_call(context);
if context.stage == gateway_plugin_sdk::Stage::Registration {
scope.request_body_limit = self.registration_body_limit;
}
Arc::new(scope)
});
let middleware = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&context.resource_scope_id);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
context.call_id,
Arc::new(CallResources::new(context, deadline, scope, middleware)),
);
}
fn finish(&self, context: &CallContext) {
if let Some(call) = self
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&context.call_id)
{
call.close();
}
}
fn call(
&self,
context: CallContext,
method: String,
params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
let call = self
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&context.call_id)
.cloned();
let http = self.http.clone();
let network = self.network.clone();
let maximum_payload = self.maximum_payload;
let log = self.log.clone();
let private_state = self.private_state.clone();
let accounts = self.accounts.clone();
let resources = self.resources.clone();
let data = self.data.clone();
let keys = self.keys.clone();
let clients = self.clients.clone();
let models = self.models.clone();
let request_limits = self.request_limits.clone();
let affinity = self.affinity.clone();
let services = self.services.clone();
let service_actor = self.service_actor.clone();
Box::pin(async move {
let call = call.ok_or_else(denied)?;
let scope = &call.scope;
let (middleware, is_private_state) = {
let state = call
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.closed || call.deadline.is_elapsed() {
return Err(denied());
}
if method == host::LOG {
return log.record(&context, params, &payload);
}
let middleware = if matches!(
method.as_str(),
middleware_wire::NEXT_METHOD
| middleware_wire::BODY_READ_METHOD
| middleware_wire::BODY_FACTS_METHOD
| middleware_wire::BODY_CLOSE_METHOD
| middleware_wire::http::UPGRADE_METHOD
| middleware_wire::http::RECEIVE_METHOD
| middleware_wire::websocket::SEND_METHOD
) {
Some(state.middleware.clone().ok_or_else(denied)?)
} else {
None
};
let is_private_state = matches!(
method.as_str(),
host::STATE_GET | host::STATE_PUT | host::STATE_DELETE
);
(middleware, is_private_state)
};
if matches!(
method.as_str(),
middleware_wire::http::DISPATCH_METHOD
| middleware_wire::http::BODY_READ_METHOD
| middleware_wire::http::BODY_CLOSE_METHOD
| middleware_wire::http::BODY_CREATE_METHOD
| middleware_wire::http::BODY_WRITE_METHOD
) {
return tokio::select! {
biased;
() = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")),
result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result,
};
}
if let Some(middleware) = middleware {
return middleware
.invoke(method, params, payload, maximum_payload)
.await;
}
if method == gateway_plugin_sdk::call::services::CALL_METHOD {
return services
.call(&context, &call, service_actor, params, &payload)
.await;
}
if is_private_state {
return private_state.call(&method, params, &payload).await;
}
if matches!(
method.as_str(),
gateway_plugin_sdk::call::resources::GROUP_ENSURE
| gateway_plugin_sdk::call::resources::GROUP_MEMBERS
| gateway_plugin_sdk::call::resources::KEY_ENSURE
) {
return resources.call(&context, &method, params, &payload).await;
}
if matches!(
method.as_str(),
host::KEYS_LIST
| gateway_plugin_sdk::call::key_budgets::RESET
| gateway_plugin_sdk::call::key_budgets::GET
| gateway_plugin_sdk::call::key_budgets::UPDATE_LIMITS
) {
return keys.call(&context, &method, params, &payload).await;
}
if matches!(
method.as_str(),
gateway_plugin_sdk::call::data::CLIENT_VERIFY
| gateway_plugin_sdk::call::data::ACCESS_CHECK
) {
return clients
.call(&context, &call, &method, params, payload)
.await;
}
if method == host::MODELS_LIST {
return models.call(&context, &call, &method, params, payload).await;
}
if matches!(
method.as_str(),
host::MODEL_EXECUTE
| host::MODEL_EXECUTE_STREAM
| host::MODEL_STREAM_READ
| host::MODEL_STREAM_CLOSE
) {
return models.call(&context, &call, &method, params, payload).await;
}
if method == host::AFFINITY_LOOKUP {
return affinity.call(&context, params, &payload).await;
}
let scope = Some(scope)
.filter(|scope| scope.authorizes(&context))
.ok_or_else(denied)?;
if matches!(
method.as_str(),
gateway_plugin_sdk::call::data::ACCOUNTS_LIST
| gateway_plugin_sdk::call::data::KEYS_GET
| gateway_plugin_sdk::call::data::QUOTA_GET
| gateway_plugin_sdk::call::data::QUOTA_REFRESH
| gateway_plugin_sdk::call::data::QUOTA_DETAILS
) {
return data.call(&method, params, &payload).await;
}
if matches!(
method.as_str(),
host::AUTH_LIST | host::AUTH_GET | host::AUTH_GET_RUNTIME | host::AUTH_SAVE
) {
return accounts
.call(&context, scope, &method, params, &payload)
.await;
}
let http = http::HttpCallbacks {
client: &http,
network: &network,
scope,
call: &call,
maximum_payload,
request_limits: &request_limits,
};
if method == host::ACCOUNT_HTTP {
return http.account(&accounts, params, payload).await;
}
if matches!(
method.as_str(),
upstream_wire::HTTP_DO
| upstream_wire::HTTP_DO_STREAM
| upstream_wire::HTTP_STREAM_READ
| upstream_wire::HTTP_STREAM_CLOSE
| upstream_wire::WEBSOCKET_OPEN
| upstream_wire::WEBSOCKET_SEND
| upstream_wire::WEBSOCKET_READ
| upstream_wire::WEBSOCKET_CLOSE
) {
return upstream::dispatch(&http, &method, params, payload).await;
}
if matches!(
method.as_str(),
host::HTTP_DO
| host::HTTP_DO_STREAM
| host::HTTP_STREAM_READ
| host::HTTP_STREAM_CLOSE
) {
return http.dispatch(&method, params, payload).await;
}
Err(PluginFault::new(
ErrorCode::Unsupported,
"host callback method is not supported",
))
})
}
}
@@ -29,16 +29,42 @@ impl HttpCallbacks<'_> {
self.request_limits.body_bytes(self.call)
}
pub(super) async fn account(
&self,
accounts: &super::accounts::PluginAccounts,
params: serde_json::Value,
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
let request: wire::AccountHttpRequest = decode(params)?;
let maximum_body_bytes = self.body_limit(&payload)?;
let proxy = accounts
.http_proxy(request.account_id, request.credential_revision)
.await?;
self.send(
HttpRequest {
method: request.request.method,
url: request.request.url,
headers: request.request.headers,
body: payload,
maximum_body_bytes,
},
true,
None,
proxy.as_ref(),
)
.await
}
pub(super) async fn send(
&self,
request: HttpRequest,
streaming: bool,
upstream_purpose: Option<gateway_plugin_sdk::call::upstream_adapter::UpstreamPathPurpose>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<RpcReply, PluginFault> {
let Self {
client,
network,
scope,
call,
maximum_payload,
..
@@ -48,9 +74,7 @@ impl HttpCallbacks<'_> {
}
let timeout = call.timeout()?;
let attempt = call.start_upstream(upstream_purpose)?;
let response = client
.open(request, scope.proxy.as_ref(), network, timeout)
.await;
let response = client.open(request, proxy, network, timeout).await;
attempt.finish(
response
.as_ref()
@@ -115,7 +139,7 @@ impl HttpCallbacks<'_> {
) -> Result<RpcReply, PluginFault> {
let call = self.call;
match method {
"host.http.do" | "host.http.do_stream" => {
wire::HTTP_DO | wire::HTTP_DO_STREAM => {
let request: wire::HttpRequest = decode(params)?;
self.send(
HttpRequest {
@@ -125,12 +149,13 @@ impl HttpCallbacks<'_> {
maximum_body_bytes: self.body_limit(&payload)?,
body: payload,
},
method == "host.http.do_stream",
method == wire::HTTP_DO_STREAM,
None,
self.scope.proxy.as_ref(),
)
.await
}
"host.http.stream_read" => {
wire::HTTP_STREAM_READ => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -174,7 +199,7 @@ impl HttpCallbacks<'_> {
payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()),
})
}
"host.http.stream_close" => {
wire::HTTP_STREAM_CLOSE => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -36,25 +36,23 @@ pub(super) async fn call(
request.timeout_ms,
&context.instance_id,
)?;
let parent = call.scope.origin.as_ref();
call.scope.check_child_depth(&context.instance_id)?;
let origin = call.child_origin(context)?;
let child = core::Context {
plugin_instance_id: Some(context.instance_id.clone()),
request_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
request_id: origin.request_id,
call_id: uuid::Uuid::now_v7().to_string(),
parent_call_id: Some(parent.map_or_else(
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|parent| parent.call_id.clone(),
)),
extensions: call.scope.child_extensions(&context.instance_id)?,
// 句柄可以在父 RPC End 后交给 HTTP 发送端;生命周期属于父调用和响应正文,不能在 RPC End 提前取消
cancellation: parent.map_or_else(
|| call.cancellation.child_token(),
|parent| parent.cancellation.child_token(),
),
plan: parent.map(|parent| parent.plan.clone()),
parent_call_id: Some(origin.call_id),
extensions: origin.extensions,
// 转交正文后由消费端持有原请求寿命,父 RPC End 只撤销新业务分派
cancellation: call
.scope
.origin
.as_ref()
.map_or(origin.cancellation, |parent| {
parent.cancellation.child_token()
}),
plan: origin.plan,
};
let body = call.http_resources.body(request.body, payload).await?;
let mut http = core::Request::new(body);
@@ -0,0 +1,243 @@
//! 为具体入口登记回调作用域和一次性中间件绑定,不持有 RPC 调度状态
use std::sync::{Arc, Weak};
use gateway_core::{account::OutboundProxy, lifecycle::CancellationToken};
use gateway_plugin_sdk::{CallContext, PluginFault};
use super::{
CallbackScope, InvocationContext, MiddlewareBinding, MiddlewareCallback, PluginCallbacks,
denied, upstream,
};
impl PluginCallbacks {
pub(crate) fn bind_middleware(
self: &Arc<Self>,
resource_scope_id: String,
invocation: Arc<dyn MiddlewareCallback>,
scope: Arc<CallbackScope>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
let mut pending = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if pending
.insert(resource_scope_id.clone(), Arc::clone(&invocation))
.is_some()
{
return Err(gateway_core::engine::middleware::MiddlewareError::InvalidState);
}
Ok(MiddlewareBinding::new(
Arc::clone(self),
resource_scope_id,
invocation,
scope,
))
}
pub(super) fn unbind_middleware(
&self,
resource_scope_id: &str,
invocation: &Arc<dyn MiddlewareCallback>,
) {
let mut pending = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if pending
.get(resource_scope_id)
.is_some_and(|pending| Arc::ptr_eq(pending, invocation))
{
pending.remove(resource_scope_id);
}
}
pub(crate) fn prepare_data_plane(
&self,
context: &CallContext,
effects: Option<Arc<gateway_core::engine::nested::ExecutionEffects>>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
cancellation: CancellationToken,
request_body_limit: Option<usize>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if !matches!(
context.stage,
gateway_plugin_sdk::Stage::Request
| gateway_plugin_sdk::Stage::Attempt
| gateway_plugin_sdk::Stage::Outbound
| gateway_plugin_sdk::Stage::Routing
| gateway_plugin_sdk::Stage::Scheduling
| gateway_plugin_sdk::Stage::Retry
) {
return Err(denied());
}
let mut scope = CallbackScope::for_call(context);
if let Some(effects) = effects {
scope = scope.with_execution_effects(effects);
}
scope.extension_scope = extensions;
scope.request_body_limit = request_body_limit;
scope.origin = plan.map(|plan| InvocationContext {
request_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
call_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
cancellation,
extensions: scope.extension_scope.clone(),
effects: scope.execution_effects.clone(),
plan: Some(plan),
});
self.register_scope(context, scope)
}
pub(crate) fn request_body_limit(&self) -> Result<usize, PluginFault> {
self.request_limits.current_body_bytes()
}
pub(crate) fn prepare_upstream(
&self,
context: &CallContext,
managed: Arc<upstream::ManagedUpstream>,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
request_body_limit: usize,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::Upstream
|| context.account_id.as_deref() != Some(managed.account.account_id().as_str())
|| context.credential_revision != Some(managed.account.credential_revision().get())
{
return Err(denied());
}
let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned())
.with_upstream(managed);
scope.extension_scope = extension_scope;
scope.request_body_limit = Some(request_body_limit);
self.register_scope(context, scope)
}
pub(crate) fn prepare_management(
&self,
context: &CallContext,
proxy: Option<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if !matches!(
context.stage,
gateway_plugin_sdk::Stage::Management | gateway_plugin_sdk::Stage::PublicManagement
) {
return Err(denied());
}
self.prepare_scope(context, proxy, request)
}
pub(crate) fn prepare_command_line(
&self,
context: &CallContext,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::CommandLine {
return Err(denied());
}
self.prepare_scope(context, None, None)
}
pub(crate) fn prepare_frontend_authentication(
&self,
context: &CallContext,
request: &gateway_core::engine::authentication::ClientAuthenticationRequest,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::Authentication
|| context.account_id.is_some()
|| context.credential_revision.is_some()
|| context.attempt_id.is_some()
{
return Err(denied());
}
let mut scope = CallbackScope::for_call(context);
scope.settings = request.settings().cloned();
if let Some(origin) = request.invocation() {
scope.extension_scope = origin.extensions.clone();
scope.execution_effects = origin.effects.clone();
scope.origin = Some(origin.clone());
} else if let Some(request) = request.shared_request_context() {
scope.extension_scope = scope.extension_scope.with_request(request);
}
self.register_scope(context, scope)
}
pub(crate) async fn save_command_account(
&self,
context: &CallContext,
scope: &CallbackScope,
request: gateway_plugin_sdk::call::host::AuthSaveRequest,
) -> Result<gateway_plugin_sdk::call::host::AuthSaveResult, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) {
return Err(denied());
}
self.accounts.save(context, scope, request).await
}
fn prepare_scope(
&self,
context: &CallContext,
proxy: Option<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
let mut scope = CallbackScope::new(context, proxy);
if let Some(request) = request {
scope.extension_scope = scope.extension_scope.with_request(request);
}
self.register_scope(context, scope)
}
pub(crate) fn prepare_extensions(
&self,
context: &CallContext,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
) -> Result<Arc<CallbackScope>, PluginFault> {
let mut scope = CallbackScope::for_call(context);
scope.extension_scope = extension_scope;
self.register_scope(context, scope)
}
pub(crate) fn bind_invocation(
self: &Arc<Self>,
call: &CallContext,
origin: Option<InvocationContext>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
invocation: Arc<dyn MiddlewareCallback>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
let mut scope = CallbackScope::for_call(call);
scope.extension_scope = extensions;
scope.origin = origin;
let scope = self
.register_scope(call, scope)
.map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?;
self.bind_middleware(call.resource_scope_id.clone(), invocation, scope)
}
fn register_scope(
&self,
context: &CallContext,
scope: CallbackScope,
) -> Result<Arc<CallbackScope>, PluginFault> {
let scope = Arc::new(scope);
let mut scopes = self
.scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
scopes.retain(|_, scope| scope.strong_count() != 0);
if scopes
.get(&context.resource_scope_id)
.and_then(Weak::upgrade)
.is_some()
{
return Err(denied());
}
scopes.insert(context.resource_scope_id.clone(), Arc::downgrade(&scope));
Ok(scope)
}
}
@@ -52,7 +52,7 @@ impl PluginClientKeys {
params: serde_json::Value,
payload: &[u8],
) -> Result<RpcReply, PluginFault> {
if method == "host.keys.list" {
if method == gateway_plugin_sdk::call::host::KEYS_LIST {
return self.ports.list(params, payload).await;
}
if params != serde_json::json!({}) {
@@ -1,20 +1,24 @@
//! 插件宿主回调端口的组合、授权检查与方法分派
//! 插件宿主回调端口、调用作用域与分派的模块入口
mod accounts;
mod admin;
mod affinity;
mod call_resources;
mod client;
mod data;
mod dispatch;
pub(crate) mod error;
mod facts;
mod http;
mod http_dispatch;
pub(crate) mod http_middleware;
mod invocation;
mod keys;
mod log;
mod middleware;
mod model;
pub(crate) mod outbound;
mod ports;
pub(crate) mod private_state;
pub(crate) mod request_limits;
mod resources;
@@ -23,21 +27,11 @@ pub(crate) mod services;
pub(crate) mod upstream;
pub(crate) mod websocket_middleware;
use std::{
collections::BTreeMap,
sync::{Arc, Mutex, Weak},
};
use futures::future::BoxFuture;
use gateway_admin::model::AdminError;
use gateway_core::{account::OutboundProxy, lifecycle::CancellationToken};
use gateway_host::outbound::{HttpClient, NetworkPolicy};
use gateway_plugin_sdk::{CallContext, ErrorCode, PluginFault, client::CallDeadline};
use crate::{CallbackHandler, RpcReply};
pub(crate) use accounts::PluginAccountPortSlot;
pub(crate) use affinity::PluginAffinityPortSlot;
use call_resources::{CallResources, HttpStream};
pub(crate) use client::PluginClientIdentityPortSlot;
use gateway_plugin_sdk::{ErrorCode, PluginFault};
pub(crate) use keys::PluginClientKeyPortSlot;
pub(crate) use middleware::{
MiddlewareBinding, MiddlewareBodyAuthority, MiddlewareCallback, MiddlewareCompletionBody,
@@ -47,509 +41,7 @@ pub(crate) use model::PluginModelPortSlot;
pub(crate) use resources::PluginResourcePorts;
pub(crate) use scope::{CallbackScope, InvocationContext};
pub(crate) struct PluginCallbackPorts {
services: Arc<services::ServicePorts>,
http: Arc<HttpClient>,
network: NetworkPolicy,
accounts: Arc<PluginAccountPortSlot>,
keys: Arc<PluginClientKeyPortSlot>,
models: Arc<PluginModelPortSlot>,
affinity: Arc<PluginAffinityPortSlot>,
resources: Arc<PluginResourcePorts>,
}
impl PluginCallbackPorts {
pub(crate) fn new(
services: Arc<services::ServicePorts>,
http: Arc<HttpClient>,
accounts: Arc<PluginAccountPortSlot>,
keys: Arc<PluginClientKeyPortSlot>,
models: Arc<PluginModelPortSlot>,
affinity: Arc<PluginAffinityPortSlot>,
resources: Arc<PluginResourcePorts>,
) -> Self {
Self {
services,
http,
network: NetworkPolicy::unrestricted(),
accounts,
keys,
models,
affinity,
resources,
}
}
}
pub(crate) struct PluginCallbacks {
pub(crate) registration_body_limit: Option<usize>,
services: Arc<services::ServicePorts>,
service_actor: gateway_admin::model::usage_data::UsageActor,
accounts: Arc<accounts::PluginAccounts>,
resources: Arc<resources::PluginResources>,
data: Arc<data::PluginData>,
keys: Arc<keys::PluginClientKeys>,
affinity: Arc<affinity::PluginAffinity>,
log: Arc<log::PluginLog>,
models: Arc<model::PluginModels>,
private_state: Arc<private_state::PluginPrivateState>,
http: Arc<HttpClient>,
network: Arc<NetworkPolicy>,
scopes: Mutex<BTreeMap<String, Weak<CallbackScope>>>,
pending_middleware: Mutex<BTreeMap<String, Arc<dyn MiddlewareCallback>>>,
calls: Mutex<BTreeMap<u64, Arc<CallResources>>>,
maximum_payload: usize,
request_limits: Arc<request_limits::RequestLimits>,
}
impl PluginCallbacks {
pub(crate) fn new(
instance: &gateway_admin::model::plugins::instances::PluginInstance,
maximum_payload: usize,
manifest: &gateway_plugin_sdk::Manifest,
log_slots: Arc<tokio::sync::Semaphore>,
private_state: Arc<private_state::PluginPrivateState>,
ports: PluginCallbackPorts,
request_limits: Arc<request_limits::RequestLimits>,
) -> Result<Self, AdminError> {
Ok(Self {
registration_body_limit: None,
services: ports.services,
service_actor: gateway_admin::model::usage_data::UsageActor::Plugin {
publisher: manifest.publisher.clone(),
name: manifest.name.clone(),
instance_id: instance.id.clone(),
},
resources: Arc::new(resources::PluginResources::new(instance, ports.resources)),
data: Arc::new(data::PluginData::new(
ports.accounts.clone(),
ports.keys.clone(),
)),
accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)),
keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)),
affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)),
log: Arc::new(
log::PluginLog::new(manifest, log_slots)
.map_err(|_| AdminError::invalid("插件清单身份不合法"))?,
),
models: Arc::new(model::PluginModels::new(
ports.models,
request_limits.clone(),
maximum_payload,
)),
private_state,
http: ports.http,
network: Arc::new(ports.network),
scopes: Mutex::new(BTreeMap::new()),
pending_middleware: Mutex::new(BTreeMap::new()),
calls: Mutex::new(BTreeMap::new()),
maximum_payload,
request_limits,
})
}
pub(crate) fn bind_middleware(
self: &Arc<Self>,
resource_scope_id: String,
invocation: Arc<dyn MiddlewareCallback>,
scope: Arc<CallbackScope>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
let mut pending = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if pending
.insert(resource_scope_id.clone(), Arc::clone(&invocation))
.is_some()
{
return Err(gateway_core::engine::middleware::MiddlewareError::InvalidState);
}
Ok(MiddlewareBinding::new(
Arc::clone(self),
resource_scope_id,
invocation,
scope,
))
}
fn unbind_middleware(&self, resource_scope_id: &str, invocation: &Arc<dyn MiddlewareCallback>) {
let mut pending = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if pending
.get(resource_scope_id)
.is_some_and(|pending| Arc::ptr_eq(pending, invocation))
{
pending.remove(resource_scope_id);
}
}
pub(crate) fn prepare_data_plane(
&self,
context: &CallContext,
effects: Option<Arc<gateway_core::engine::nested::ExecutionEffects>>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
cancellation: CancellationToken,
request_body_limit: Option<usize>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if !matches!(
context.stage,
gateway_plugin_sdk::Stage::Request
| gateway_plugin_sdk::Stage::Attempt
| gateway_plugin_sdk::Stage::Outbound
| gateway_plugin_sdk::Stage::Routing
| gateway_plugin_sdk::Stage::Scheduling
| gateway_plugin_sdk::Stage::Retry
) {
return Err(denied());
}
let mut scope = CallbackScope::for_call(context);
if let Some(effects) = effects {
scope = scope.with_execution_effects(effects);
}
scope.extension_scope = extensions;
scope.request_body_limit = request_body_limit;
scope.origin = plan.map(|plan| InvocationContext {
request_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
call_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
cancellation,
plan,
});
self.register_scope(context, scope)
}
pub(crate) fn request_body_limit(&self) -> Result<usize, PluginFault> {
self.request_limits.current_body_bytes()
}
pub(crate) fn prepare_upstream(
&self,
context: &CallContext,
managed: Arc<upstream::ManagedUpstream>,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
request_body_limit: usize,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::Upstream
|| context.account_id.as_deref() != Some(managed.account.account_id().as_str())
|| context.credential_revision != Some(managed.account.credential_revision().get())
{
return Err(denied());
}
let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned())
.with_upstream(managed);
scope.extension_scope = extension_scope;
scope.request_body_limit = Some(request_body_limit);
self.register_scope(context, scope)
}
pub(crate) fn prepare_management(
&self,
context: &CallContext,
proxy: Option<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if !matches!(
context.stage,
gateway_plugin_sdk::Stage::Management | gateway_plugin_sdk::Stage::PublicManagement
) {
return Err(denied());
}
self.prepare_scope(context, proxy, request)
}
pub(crate) fn prepare_command_line(
&self,
context: &CallContext,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::CommandLine {
return Err(denied());
}
self.prepare_scope(context, None, None)
}
pub(crate) fn prepare_frontend_authentication(
&self,
context: &CallContext,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::Authentication
|| context.account_id.is_some()
|| context.credential_revision.is_some()
|| context.attempt_id.is_some()
{
return Err(denied());
}
self.prepare_scope(context, None, request)
}
pub(crate) async fn save_command_account(
&self,
context: &CallContext,
scope: &CallbackScope,
request: gateway_plugin_sdk::call::host::AuthSaveRequest,
) -> Result<gateway_plugin_sdk::call::host::AuthSaveResult, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) {
return Err(denied());
}
self.accounts.save(context, scope, request).await
}
fn prepare_scope(
&self,
context: &CallContext,
proxy: Option<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
let mut scope = CallbackScope::new(context, proxy);
if let Some(request) = request {
scope.extension_scope = scope.extension_scope.with_request(request);
}
self.register_scope(context, scope)
}
pub(crate) fn prepare_extensions(
&self,
context: &CallContext,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
) -> Result<Arc<CallbackScope>, PluginFault> {
let mut scope = CallbackScope::for_call(context);
scope.extension_scope = extension_scope;
self.register_scope(context, scope)
}
pub(crate) fn bind_invocation(
self: &Arc<Self>,
call: &CallContext,
origin: Option<InvocationContext>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
invocation: Arc<dyn MiddlewareCallback>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
let mut scope = CallbackScope::for_call(call);
scope.extension_scope = extensions;
scope.origin = origin;
let scope = self
.register_scope(call, scope)
.map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?;
self.bind_middleware(call.resource_scope_id.clone(), invocation, scope)
}
fn register_scope(
&self,
context: &CallContext,
scope: CallbackScope,
) -> Result<Arc<CallbackScope>, PluginFault> {
let scope = Arc::new(scope);
let mut scopes = self
.scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
scopes.retain(|_, scope| scope.strong_count() != 0);
if scopes
.get(&context.resource_scope_id)
.and_then(Weak::upgrade)
.is_some()
{
return Err(denied());
}
scopes.insert(context.resource_scope_id.clone(), Arc::downgrade(&scope));
Ok(scope)
}
}
impl CallbackHandler for PluginCallbacks {
fn begin(&self, context: &CallContext, deadline: &CallDeadline) {
let scope = self
.scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&context.resource_scope_id)
.and_then(Weak::upgrade)
.unwrap_or_else(|| {
let mut scope = CallbackScope::for_call(context);
if context.stage == gateway_plugin_sdk::Stage::Registration {
scope.request_body_limit = self.registration_body_limit;
}
Arc::new(scope)
});
let middleware = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&context.resource_scope_id);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
context.call_id,
Arc::new(CallResources::new(context, deadline, scope, middleware)),
);
}
fn finish(&self, context: &CallContext) {
if let Some(call) = self
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&context.call_id)
{
call.close();
}
}
fn call(
&self,
context: CallContext,
method: String,
params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
let call = self
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&context.call_id)
.cloned();
let http = self.http.clone();
let network = self.network.clone();
let maximum_payload = self.maximum_payload;
let log = self.log.clone();
let private_state = self.private_state.clone();
let accounts = self.accounts.clone();
let resources = self.resources.clone();
let data = self.data.clone();
let keys = self.keys.clone();
let models = self.models.clone();
let request_limits = self.request_limits.clone();
let affinity = self.affinity.clone();
let services = self.services.clone();
let service_actor = self.service_actor.clone();
Box::pin(async move {
let call = call.ok_or_else(denied)?;
let scope = &call.scope;
let (middleware, is_private_state) = {
let state = call
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.closed || call.deadline.is_elapsed() {
return Err(denied());
}
if method == "host.log" {
return log.record(&context, params, &payload);
}
let middleware = if method.starts_with("host.middleware.") {
Some(state.middleware.clone().ok_or_else(denied)?)
} else {
None
};
let is_private_state = matches!(
method.as_str(),
"host.state.get" | "host.state.put" | "host.state.delete"
);
(middleware, is_private_state)
};
if matches!(
method.as_str(),
gateway_plugin_sdk::call::middleware::http::DISPATCH_METHOD
| gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD
| gateway_plugin_sdk::call::middleware::http::BODY_CLOSE_METHOD
| gateway_plugin_sdk::call::middleware::http::BODY_CREATE_METHOD
| gateway_plugin_sdk::call::middleware::http::BODY_WRITE_METHOD
) {
return tokio::select! {
biased;
() = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")),
result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result,
};
}
if let Some(middleware) = middleware {
return middleware
.invoke(method, params, payload, maximum_payload)
.await;
}
if method == gateway_plugin_sdk::call::services::CALL_METHOD {
return services
.call(&context, &call, service_actor, params, &payload)
.await;
}
if is_private_state {
return private_state.call(&method, params, &payload).await;
}
if matches!(
method.as_str(),
gateway_plugin_sdk::call::resources::GROUP_ENSURE
| gateway_plugin_sdk::call::resources::GROUP_MEMBERS
| gateway_plugin_sdk::call::resources::KEY_ENSURE
) {
return resources.call(&context, &method, params, &payload).await;
}
if matches!(
method.as_str(),
"host.keys.list"
| gateway_plugin_sdk::call::key_budgets::RESET
| gateway_plugin_sdk::call::key_budgets::GET
| gateway_plugin_sdk::call::key_budgets::UPDATE_LIMITS
) {
return keys.call(&context, &method, params, &payload).await;
}
if method == "host.models.list" {
return models.call(&context, &call, &method, params, payload).await;
}
if matches!(
method.as_str(),
"host.model.execute"
| "host.model.execute_stream"
| "host.model.stream_read"
| "host.model.stream_close"
) {
return models.call(&context, &call, &method, params, payload).await;
}
if method == "host.affinity.lookup" {
return affinity.call(&context, params, &payload).await;
}
let scope = Some(scope)
.filter(|scope| scope.authorizes(&context))
.ok_or_else(denied)?;
if matches!(
method.as_str(),
gateway_plugin_sdk::call::data::ACCOUNTS_LIST
| gateway_plugin_sdk::call::data::KEYS_GET
| gateway_plugin_sdk::call::data::QUOTA_GET
| gateway_plugin_sdk::call::data::QUOTA_REFRESH
) {
return data.call(&method, params, &payload).await;
}
if matches!(
method.as_str(),
"host.auth.list" | "host.auth.get" | "host.auth.get_runtime" | "host.auth.save"
) {
return accounts
.call(&context, scope, &method, params, &payload)
.await;
}
let http = http::HttpCallbacks {
client: &http,
network: &network,
scope,
call: &call,
maximum_payload,
request_limits: &request_limits,
};
if method.starts_with("host.upstream.") {
return upstream::dispatch(&http, &method, params, payload).await;
}
http.dispatch(&method, params, payload).await
})
}
}
pub(crate) use ports::{PluginCallbackPorts, PluginCallbacks};
fn denied() -> PluginFault {
PluginFault::new(
@@ -1,4 +1,4 @@
//! 嵌套模型执行;只驱动 Core 会话,不拥有路由、重试或计费规则
//! 模型目录与执行适配;身份解析交给客户端端口,路由与计费沿用 Core
use std::sync::{Arc, OnceLock, Weak};
@@ -9,11 +9,10 @@ use gateway_core::{
engine::{
CommitRequirement, ModelRequestId,
execution::{
BoundModelExecutionContext, ClientTransport, ExecutionRequestMetadata, ExecutionSession,
AuthenticatedClient, ClientTransport, ExecutionRequestMetadata, ExecutionSession,
},
nested::{
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
NestedModelExecutionRequest,
BoundModelExecutionRequest, NestedModelExecutionPort, NestedModelExecutionRequest,
},
},
event::ProviderEvent,
@@ -22,13 +21,12 @@ use gateway_core::{
GenerateRequest, ImageRequest, ImageRequestKind, Operation, ProtocolPayload,
RawJsonPayload, StandaloneSearchRequest,
},
policy::ClientApiKeyId,
routing::PublicModelId,
};
use gateway_plugin_sdk::{
CallContext, ErrorCode, PluginFault,
call::host::{
ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest,
self as wire, ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest,
ModelListResult, ModelOperation, ModelStreamCloseRequest, ModelStreamReadRequest,
ModelStreamReadResult, ModelStreamResult,
},
@@ -63,6 +61,7 @@ impl PluginModelPortSlot {
pub(super) struct PluginModels {
slot: Arc<PluginModelPortSlot>,
clients: Arc<super::client::PluginClients>,
request_limits: Arc<super::request_limits::RequestLimits>,
maximum_payload: usize,
}
@@ -70,11 +69,13 @@ pub(super) struct PluginModels {
impl PluginModels {
pub(super) fn new(
slot: Arc<PluginModelPortSlot>,
clients: Arc<super::client::PluginClients>,
request_limits: Arc<super::request_limits::RequestLimits>,
maximum_payload: usize,
) -> Self {
Self {
slot,
clients,
request_limits,
maximum_payload,
}
@@ -89,7 +90,7 @@ impl PluginModels {
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
match method {
"host.models.list" => {
wire::MODELS_LIST => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -102,19 +103,14 @@ impl PluginModels {
return Err(invalid());
}
let port = self.slot.upgrade()?;
let bound = port
.bind(BoundModelExecutionBinding {
settings: call.request_settings(),
client_key_id: ClientApiKeyId::new(request.client_key_id)
.map_err(|_| invalid())?,
initiating_plugin_instance_id: context.instance_id.clone(),
cancellation: call.cancellation.child_token(),
extension_scope: call.scope.extension_scope.clone(),
})
.await
.map_err(super::error::gateway)?;
let client = self.clients.identity(call, request.client_key_id).await?;
let models = port
.models(bound, request.protocol, request.client_version)
.models(
client,
call.cancellation.clone(),
request.protocol,
request.client_version,
)
.await
.map_err(super::error::gateway)?;
encode_result(ModelListResult {
@@ -124,7 +120,7 @@ impl PluginModels {
.collect(),
})
}
"host.model.execute" | "host.model.execute_stream" => {
wire::MODEL_EXECUTE | wire::MODEL_EXECUTE_STREAM => {
let mut request: ModelExecuteRequest =
serde_json::from_value(params).map_err(|_| invalid())?;
if payload.is_empty() {
@@ -139,11 +135,14 @@ impl PluginModels {
}
};
let port = self.slot.upgrade()?;
let stream = method == "host.model.execute_stream";
let stream = method == wire::MODEL_EXECUTE_STREAM;
let started = if let Some(client_key_id) = request.client_key_id.take() {
let bound = self.binding(&port, context, call, client_key_id).await?;
check_size(bound.request_body_limit())?;
port.start_bound(self.bound_request(request, payload, stream, bound)?)
let client = self.clients.identity(call, client_key_id).await?;
check_size(client.snapshot().responses_max_decompressed_body_bytes())?;
let request = self.request_parts(request, payload, stream)?;
let request = Self::bound_request(context, call, request, client);
call.observe_dispatch()?;
port.start_bound(request)
.await
.map_err(super::error::gateway)?
} else {
@@ -151,7 +150,7 @@ impl PluginModels {
let request = self.request(context, request, payload, stream)?;
port.start(request).await.map_err(super::error::gateway)?
};
if method == "host.model.execute" {
if method == wire::MODEL_EXECUTE {
execute_buffered(started, self.maximum_payload).await
} else {
let stream = Arc::new(ModelStream::new(started.session, self.maximum_payload));
@@ -175,7 +174,7 @@ impl PluginModels {
})
}
}
"host.model.stream_read" => {
wire::MODEL_STREAM_READ => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -214,7 +213,7 @@ impl PluginModels {
payload: read.payload,
})
}
"host.model.stream_close" => {
wire::MODEL_STREAM_CLOSE => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -237,44 +236,6 @@ impl PluginModels {
}
}
async fn binding(
&self,
port: &Arc<dyn NestedModelExecutionPort>,
context: &CallContext,
call: &CallResources,
client_key_id: String,
) -> Result<BoundModelExecutionContext, PluginFault> {
let key = ClientApiKeyId::new(client_key_id.clone()).map_err(|_| invalid())?;
let settings = call.request_settings();
let mut bindings = call.model_bindings.lock().await;
if let Some(bound) = bindings
.get(&client_key_id)
.filter(|bound| bound.request_settings() == settings.as_ref())
{
return Ok(bound.clone());
}
if !bindings.contains_key(&client_key_id) && bindings.len() >= MAX_MODEL_STREAMS_PER_CALL {
return Err(PluginFault::new(
ErrorCode::Capacity,
"model identity capacity is exhausted",
));
}
let bound = port
.bind(BoundModelExecutionBinding {
settings,
client_key_id: key,
initiating_plugin_instance_id: context.instance_id.clone(),
cancellation: call.cancellation.child_token(),
extension_scope: call.scope.extension_scope.clone(),
})
.await
.map_err(super::error::gateway)?;
// 调用持有身份直到 RPC 结束,流式执行不会因单次 callback 返回而提前取消;
// 同一 Key 复用身份快照,每次执行由 Core 创建独立调用图
bindings.insert(client_key_id, bound.clone());
Ok(bound)
}
fn request(
&self,
context: &CallContext,
@@ -310,21 +271,22 @@ impl PluginModels {
}
fn bound_request(
&self,
request: ModelExecuteRequest,
body: Vec<u8>,
stream: bool,
context: BoundModelExecutionContext,
) -> Result<BoundModelExecutionRequest, PluginFault> {
let request = self.request_parts(request, body, stream)?;
Ok(BoundModelExecutionRequest {
context,
context: &CallContext,
call: &CallResources,
request: ModelRequestParts,
client: AuthenticatedClient,
) -> BoundModelExecutionRequest {
BoundModelExecutionRequest {
client,
initiating_plugin_instance_id: context.instance_id.clone(),
cancellation: call.cancellation.clone(),
extension_scope: call.scope.extension_scope.clone(),
public_model: request.public_model,
operation: request.operation,
metadata: request.metadata,
provider: request.provider,
account: request.account,
})
}
}
fn request_parts(
@@ -0,0 +1,102 @@
//! 组装实例宿主端口及调用资源索引,保持资源与插件实例同寿命
use std::{
collections::BTreeMap,
sync::{Arc, Mutex, Weak},
};
use gateway_admin::model::AdminError;
use gateway_host::outbound::{HttpClient, NetworkPolicy};
use super::{
CallResources, CallbackScope, MiddlewareCallback, PluginAccountPortSlot,
PluginAffinityPortSlot, PluginClientIdentityPortSlot, PluginClientKeyPortSlot,
PluginModelPortSlot, PluginResourcePorts,
};
use super::{
accounts, affinity, client, data, keys, log, model, private_state, request_limits, resources,
services,
};
pub(crate) struct PluginCallbackPorts {
pub(crate) services: Arc<services::ServicePorts>,
pub(crate) http: Arc<HttpClient>,
pub(crate) accounts: Arc<PluginAccountPortSlot>,
pub(crate) keys: Arc<PluginClientKeyPortSlot>,
pub(crate) clients: Arc<PluginClientIdentityPortSlot>,
pub(crate) models: Arc<PluginModelPortSlot>,
pub(crate) affinity: Arc<PluginAffinityPortSlot>,
pub(crate) resources: Arc<PluginResourcePorts>,
}
pub(crate) struct PluginCallbacks {
pub(crate) registration_body_limit: Option<usize>,
pub(super) services: Arc<services::ServicePorts>,
pub(super) service_actor: gateway_admin::model::usage_data::UsageActor,
pub(super) accounts: Arc<accounts::PluginAccounts>,
pub(super) resources: Arc<resources::PluginResources>,
pub(super) data: Arc<data::PluginData>,
pub(super) keys: Arc<keys::PluginClientKeys>,
pub(super) affinity: Arc<affinity::PluginAffinity>,
pub(super) log: Arc<log::PluginLog>,
pub(super) clients: Arc<client::PluginClients>,
pub(super) models: Arc<model::PluginModels>,
pub(super) private_state: Arc<private_state::PluginPrivateState>,
pub(super) http: Arc<HttpClient>,
pub(super) network: Arc<NetworkPolicy>,
pub(super) scopes: Mutex<BTreeMap<String, Weak<CallbackScope>>>,
pub(super) pending_middleware: Mutex<BTreeMap<String, Arc<dyn MiddlewareCallback>>>,
pub(super) calls: Mutex<BTreeMap<u64, Arc<CallResources>>>,
pub(super) maximum_payload: usize,
pub(super) request_limits: Arc<request_limits::RequestLimits>,
}
impl PluginCallbacks {
pub(crate) fn new(
instance: &gateway_admin::model::plugins::instances::PluginInstance,
maximum_payload: usize,
manifest: &gateway_plugin_sdk::Manifest,
log_slots: Arc<tokio::sync::Semaphore>,
private_state: Arc<private_state::PluginPrivateState>,
ports: PluginCallbackPorts,
request_limits: Arc<request_limits::RequestLimits>,
) -> Result<Self, AdminError> {
let clients = Arc::new(client::PluginClients::new(ports.clients));
Ok(Self {
registration_body_limit: None,
services: ports.services,
service_actor: gateway_admin::model::usage_data::UsageActor::Plugin {
publisher: manifest.publisher.clone(),
name: manifest.name.clone(),
instance_id: instance.id.clone(),
},
resources: Arc::new(resources::PluginResources::new(instance, ports.resources)),
data: Arc::new(data::PluginData::new(
ports.accounts.clone(),
ports.keys.clone(),
)),
accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)),
keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)),
affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)),
log: Arc::new(
log::PluginLog::new(manifest, log_slots)
.map_err(|_| AdminError::invalid("插件清单身份不合法"))?,
),
clients: clients.clone(),
models: Arc::new(model::PluginModels::new(
ports.models,
clients,
request_limits.clone(),
maximum_payload,
)),
private_state,
http: ports.http,
network: Arc::new(NetworkPolicy::unrestricted()),
scopes: Mutex::new(BTreeMap::new()),
pending_middleware: Mutex::new(BTreeMap::new()),
calls: Mutex::new(BTreeMap::new()),
maximum_payload,
request_limits,
})
}
}
@@ -21,8 +21,8 @@ use gateway_admin::{
use gateway_plugin_sdk::{
ErrorCode, Manifest, PluginFault,
call::host::{
StateDeleteRequest, StateDeleteResult, StateGetRequest, StateGetResult, StatePutRequest,
StatePutResult, StateRecord,
self as wire, StateDeleteRequest, StateDeleteResult, StateGetRequest, StateGetResult,
StatePutRequest, StatePutResult, StateRecord,
},
};
use sha2::{Digest as _, Sha256};
@@ -99,7 +99,7 @@ impl PluginPrivateState {
.clone()
.ok_or_else(denied_fault)?;
let result = match method {
"host.state.get" => {
wire::STATE_GET => {
let request: StateGetRequest =
serde_json::from_value(params).map_err(|_| invalid_fault())?;
self.authorize(&request.namespace)?;
@@ -115,7 +115,7 @@ impl PluginPrivateState {
});
serde_json::to_value(StateGetResult { record }).map_err(|_| internal_fault())?
}
"host.state.put" => {
wire::STATE_PUT => {
let request: StatePutRequest =
serde_json::from_value(params).map_err(|_| invalid_fault())?;
self.authorize(&request.namespace)?;
@@ -141,7 +141,7 @@ impl PluginPrivateState {
})
.map_err(|_| internal_fault())?
}
"host.state.delete" => {
wire::STATE_DELETE => {
let request: StateDeleteRequest =
serde_json::from_value(params).map_err(|_| invalid_fault())?;
self.authorize(&request.namespace)?;
@@ -7,14 +7,7 @@ use gateway_core::{
};
use gateway_plugin_sdk::{CallContext, Stage};
/// 所有中间件共享的父调用身份;不混入协议操作或服务参数
#[derive(Clone)]
pub(crate) struct InvocationContext {
pub request_id: String,
pub call_id: String,
pub cancellation: gateway_core::lifecycle::CancellationToken,
pub plan: gateway_core::engine::middleware::FrozenMiddlewarePlan,
}
pub(crate) use gateway_core::engine::extensions::InvocationOrigin as InvocationContext;
/// 一次回调的调用链、账号与网络事实,由操作持有到完成
pub(crate) struct CallbackScope {
@@ -25,7 +18,8 @@ pub(crate) struct CallbackScope {
credential_revision: Option<u64>,
pub(super) proxy: Option<OutboundProxy>,
pub(super) extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
execution_effects: Option<Arc<ExecutionEffects>>,
pub(super) execution_effects: Option<Arc<ExecutionEffects>>,
pub(super) settings: Option<gateway_core::routing::request_settings::RequestSettings>,
pub(super) upstream: Option<Arc<super::upstream::ManagedUpstream>>,
}
@@ -37,14 +31,13 @@ impl CallbackScope {
}
}
pub(super) fn child_extensions(
pub(super) fn check_child_depth(
&self,
instance_id: &str,
) -> Result<gateway_core::engine::extensions::ExtensionCallScope, gateway_plugin_sdk::PluginFault>
{
) -> Result<(), gateway_plugin_sdk::PluginFault> {
// 上游适配器在进入回调前已登记当前实例;主动子调用沿用该集合
if self.extension_scope.contains(instance_id) {
return Ok(self.extension_scope.clone());
return Ok(());
}
if self.extension_scope.len()
>= gateway_core::engine::extensions::ExtensionCallScope::MAXIMUM_DEPTH
@@ -54,9 +47,7 @@ impl CallbackScope {
"child call depth exceeded",
));
}
self.extension_scope
.extending(instance_id.to_owned())
.ok_or_else(super::invalid)
Ok(())
}
pub(super) fn new(context: &CallContext, proxy: Option<OutboundProxy>) -> Self {
@@ -69,6 +60,7 @@ impl CallbackScope {
proxy,
extension_scope: Default::default(),
execution_effects: None,
settings: None,
upstream: None,
}
}
@@ -63,27 +63,18 @@ impl ServicePorts {
)
})?;
let call = registry.call(&request.operation, request.input);
let scope = &resources.scope;
let origin = scope.origin.as_ref();
resources.scope.check_child_depth(&context.instance_id)?;
let origin = resources.child_origin(context)?;
let parent = gateway_admin::public_service::Origin {
request_id: origin.map_or_else(
|| {
context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone())
},
|origin| origin.request_id.clone(),
),
call_id: origin.map_or_else(
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|origin| origin.call_id.clone(),
),
cancellation: resources.cancellation.clone(),
extensions: scope.child_extensions(&context.instance_id)?,
plan: origin.map_or(gateway_admin::public_service::Plan::Current, |origin| {
gateway_admin::public_service::Plan::Frozen(Some(origin.plan.clone()))
}),
request_id: origin.request_id,
call_id: origin.call_id,
cancellation: origin.cancellation,
extensions: origin.extensions,
plan: origin
.plan
.map_or(gateway_admin::public_service::Plan::Current, |plan| {
gateway_admin::public_service::Plan::Frozen(Some(plan))
}),
};
// 即使终端只读,服务中间件也可提交写入;不存在的操作不会进入调用链
if registry
@@ -193,26 +193,31 @@ pub(super) async fn dispatch(
.map(|remaining| remaining.min(Duration::from_secs(120)))
};
match method {
"host.upstream.http.do" | "host.upstream.http.do_stream" => {
adapter::HTTP_DO | adapter::HTTP_DO_STREAM => {
let request = serde_json::from_value(params).map_err(|_| invalid())?;
let maximum_body_bytes = http.body_limit(&payload)?;
let (request, purpose) = managed.request(request, payload, maximum_body_bytes)?;
http.send(request, method.ends_with("do_stream"), Some(purpose))
.await
http.send(
request,
method == adapter::HTTP_DO_STREAM,
Some(purpose),
scope.proxy.as_ref(),
)
.await
}
"host.upstream.http.stream_read" | "host.upstream.http.stream_close" => {
adapter::HTTP_STREAM_READ | adapter::HTTP_STREAM_CLOSE => {
http.dispatch(
if method.ends_with("stream_read") {
"host.http.stream_read"
if method == adapter::HTTP_STREAM_READ {
wire::HTTP_STREAM_READ
} else {
"host.http.stream_close"
wire::HTTP_STREAM_CLOSE
},
params,
payload,
)
.await
}
"host.upstream.websocket.open" => {
adapter::WEBSOCKET_OPEN => {
if !payload.is_empty() || !managed.websocket_allowed {
return Err(denied());
}
@@ -298,7 +303,7 @@ pub(super) async fn dispatch(
payload,
})
}
"host.upstream.websocket.send" => {
adapter::WEBSOCKET_SEND => {
let message: adapter::UpstreamWebSocketMessage =
serde_json::from_value(params).map_err(|_| invalid())?;
let message = match message.kind {
@@ -322,7 +327,7 @@ pub(super) async fn dispatch(
payload: vec![],
})
}
"host.upstream.websocket.read" => {
adapter::WEBSOCKET_READ => {
if params != serde_json::json!({}) || !payload.is_empty() {
return Err(invalid());
}
@@ -353,7 +358,7 @@ pub(super) async fn dispatch(
payload,
})
}
"host.upstream.websocket.close" => {
adapter::WEBSOCKET_CLOSE => {
if params != serde_json::json!({}) || !payload.is_empty() {
return Err(invalid());
}
@@ -10,7 +10,7 @@ use gateway_admin::model::{
AdminError, AdminErrorKind, Revision,
plugins::instances::{
PluginInstance, PluginInstanceRuntime, PluginInstanceRuntimeFailure,
PluginInstanceRuntimeStatus, PluginInstanceSnapshot,
PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus, PluginInstanceSnapshot,
},
};
use gateway_core::routing::extensions::ExtensionSetReference;
@@ -227,6 +227,17 @@ impl InstanceRuntimeProjection<'_> {
actual_artifact_sha256: active.map(|instance| instance.artifact_sha256.clone()),
failure,
draining_revisions: draining_revisions.into_iter().collect(),
load: active.map(|instance| {
let load = instance.session.load();
PluginInstanceRuntimeLoad {
active_calls: load.active_calls,
executing_calls: load.executing_calls,
queued_calls: load.queued_calls,
resuming_calls: load.resuming_calls,
pending_callbacks: load.pending_callbacks,
active_streams: load.active_streams,
}
}),
}
}
}
@@ -161,15 +161,16 @@ impl PluginRuntime {
&manifest,
self.log_slots.clone(),
private_state.clone(),
PluginCallbackPorts::new(
self.service_ports.clone(),
self.http.clone(),
self.account_ports.clone(),
self.client_key_ports.clone(),
self.model_ports.clone(),
self.affinity_ports.clone(),
self.resource_ports.clone(),
),
PluginCallbackPorts {
services: self.service_ports.clone(),
http: self.http.clone(),
accounts: self.account_ports.clone(),
keys: self.client_key_ports.clone(),
clients: self.client_identity_port.clone(),
models: self.model_ports.clone(),
affinity: self.affinity_ports.clone(),
resources: self.resource_ports.clone(),
},
self.request_limits.clone(),
)?;
callbacks.registration_body_limit = request_body_limit;
@@ -8,7 +8,8 @@ use super::{
use crate::{
PackageLimits, RpcLimits, RpcSession,
callback::{
PluginAccountPortSlot, PluginAffinityPortSlot, PluginClientKeyPortSlot, PluginModelPortSlot,
PluginAccountPortSlot, PluginAffinityPortSlot, PluginClientIdentityPortSlot,
PluginClientKeyPortSlot, PluginModelPortSlot,
},
};
use gateway_admin::{
@@ -21,7 +22,7 @@ use gateway_admin::{
};
use gateway_core::{
engine::{
authentication::FrontendAuthenticationExtensionIndex,
authentication::{ClientIdentityPort, FrontendAuthenticationExtensionIndex},
extensions::ExecutionExtensionIndex,
nested::{AffinityLookupPort, NestedModelExecutionPort},
observation::RequestObserverExtensionIndex,
@@ -69,6 +70,7 @@ pub struct PluginRuntime {
pub(super) account_ports: Arc<PluginAccountPortSlot>,
pub(super) client_key_ports: Arc<PluginClientKeyPortSlot>,
pub(super) resource_ports: Arc<crate::callback::PluginResourcePorts>,
pub(super) client_identity_port: Arc<PluginClientIdentityPortSlot>,
pub(super) model_ports: Arc<PluginModelPortSlot>,
pub(super) affinity_ports: Arc<PluginAffinityPortSlot>,
pub(super) observers: RequestObserverExtensionIndex,
@@ -113,6 +115,7 @@ impl PluginRuntime {
account_ports: Arc::new(PluginAccountPortSlot::new()),
client_key_ports: Arc::new(PluginClientKeyPortSlot::new()),
resource_ports: Arc::new(crate::callback::PluginResourcePorts::new()),
client_identity_port: Arc::new(PluginClientIdentityPortSlot::new()),
model_ports: Arc::new(PluginModelPortSlot::new()),
affinity_ports: Arc::new(PluginAffinityPortSlot::new()),
observers: RequestObserverExtensionIndex::default(),
@@ -168,6 +171,14 @@ impl PluginRuntime {
self.client_key_ports.bind(access)
}
/// 身份查询独立装配,复用 Core 的认证与冻结权限,不要求绑定模型执行端口
pub fn bind_client_identity_port(
&self,
clients: &Arc<dyn ClientIdentityPort>,
) -> Result<(), AdminError> {
self.client_identity_port.bind(clients)
}
/// Core 激活后一次性绑定嵌套执行与亲和查询端口;Runtime 仅保存 Weak,避免组合根强环
pub fn bind_model_ports(
&self,
@@ -47,4 +47,11 @@ impl ExecutionCapacity {
self.active.close();
self.queued.close();
}
pub(super) fn available(&self) -> (usize, usize) {
(
self.active.available_permits(),
self.queued.available_permits(),
)
}
}
@@ -0,0 +1,658 @@
//! RPC 调用表唯一持有调度、回调、响应流和取消确认,统一完成调用资源回收
use std::{
collections::BTreeMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use gateway_host::process::ProcessControl;
use gateway_plugin_sdk::{
CallContext, Frame, Message, PluginFault,
client::{CallDeadline, CallDeadlinePause, EncodedFrame},
};
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch};
use super::{
CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSessionLoad, admission::ExecutionCapacity,
};
use crate::stream::StreamIngress;
pub(crate) struct Shared {
state: Mutex<State>,
started_at: Instant,
pub(super) expected_stop: AtomicBool,
failure_observer: Option<Arc<dyn Fn(Duration) + Send + Sync>>,
pub control: mpsc::Sender<EncodedFrame>,
pub process: ProcessControl,
pub stopped: watch::Sender<bool>,
callback_slots: Arc<Semaphore>,
pub(super) execution: ExecutionCapacity,
cancellations_changed: Notify,
}
struct State {
failure: Option<RpcError>,
calls: BTreeMap<u64, PendingCall>,
}
struct PendingCall {
_slot: Arc<OwnedSemaphorePermit>,
execution: Option<OwnedSemaphorePermit>,
deadline: CallDeadline,
handler: Arc<dyn CallbackHandler>,
transmitted: bool,
cancellation: Option<RpcError>,
context: CallContext,
result: Option<oneshot::Sender<Result<RpcReply, RpcError>>>,
stream: Option<StreamIngress>,
scheduling: Option<Scheduling>,
callbacks: BTreeMap<u64, CallbackTask>,
}
// 调度状态只属于父任务;回调完成与恢复授权分别推进
enum Scheduling {
Running {
epoch: u64,
},
Suspended {
epoch: u64,
pause: Option<CallDeadlinePause>,
},
Resuming {
epoch: u64,
pause: Option<CallDeadlinePause>,
task: tokio::task::AbortHandle,
},
}
struct CallbackTask {
task: tokio::task::AbortHandle,
pauses_budget: bool,
_permit: OwnedSemaphorePermit,
}
impl Drop for CallbackTask {
fn drop(&mut self) {
self.task.abort();
}
}
impl PendingCall {
/// 关闭资源与封存副作用必须先于任何结果通知;取消确认只延续容量租约
fn revoke(&mut self) {
self.handler.finish(&self.context);
self.callbacks.clear();
self.stop_resumption();
}
fn stop_resumption(&mut self) {
if let Some(Scheduling::Resuming { task, .. }) = self.scheduling.take() {
task.abort();
}
}
fn finish(mut self, result: Result<RpcReply, RpcError>) {
tracing::debug!(
event = "plugin.rpc.finished",
instance_id = self.context.instance_id,
call_id = self.context.call_id,
stage = ?self.context.stage,
outcome = result.as_ref().map_or_else(|error| error.diagnostic().0, |_| "success"),
"插件 RPC 调用结束"
);
let stream = self.stream.take();
let sender = self.result.take();
// 先同步关闭调用资源,Core 才能在收到结果时看到全部发送副作用
drop(self);
if let Some(stream) = stream {
stream.finish(result.as_ref().map(|_| ()).map_err(Clone::clone));
}
if let Some(sender) = sender {
let _ = sender.send(result);
}
}
}
impl Drop for PendingCall {
fn drop(&mut self) {
self.revoke();
}
}
impl Shared {
pub(super) fn new(
limits: RpcLimits,
process: ProcessControl,
control: mpsc::Sender<EncodedFrame>,
failure_observer: Option<Arc<dyn Fn(Duration) + Send + Sync>>,
) -> Self {
let (stopped, _) = watch::channel(false);
Self {
state: Mutex::new(State {
failure: None,
calls: BTreeMap::new(),
}),
started_at: Instant::now(),
expected_stop: AtomicBool::new(false),
failure_observer,
control,
process,
stopped,
callback_slots: Arc::new(Semaphore::new(limits.maximum_callbacks)),
execution: ExecutionCapacity::new(
limits.maximum_executing_calls,
limits.maximum_queued_calls,
limits.maximum_queue_timeout,
),
cancellations_changed: Notify::new(),
}
}
pub(super) fn failure_diagnostic(&self) -> Option<(&'static str, &'static str)> {
self.state().failure.as_ref().map(RpcError::diagnostic)
}
pub(super) fn load(&self, limits: RpcLimits, available_calls: usize) -> RpcSessionLoad {
let state = self.state();
let (available_execution, available_queue) = self.execution.available();
RpcSessionLoad {
active_calls: limits.maximum_calls - available_calls,
executing_calls: limits.maximum_executing_calls - available_execution,
queued_calls: limits.maximum_queued_calls - available_queue,
resuming_calls: state
.calls
.values()
.filter(|call| matches!(call.scheduling, Some(Scheduling::Resuming { .. })))
.count(),
pending_callbacks: limits.maximum_callbacks - self.callback_slots.available_permits(),
active_streams: state
.calls
.values()
.filter(|call| call.stream.is_some() && call.result.is_none())
.count(),
}
}
pub(super) fn begin_call(
&self,
context: CallContext,
deadline: tokio::time::Instant,
stream: Option<StreamIngress>,
slot: Arc<OwnedSemaphorePermit>,
execution: OwnedSemaphorePermit,
handler: Arc<dyn CallbackHandler>,
) -> Result<StartedCall, RpcError> {
let id = context.call_id;
let (result, received) = oneshot::channel();
let mut state = self.state();
if let Some(error) = &state.failure {
return Err(error.clone());
}
let deadline = CallDeadline::new(deadline);
handler.begin(&context, &deadline);
state.calls.insert(
id,
PendingCall {
_slot: slot,
execution: Some(execution),
deadline: deadline.clone(),
handler,
transmitted: false,
cancellation: None,
context,
result: Some(result),
stream,
scheduling: Some(Scheduling::Running { epoch: 0 }),
callbacks: BTreeMap::new(),
},
);
Ok(StartedCall {
id,
deadline,
received,
})
}
/// 替换维护任务前等待已取消的调用确认;未确认仍沿用会话的有界故障回收
pub(crate) async fn settle_cancellations(&self) {
let ids: Vec<_> = self
.state()
.calls
.iter()
.filter_map(|(id, call)| call.cancellation.is_some().then_some(*id))
.collect();
loop {
let changed = self.cancellations_changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
if !ids.iter().any(|id| self.is_cancelling(*id)) {
break;
}
changed.await;
}
if self.failure_diagnostic().is_some() {
self.process.exited().await;
}
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn fail(&self, error: RpcError) {
let uptime = self.started_at.elapsed();
let (calls, failure_observer) = {
let mut state = self.state();
if state.failure.is_some() {
return;
}
state.failure = Some(error.clone());
let observer = (!self.expected_stop.load(Ordering::Acquire))
.then(|| self.failure_observer.clone())
.flatten();
(std::mem::take(&mut state.calls), observer)
};
self.execution.close();
for call in calls.into_values() {
call.finish(Err(error.clone()));
}
if let Some(observer) = failure_observer {
observer(uptime);
}
self.stopped.send_replace(true);
self.process.stop();
self.cancellations_changed.notify_waiters();
}
pub(super) fn expect_stop(&self) {
self.expected_stop.store(true, Ordering::Release);
}
pub fn finish(&self, id: u64, result: Result<RpcReply, RpcError>) -> Result<(), RpcError> {
let mut state = self.state();
let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?;
if call.cancellation.is_some() {
return Ok(());
}
if matches!(&result, Err(RpcError::Remote(fault)) if fault.code == gateway_plugin_sdk::ErrorCode::Timeout)
{
log_timeout(&call.context, "plugin", "initial_reply", "remote_timeout");
}
if result.is_ok() && !matches!(call.scheduling, Some(Scheduling::Running { .. })) {
return Err(RpcError::Protocol(None));
}
if call.stream.is_some() && result.is_ok() {
if call.context.resource_stream {
call.deadline.clear();
}
let initial = call.result.take().ok_or(RpcError::Protocol(None))?;
call.execution.take();
call.scheduling.take();
call.handler.streaming(&call.context);
drop(state);
let _ = initial.send(result);
} else {
let pending = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?;
drop(state);
pending.finish(result);
}
Ok(())
}
pub fn stream_chunk(&self, id: u64, sequence: u64, payload: Vec<u8>) -> Result<(), RpcError> {
let mut state = self.state();
let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?;
if call.cancellation.is_some() {
return Ok(());
}
if call.result.is_some() {
return Err(RpcError::Protocol(None));
}
call.stream
.as_mut()
.ok_or(RpcError::Protocol(None))?
.receive(sequence, payload)
}
pub fn stream_end(&self, id: u64, error: Option<PluginFault>) -> Result<(), RpcError> {
let mut state = self.state();
if state
.calls
.get(&id)
.is_some_and(|call| call.cancellation.is_some())
{
return Ok(());
}
let call = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?;
drop(state);
if call.stream.is_none() || call.result.is_some() {
call.finish(Err(RpcError::Protocol(None)));
return Err(RpcError::Protocol(None));
}
let result = error.map_or_else(
|| {
Ok(RpcReply {
result: serde_json::Value::Null,
payload: Vec::new(),
})
},
|error| Err(RpcError::Remote(error)),
);
call.finish(result);
Ok(())
}
pub fn release_stream_credit(&self, id: u64, bytes: u32) -> Result<(), RpcError> {
let mut state = self.state();
// 完成后仍可消费已排队数据,无须再给已关闭的流发信用
let Some(call) = state.calls.get_mut(&id) else {
return Ok(());
};
call.stream
.as_mut()
.ok_or(RpcError::Protocol(None))?
.release(bytes)?;
drop(state);
self.send_control(Frame::control(Message::Credit {
id,
bytes,
frames: 1,
}));
Ok(())
}
pub fn context(&self, id: u64) -> Option<CallContext> {
self.state()
.calls
.get(&id)
.filter(|call| call.cancellation.is_none())
.map(|call| call.context.clone())
}
pub fn is_cancelling(&self, id: u64) -> bool {
self.state()
.calls
.get(&id)
.is_some_and(|call| call.cancellation.is_some())
}
pub fn mark_transmitted(&self, id: u64) -> bool {
let mut state = self.state();
let Some(call) = state.calls.get_mut(&id) else {
return false;
};
if call.cancellation.is_some() {
return false;
}
call.transmitted = true;
true
}
/// 先撤销调用权限,再等待对端确认;只有无响应对端才终止整个 incarnation
pub fn cancel(self: &Arc<Self>, id: u64, error: RpcError) {
let mut state = self.state();
let Some(call) = state.calls.get_mut(&id) else {
return;
};
if call.cancellation.is_some() {
return;
}
if matches!(error, RpcError::Timeout) {
log_timeout(
&call.context,
"runtime",
if call.result.is_some() {
"initial_reply"
} else {
"response_stream"
},
"processing_budget_exhausted",
);
}
if !call.transmitted {
let call = state.calls.remove(&id);
drop(state);
if let Some(call) = call {
call.finish(Err(error));
}
return;
}
call.cancellation = Some(error.clone());
// 同步撤销不能跨越取消确认:收到确认的并发任务可能立即复用实例
// CallbackHandler::finish 合同要求非阻塞;真正的等待与结果交付放在锁外
call.revoke();
let result = call.result.take();
let stream = call.stream.take();
tracing::debug!(
event = "plugin.rpc.cancelled",
instance_id = call.context.instance_id,
call_id = id,
stage = ?call.context.stage,
reason = error.diagnostic().0,
"插件 RPC 调用已撤销,等待进程确认"
);
drop(state);
if let Some(stream) = stream {
stream.finish(Err(error.clone()));
}
if let Some(result) = result {
let _ = result.send(Err(error.clone()));
}
self.send_control(Frame::control(Message::Cancel { id }));
let shared = Arc::clone(self);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(250)).await;
if shared.is_cancelling(id) {
shared.fail(error);
}
});
}
pub fn acknowledge_cancellation(&self, id: u64) -> Result<(), RpcError> {
let mut state = self.state();
if !state
.calls
.get(&id)
.is_some_and(|call| call.cancellation.is_some())
{
return Err(RpcError::Protocol(None));
}
let call = state.calls.remove(&id);
drop(state);
drop(call);
self.cancellations_changed.notify_waiters();
Ok(())
}
pub fn track_callback(
&self,
parent_id: u64,
id: u64,
method: &str,
task: tokio::task::AbortHandle,
permit: OwnedSemaphorePermit,
) -> bool {
let mut state = self.state();
let Some(call) = state
.calls
.get_mut(&parent_id)
.filter(|call| call.cancellation.is_none())
else {
task.abort();
return false;
};
let pauses_budget = method == gateway_plugin_sdk::call::middleware::NEXT_METHOD;
if !pauses_budget
&& let Some(Scheduling::Suspended { pause, .. } | Scheduling::Resuming { pause, .. }) =
call.scheduling.as_mut()
{
pause.take();
}
call.callbacks.insert(
id,
CallbackTask {
task,
pauses_budget,
_permit: permit,
},
);
true
}
pub(super) fn consume_callback(&self, parent_id: u64, id: u64) -> Result<(), RpcError> {
if id == 0 || !id.is_multiple_of(2) || parent_id == 0 || parent_id.is_multiple_of(2) {
return Err(RpcError::Protocol(None));
}
// 取消确认可以与消费确认交错;确认只能撤销资源,不能重新创建调用
if let Some(call) = self.state().calls.get_mut(&parent_id) {
call.callbacks.remove(&id);
}
Ok(())
}
pub(super) fn suspend(&self, id: u64, epoch: u64) -> Result<(), RpcError> {
if id == 0 || id.is_multiple_of(2) || epoch == 0 {
return Err(RpcError::Protocol(None));
}
let mut state = self.state();
let Some(call) = state.calls.get_mut(&id) else {
return Ok(());
};
if call.cancellation.is_some() {
return Ok(());
}
match call.scheduling.as_ref() {
Some(Scheduling::Running { epoch: previous })
if previous.checked_add(1) == Some(epoch) => {}
_ => return Err(RpcError::Protocol(None)),
}
let pauses = !call.callbacks.is_empty()
&& call
.callbacks
.values()
.all(|callback| callback.pauses_budget);
let pause = pauses.then(|| call.deadline.pause()).flatten();
call.execution.take();
call.scheduling = Some(Scheduling::Suspended { epoch, pause });
Ok(())
}
pub(super) fn request_resume(self: &Arc<Self>, id: u64, epoch: u64) -> Result<(), RpcError> {
if id == 0 || id.is_multiple_of(2) || epoch == 0 {
return Err(RpcError::Protocol(None));
}
let mut state = self.state();
let Some(call) = state.calls.get_mut(&id) else {
return Ok(());
};
if call.cancellation.is_some() {
return Ok(());
}
if !matches!(call.scheduling, Some(Scheduling::Suspended { epoch: current, .. }) if current == epoch)
{
return Err(RpcError::Protocol(None));
}
let Some(Scheduling::Suspended { pause, .. }) = call.scheduling.take() else {
return Err(RpcError::Protocol(None));
};
let shared = Arc::clone(self);
let (ready, started) = oneshot::channel();
let task = tokio::spawn(async move {
if started.await.is_err() {
return;
}
match shared.execution.resume().await {
Ok(permit) => shared.grant_resume(id, epoch, permit),
Err(error) => shared.cancel(id, error),
}
});
call.scheduling = Some(Scheduling::Resuming {
epoch,
pause,
task: task.abort_handle(),
});
let _ = ready.send(());
Ok(())
}
fn grant_resume(&self, id: u64, epoch: u64, permit: OwnedSemaphorePermit) {
let mut state = self.state();
let Some(call) = state
.calls
.get_mut(&id)
.filter(|call| call.cancellation.is_none())
else {
return;
};
if !matches!(call.scheduling, Some(Scheduling::Resuming { epoch: current, .. }) if current == epoch)
{
return;
}
call.execution = Some(permit);
call.scheduling = Some(Scheduling::Running { epoch });
drop(state);
self.send_control(Frame::control(Message::Resumed { id, epoch }));
}
pub fn send_control(&self, frame: Frame) {
let Ok(frame) = EncodedFrame::new(frame) else {
self.fail(RpcError::Protocol(None));
return;
};
self.send_encoded_control(frame);
}
pub(super) fn send_encoded_control(&self, frame: EncodedFrame) {
if self.control.try_send(frame).is_err() {
self.fail(RpcError::Capacity);
}
}
pub(super) fn try_callback_slot(&self) -> Option<OwnedSemaphorePermit> {
self.callback_slots.clone().try_acquire_owned().ok()
}
pub(super) async fn wait_for_callbacks(&self, limits: RpcLimits) {
if let Ok(permits) = self
.callback_slots
.clone()
.acquire_many_owned(limits.maximum_callbacks as u32)
.await
{
drop(permits);
}
}
}
pub(super) struct StartedCall {
pub(super) id: u64,
pub(super) deadline: CallDeadline,
pub(super) received: oneshot::Receiver<Result<RpcReply, RpcError>>,
}
fn log_timeout(
context: &CallContext,
owner: &'static str,
phase: &'static str,
reason: &'static str,
) {
tracing::warn!(
event = "plugin.rpc.timeout",
owner,
phase,
instance_id = context.instance_id,
call_id = context.call_id,
request_id = context.request_id,
attempt_id = context.attempt_id,
stage = ?context.stage,
budget_ms = context.timeout_ms,
reason,
"插件 RPC 调用超时"
);
}
@@ -0,0 +1,192 @@
//! RPC 会话的容量、错误、回调与生命周期合同
use std::{sync::Arc, time::Duration};
use futures::future::BoxFuture;
use gateway_host::process::ProcessStartError;
use gateway_plugin_sdk::{CallBudgetError, CallContext, PluginFault, Stage, client::CallDeadline};
#[derive(Debug, Clone, Copy)]
pub struct RpcLimits {
/// 受管回调与观察队列的缓冲预算,不是请求正文或 IPC 消息总量上限
pub maximum_buffered_body_bytes: usize,
/// 包含初次排队、下游等待、取消确认和未消费流的总在途上限
pub maximum_calls: usize,
pub maximum_executing_calls: usize,
pub maximum_queued_calls: usize,
pub maximum_queue_timeout: Duration,
pub maximum_callbacks: usize,
pub handshake_timeout: Duration,
pub maximum_stderr_bytes: usize,
}
impl Default for RpcLimits {
fn default() -> Self {
Self {
maximum_buffered_body_bytes: 1024 * 1024,
maximum_calls: 256,
maximum_executing_calls: 64,
maximum_queued_calls: 64,
maximum_queue_timeout: Duration::from_secs(30),
maximum_callbacks: 320,
handshake_timeout: Duration::from_secs(5),
maximum_stderr_bytes: 64 * 1024,
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum RpcError {
#[error("plugin process could not be started: {0}")]
Start(#[source] ProcessStartError),
#[error("plugin handshake does not match the prepared instance")]
Handshake(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin call budget negotiation failed: {0}")]
Budget(#[from] CallBudgetError),
#[error("plugin protocol is invalid")]
Protocol(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an invalid response at {0:?}")]
InvalidResponse(Stage, #[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin process or transport has stopped")]
Closed(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin call exceeded its deadline")]
Timeout,
#[error("plugin session was cancelled")]
Cancelled,
#[error("plugin call capacity is exhausted")]
Capacity,
#[error("plugin admission capacity is exhausted")]
Overloaded,
#[error("plugin call context is invalid")]
Context(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an error")]
Remote(PluginFault),
}
impl RpcError {
pub(crate) fn is_overloaded(&self) -> bool {
matches!(self, Self::Overloaded)
|| matches!(self, Self::Remote(fault) if fault.code == gateway_plugin_sdk::ErrorCode::Capacity)
}
/// 会话状态与请求错误共用安全分类,不展开插件返回正文
pub(crate) fn diagnostic(&self) -> (&'static str, &'static str) {
match self {
RpcError::Start(_) => ("process_start", "插件进程启动失败"),
RpcError::Handshake(_) => ("handshake", "插件握手失败"),
RpcError::Budget(_) => ("call_budget", "插件调用预算协商失败"),
RpcError::Protocol(_) => ("protocol", "插件协议错误"),
RpcError::InvalidResponse(stage, _) => (
"invalid_response",
match stage {
Stage::Registration => "注册阶段:插件返回了无效响应",
Stage::Configuration => "配置阶段:插件返回了无效响应",
Stage::Authentication => "认证阶段:插件返回了无效响应",
Stage::Routing => "路由阶段:插件返回了无效响应",
Stage::Scheduling => "调度阶段:插件返回了无效响应",
Stage::Retry => "重试阶段:插件返回了无效响应",
Stage::Http => "HTTP 中间件:插件返回了无效响应",
Stage::Service => "服务中间件:插件返回了无效响应",
Stage::WebSocket => "WebSocket 中间件:插件返回了无效响应",
Stage::Request => "请求中间件:插件返回了无效响应",
Stage::Attempt => "尝试中间件:插件返回了无效响应",
Stage::Outbound => "出站准备中间件:插件返回了无效响应",
Stage::Upstream => "上游适配阶段:插件返回了无效响应",
Stage::Observation => "观察阶段:插件返回了无效响应",
Stage::Management | Stage::PublicManagement => "管理阶段:插件返回了无效响应",
Stage::CommandLine => "命令行阶段:插件返回了无效响应",
Stage::Maintenance => "维护阶段:插件返回了无效响应",
},
),
RpcError::Closed(_) => ("closed", "插件进程或传输已停止"),
RpcError::Timeout => ("timeout", "插件调用超时"),
RpcError::Cancelled => ("cancelled", "插件会话已取消"),
RpcError::Capacity => ("capacity", "插件调用容量已耗尽"),
RpcError::Overloaded => ("overloaded", "插件暂时繁忙"),
RpcError::Context(_) => ("context", "插件调用上下文无效"),
RpcError::Remote(_) => ("remote", "插件返回错误"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RpcSessionDiagnostic {
Ready,
Quiescing,
Failed {
code: &'static str,
message: &'static str,
},
}
pub(crate) struct RpcSessionLifecycle {
pub(super) failure: Arc<dyn Fn(Duration) + Send + Sync>,
end: Option<Box<dyn FnOnce() + Send + Sync>>,
}
impl RpcSessionLifecycle {
pub(crate) fn new(
failure: impl Fn(Duration) + Send + Sync + 'static,
end: impl FnOnce() + Send + Sync + 'static,
) -> Self {
Self {
failure: Arc::new(failure),
end: Some(Box::new(end)),
}
}
pub(super) fn fail(&self, uptime: Duration) {
(self.failure)(uptime);
}
}
/// 从实际租约与调用表采样,各项存在重叠,不构成同一时刻的可加总分区
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RpcSessionLoad {
/// 总在途租约,包含初次排队、取消确认和 End 后仍由消费者持有的流
pub(crate) active_calls: usize,
/// 正在占用执行名额的调用,包含取消确认前尚未归还的名额
pub(crate) executing_calls: usize,
/// 正在占用初次准入排队名额的调用
pub(crate) queued_calls: usize,
/// 调用表中等待恢复授权的调用
pub(crate) resuming_calls: usize,
/// 驻留回调名额,包含已回复但插件尚未消费的结果
pub(crate) pending_callbacks: usize,
/// 已交付首结果且尚未收到 End 或取消的协议响应流
pub(crate) active_streams: usize,
}
impl Drop for RpcSessionLifecycle {
fn drop(&mut self) {
if let Some(end) = self.end.take() {
end();
}
}
}
/// 不派生 Debug,防止原始请求、凭据或模型输出进入宿主诊断
pub struct RpcReply {
pub result: serde_json::Value,
pub payload: Vec<u8>,
}
/// 具体回调在领域适配器校验账号、出站与执行身份;上下文只取自宿主在途调用表
pub trait CallbackHandler: Send + Sync {
/// 在 Call 可见之前建立资源范围;不得执行 I/O 或调用 RPC
fn begin(&self, _context: &CallContext, _deadline: &CallDeadline) {}
/// 调用结束或取消时立即撤销其句柄;实现必须幂等且不能阻塞
fn finish(&self, _context: &CallContext) {}
/// 首个流式结果已到达;资源仍绑定原调用并在终态统一回收
fn streaming(&self, _context: &CallContext) {}
fn call(
&self,
context: CallContext,
method: String,
params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>>;
}
@@ -4,7 +4,6 @@ use std::sync::Arc;
use gateway_plugin_sdk::{
ErrorCode, Frame, Message, PluginFault,
call::middleware::NEXT_METHOD,
client::{EncodedFrame, read_frame},
};
use tokio::{
@@ -12,7 +11,7 @@ use tokio::{
sync::{mpsc, oneshot},
};
use super::session::{CallbackHandler, RpcError, RpcReply, Shared};
use super::{CallbackHandler, RpcError, RpcReply, Shared};
pub(super) fn start<W, R>(
writer: W,
@@ -100,6 +99,13 @@ async fn read_loop<R: AsyncRead + Unpin>(
}
};
let result = match frame.message {
Message::Suspend { id, epoch } if frame.payload.is_empty() => shared.suspend(id, epoch),
Message::Resume { id, epoch } if frame.payload.is_empty() => {
shared.request_resume(id, epoch)
}
Message::CallbackConsumed { id, parent_id } if frame.payload.is_empty() => {
shared.consume_callback(parent_id, id)
}
Message::Cancelled { id } if frame.payload.is_empty() => {
shared.acknowledge_cancellation(id)
}
@@ -125,26 +131,17 @@ async fn read_loop<R: AsyncRead + Unpin>(
Err(RpcError::Protocol(None))
} else if let Some(context) = shared.context(parent_id) {
last_callback = id;
if let Some(permit) = shared.try_callback_slot(&method) {
if let Some(permit) = shared.try_callback_slot() {
let handler = Arc::clone(&callbacks);
let response = Arc::clone(&shared);
let (ready, started) = oneshot::channel();
let pause = (method == NEXT_METHOD)
.then(|| shared.pause_next(parent_id))
.flatten();
let callback_method = method.clone();
let task = tokio::spawn(async move {
// 先把任务归属登记到父调用,再允许执行宿主操作
if started.await.is_err() {
return;
}
let result = handler.call(context, method, params, frame.payload).await;
if pause.is_some()
&& let Err(error) = response.resume_next(parent_id).await
{
response.cancel(parent_id, error);
return;
}
drop(pause);
let reply = match result {
Ok(reply) => Frame {
message: Message::Result {
@@ -166,9 +163,14 @@ async fn read_loop<R: AsyncRead + Unpin>(
),
})),
}
drop(permit);
});
if shared.track_callback(parent_id, task.abort_handle()) {
if shared.track_callback(
parent_id,
id,
&callback_method,
task.abort_handle(),
permit,
) {
let _ = ready.send(());
}
} else {
@@ -1,8 +1,12 @@
//! 插件双向 RPC 会话、调用结果与生命周期的公共入口
mod admission;
mod calls;
mod contract;
mod dispatch;
mod session;
pub use session::{CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSession};
pub(crate) use session::{RpcSessionDiagnostic, RpcSessionLifecycle, Shared};
pub(crate) use calls::Shared;
pub use contract::{CallbackHandler, RpcError, RpcLimits, RpcReply};
pub(crate) use contract::{RpcSessionDiagnostic, RpcSessionLifecycle, RpcSessionLoad};
pub use session::RpcSession;
@@ -1,197 +1,24 @@
//! 插件 RPC 会话的调用登记、超时取消、故障传播与关闭管理
//! 插件 RPC 会话的握手、对外调用、准入和进程关闭协调
use std::{
collections::BTreeMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
sync::{Arc, atomic::Ordering},
time::{Duration, Instant},
};
use futures::future::BoxFuture;
use gateway_host::process::{ProcessControl, ProcessSpec, ProcessStartError, ProcessSupervisor};
use gateway_host::process::{ProcessSpec, ProcessStartError, ProcessSupervisor};
use gateway_plugin_sdk::{
CallBudgetError, CallBudgetSource, CallContext, Frame, Handshake, Message, PROTOCOL_VERSION,
PluginFault, ResolvedCallBudgets, Stage,
client::{CallDeadline, CallDeadlinePause, EncodedFrame, read_frame, write_frame},
};
use tokio::sync::{
Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch,
CallBudgetSource, CallContext, Frame, Handshake, Message, PROTOCOL_VERSION,
ResolvedCallBudgets, Stage,
client::{CallDeadline, EncodedFrame, read_frame, write_frame},
};
use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore, mpsc};
use super::{
CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSessionDiagnostic, RpcSessionLifecycle,
RpcSessionLoad, Shared, dispatch,
};
use crate::{PreparedPackage, RpcStream, stream::StreamIngress};
use super::{admission::ExecutionCapacity, dispatch};
#[derive(Debug, Clone, Copy)]
pub struct RpcLimits {
/// 受管回调与观察队列的缓冲预算,不是请求正文或 IPC 消息总量上限
pub maximum_buffered_body_bytes: usize,
/// 包含初次排队、下游等待、取消确认和未消费流的总在途上限
pub maximum_calls: usize,
pub maximum_executing_calls: usize,
pub maximum_queued_calls: usize,
pub maximum_queue_timeout: Duration,
pub maximum_callbacks: usize,
pub handshake_timeout: Duration,
pub maximum_stderr_bytes: usize,
}
impl Default for RpcLimits {
fn default() -> Self {
Self {
maximum_buffered_body_bytes: 1024 * 1024,
maximum_calls: 256,
maximum_executing_calls: 64,
maximum_queued_calls: 64,
maximum_queue_timeout: Duration::from_secs(30),
maximum_callbacks: 64,
handshake_timeout: Duration::from_secs(5),
maximum_stderr_bytes: 64 * 1024,
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum RpcError {
#[error("plugin process could not be started: {0}")]
Start(#[source] ProcessStartError),
#[error("plugin handshake does not match the prepared instance")]
Handshake(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin call budget negotiation failed: {0}")]
Budget(#[from] CallBudgetError),
#[error("plugin protocol is invalid")]
Protocol(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an invalid response at {0:?}")]
InvalidResponse(Stage, #[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin process or transport has stopped")]
Closed(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin call exceeded its deadline")]
Timeout,
#[error("plugin session was cancelled")]
Cancelled,
#[error("plugin call capacity is exhausted")]
Capacity,
#[error("plugin admission capacity is exhausted")]
Overloaded,
#[error("plugin call context is invalid")]
Context(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an error")]
Remote(PluginFault),
}
impl RpcError {
pub(crate) fn is_overloaded(&self) -> bool {
matches!(self, Self::Overloaded)
|| matches!(self, Self::Remote(fault) if fault.code == gateway_plugin_sdk::ErrorCode::Capacity)
}
/// 会话状态与请求错误共用安全分类,不展开插件返回正文
pub(crate) fn diagnostic(&self) -> (&'static str, &'static str) {
match self {
RpcError::Start(_) => ("process_start", "插件进程启动失败"),
RpcError::Handshake(_) => ("handshake", "插件握手失败"),
RpcError::Budget(_) => ("call_budget", "插件调用预算协商失败"),
RpcError::Protocol(_) => ("protocol", "插件协议错误"),
RpcError::InvalidResponse(stage, _) => (
"invalid_response",
match stage {
Stage::Registration => "注册阶段:插件返回了无效响应",
Stage::Configuration => "配置阶段:插件返回了无效响应",
Stage::Authentication => "认证阶段:插件返回了无效响应",
Stage::Routing => "路由阶段:插件返回了无效响应",
Stage::Scheduling => "调度阶段:插件返回了无效响应",
Stage::Retry => "重试阶段:插件返回了无效响应",
Stage::Http => "HTTP 中间件:插件返回了无效响应",
Stage::Service => "服务中间件:插件返回了无效响应",
Stage::WebSocket => "WebSocket 中间件:插件返回了无效响应",
Stage::Request => "请求中间件:插件返回了无效响应",
Stage::Attempt => "尝试中间件:插件返回了无效响应",
Stage::Outbound => "出站准备中间件:插件返回了无效响应",
Stage::Upstream => "上游适配阶段:插件返回了无效响应",
Stage::Observation => "观察阶段:插件返回了无效响应",
Stage::Management | Stage::PublicManagement => "管理阶段:插件返回了无效响应",
Stage::CommandLine => "命令行阶段:插件返回了无效响应",
Stage::Maintenance => "维护阶段:插件返回了无效响应",
},
),
RpcError::Closed(_) => ("closed", "插件进程或传输已停止"),
RpcError::Timeout => ("timeout", "插件调用超时"),
RpcError::Cancelled => ("cancelled", "插件会话已取消"),
RpcError::Capacity => ("capacity", "插件调用容量已耗尽"),
RpcError::Overloaded => ("overloaded", "插件暂时繁忙"),
RpcError::Context(_) => ("context", "插件调用上下文无效"),
RpcError::Remote(_) => ("remote", "插件返回错误"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RpcSessionDiagnostic {
Ready,
Quiescing,
Failed {
code: &'static str,
message: &'static str,
},
}
pub(crate) struct RpcSessionLifecycle {
failure: Arc<dyn Fn(Duration) + Send + Sync>,
end: Option<Box<dyn FnOnce() + Send + Sync>>,
}
impl RpcSessionLifecycle {
pub(crate) fn new(
failure: impl Fn(Duration) + Send + Sync + 'static,
end: impl FnOnce() + Send + Sync + 'static,
) -> Self {
Self {
failure: Arc::new(failure),
end: Some(Box::new(end)),
}
}
fn fail(&self, uptime: Duration) {
(self.failure)(uptime);
}
}
impl Drop for RpcSessionLifecycle {
fn drop(&mut self) {
if let Some(end) = self.end.take() {
end();
}
}
}
/// 不派生 Debug,防止原始请求、凭据或模型输出进入宿主诊断
pub struct RpcReply {
pub result: serde_json::Value,
pub payload: Vec<u8>,
}
/// 具体回调在领域适配器校验账号、出站与执行身份;上下文只取自宿主在途调用表
pub trait CallbackHandler: Send + Sync {
/// 在 Call 可见之前建立资源范围;不得执行 I/O 或调用 RPC
fn begin(&self, _context: &CallContext, _deadline: &CallDeadline) {}
/// 调用结束或取消时立即撤销其句柄;实现必须幂等且不能阻塞
fn finish(&self, _context: &CallContext) {}
/// 首个流式结果已到达;资源仍绑定原调用并在终态统一回收
fn streaming(&self, _context: &CallContext) {}
fn call(
&self,
context: CallContext,
method: String,
params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>>;
}
pub struct RpcSession {
shared: Arc<Shared>,
handshake: Handshake,
@@ -216,366 +43,6 @@ impl RpcSession {
}
}
pub(crate) struct Shared {
state: Mutex<State>,
started_at: Instant,
expected_stop: AtomicBool,
failure_observer: Option<Arc<dyn Fn(Duration) + Send + Sync>>,
pub control: mpsc::Sender<EncodedFrame>,
pub process: ProcessControl,
pub stopped: watch::Sender<bool>,
callback_slots: Arc<Semaphore>,
next_slots: Arc<Semaphore>,
execution: ExecutionCapacity,
cancellations_changed: Notify,
}
struct State {
failure: Option<RpcError>,
calls: BTreeMap<u64, PendingCall>,
}
struct PendingCall {
_slot: Arc<OwnedSemaphorePermit>,
execution: Option<OwnedSemaphorePermit>,
deadline: CallDeadline,
handler: Arc<dyn CallbackHandler>,
transmitted: bool,
cancellation: Option<RpcError>,
context: CallContext,
result: Option<oneshot::Sender<Result<RpcReply, RpcError>>>,
stream: Option<StreamIngress>,
callbacks: Vec<tokio::task::AbortHandle>,
}
impl PendingCall {
fn finish(mut self, result: Result<RpcReply, RpcError>) {
for callback in self.callbacks.drain(..) {
callback.abort();
}
let stream = self.stream.take();
let sender = self.result.take();
// 先同步关闭调用资源,Core 才能在收到结果时看到全部发送副作用
drop(self);
if let Some(stream) = stream {
stream.finish(result.as_ref().map(|_| ()).map_err(Clone::clone));
}
if let Some(sender) = sender {
let _ = sender.send(result);
}
}
}
impl Drop for PendingCall {
fn drop(&mut self) {
self.handler.finish(&self.context);
}
}
impl Shared {
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn fail(&self, error: RpcError) {
let uptime = self.started_at.elapsed();
let (calls, failure_observer) = {
let mut state = self.state();
if state.failure.is_some() {
return;
}
state.failure = Some(error.clone());
let observer = (!self.expected_stop.load(Ordering::Acquire))
.then(|| self.failure_observer.clone())
.flatten();
(std::mem::take(&mut state.calls), observer)
};
self.execution.close();
for call in calls.into_values() {
call.finish(Err(error.clone()));
}
if let Some(observer) = failure_observer {
observer(uptime);
}
self.stopped.send_replace(true);
self.process.stop();
self.cancellations_changed.notify_waiters();
}
fn expect_stop(&self) {
self.expected_stop.store(true, Ordering::Release);
}
pub fn finish(&self, id: u64, result: Result<RpcReply, RpcError>) -> Result<(), RpcError> {
let mut state = self.state();
let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?;
if call.cancellation.is_some() {
return Ok(());
}
if matches!(&result, Err(RpcError::Remote(fault)) if fault.code == gateway_plugin_sdk::ErrorCode::Timeout)
{
log_timeout(&call.context, "plugin", "initial_reply", "remote_timeout");
}
if call.stream.is_some() && result.is_ok() {
if call.context.resource_stream {
call.deadline.clear();
}
let initial = call.result.take().ok_or(RpcError::Protocol(None))?;
call.execution.take();
call.handler.streaming(&call.context);
let _ = initial.send(result);
} else {
let pending = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?;
drop(state);
pending.finish(result);
}
Ok(())
}
pub fn stream_chunk(&self, id: u64, sequence: u64, payload: Vec<u8>) -> Result<(), RpcError> {
let mut state = self.state();
let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?;
if call.cancellation.is_some() {
return Ok(());
}
if call.result.is_some() {
return Err(RpcError::Protocol(None));
}
call.stream
.as_mut()
.ok_or(RpcError::Protocol(None))?
.receive(sequence, payload)
}
pub fn stream_end(&self, id: u64, error: Option<PluginFault>) -> Result<(), RpcError> {
let mut state = self.state();
if state
.calls
.get(&id)
.is_some_and(|call| call.cancellation.is_some())
{
return Ok(());
}
let call = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?;
drop(state);
if call.stream.is_none() || call.result.is_some() {
call.finish(Err(RpcError::Protocol(None)));
return Err(RpcError::Protocol(None));
}
let result = error.map_or_else(
|| {
Ok(RpcReply {
result: serde_json::Value::Null,
payload: Vec::new(),
})
},
|error| Err(RpcError::Remote(error)),
);
call.finish(result);
Ok(())
}
pub fn release_stream_credit(&self, id: u64, bytes: u32) -> Result<(), RpcError> {
let mut state = self.state();
// 完成后仍可消费已排队数据,无须再给已关闭的流发信用
let Some(call) = state.calls.get_mut(&id) else {
return Ok(());
};
call.stream
.as_mut()
.ok_or(RpcError::Protocol(None))?
.release(bytes)?;
drop(state);
self.send_control(Frame::control(Message::Credit {
id,
bytes,
frames: 1,
}));
Ok(())
}
pub fn context(&self, id: u64) -> Option<CallContext> {
self.state()
.calls
.get(&id)
.filter(|call| call.cancellation.is_none())
.map(|call| call.context.clone())
}
pub fn pause_next(&self, id: u64) -> Option<CallDeadlinePause> {
let mut state = self.state();
let call = state
.calls
.get_mut(&id)
.filter(|call| call.cancellation.is_none())?;
let pause = call.deadline.pause()?;
call.execution.take();
Some(pause)
}
pub async fn resume_next(&self, id: u64) -> Result<(), RpcError> {
{
let state = self.state();
let call = state
.calls
.get(&id)
.filter(|call| call.cancellation.is_none())
.ok_or(RpcError::Cancelled)?;
// 已交付响应头的流只占在途资源,不重新占用首结果执行名额
if call.result.is_none() {
return Ok(());
}
}
let permit = self.execution.resume().await?;
let mut state = self.state();
let call = state
.calls
.get_mut(&id)
.filter(|call| call.cancellation.is_none())
.ok_or(RpcError::Cancelled)?;
if call.result.is_some() {
call.execution = Some(permit);
}
Ok(())
}
pub fn is_cancelling(&self, id: u64) -> bool {
self.state()
.calls
.get(&id)
.is_some_and(|call| call.cancellation.is_some())
}
pub fn mark_transmitted(&self, id: u64) -> bool {
let mut state = self.state();
let Some(call) = state.calls.get_mut(&id) else {
return false;
};
if call.cancellation.is_some() {
return false;
}
call.transmitted = true;
true
}
/// 先撤销调用权限,再等待对端确认;只有无响应对端才终止整个 incarnation
pub fn cancel(self: &Arc<Self>, id: u64, error: RpcError) {
let mut state = self.state();
let Some(call) = state.calls.get_mut(&id) else {
return;
};
if call.cancellation.is_some() {
return;
}
if matches!(error, RpcError::Timeout) {
log_timeout(
&call.context,
"runtime",
if call.result.is_some() {
"initial_reply"
} else {
"response_stream"
},
"processing_budget_exhausted",
);
}
if !call.transmitted {
if let Some(call) = state.calls.remove(&id) {
call.finish(Err(error));
}
return;
}
call.cancellation = Some(error.clone());
call.handler.finish(&call.context);
for callback in call.callbacks.drain(..) {
callback.abort();
}
if let Some(result) = call.result.take() {
let _ = result.send(Err(error.clone()));
}
if let Some(stream) = call.stream.take() {
stream.finish(Err(error.clone()));
}
drop(state);
self.send_control(Frame::control(Message::Cancel { id }));
let shared = Arc::clone(self);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(250)).await;
if shared.is_cancelling(id) {
shared.fail(error);
}
});
}
pub fn acknowledge_cancellation(&self, id: u64) -> Result<(), RpcError> {
let mut state = self.state();
if !state
.calls
.get(&id)
.is_some_and(|call| call.cancellation.is_some())
{
return Err(RpcError::Protocol(None));
}
state.calls.remove(&id);
drop(state);
self.cancellations_changed.notify_waiters();
Ok(())
}
pub fn track_callback(&self, parent_id: u64, callback: tokio::task::AbortHandle) -> bool {
if let Some(call) = self
.state()
.calls
.get_mut(&parent_id)
.filter(|call| call.cancellation.is_none())
{
// 已完成回调的句柄不继续占用请求预算
call.callbacks.retain(|handle| !handle.is_finished());
call.callbacks.push(callback);
true
} else {
callback.abort();
false
}
}
pub fn send_control(&self, frame: Frame) {
let Ok(frame) = EncodedFrame::new(frame) else {
self.fail(RpcError::Protocol(None));
return;
};
self.send_encoded_control(frame);
}
pub(super) fn send_encoded_control(&self, frame: EncodedFrame) {
if self.control.try_send(frame).is_err() {
self.fail(RpcError::Capacity);
}
}
pub(super) fn try_callback_slot(&self, method: &str) -> Option<OwnedSemaphorePermit> {
let slots = if method == gateway_plugin_sdk::call::middleware::NEXT_METHOD {
&self.next_slots
} else {
&self.callback_slots
};
slots.clone().try_acquire_owned().ok()
}
async fn wait_for_callbacks(&self, limits: RpcLimits) {
for (slots, maximum) in [
(&self.callback_slots, limits.maximum_callbacks),
(&self.next_slots, limits.maximum_calls),
] {
if let Ok(permits) = slots.clone().acquire_many_owned(maximum as u32).await {
drop(permits);
}
}
}
}
impl RpcSession {
pub fn budget(&self, stage: Stage) -> Duration {
self.budgets.budget(stage)
@@ -586,27 +53,9 @@ impl RpcSession {
self.budgets.source(stage)
}
/// 替换维护任务前等待已取消的调用确认;未确认仍沿用会话的有界故障回收
/// 替换维护任务前确认旧调用已取消或所属进程退出
pub(crate) async fn settle_cancellations(&self) {
let ids: Vec<_> = self
.shared
.state()
.calls
.iter()
.filter_map(|(id, call)| call.cancellation.is_some().then_some(*id))
.collect();
loop {
let changed = self.shared.cancellations_changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
if !ids.iter().any(|id| self.shared.is_cancelling(*id)) {
break;
}
changed.await;
}
if !self.is_ready() {
self.shared.process.exited().await;
}
self.shared.settle_cancellations().await;
}
pub async fn start(
@@ -653,7 +102,7 @@ impl RpcSession {
|| limits.maximum_queued_calls > 256
|| limits.maximum_queue_timeout.is_zero()
|| limits.maximum_callbacks == 0
|| limits.maximum_callbacks > 256
|| limits.maximum_callbacks > 512
|| limits.maximum_buffered_body_bytes < 1024
|| limits.maximum_buffered_body_bytes > 16 * 1024 * 1024
|| limits.handshake_timeout.is_zero()
@@ -755,30 +204,14 @@ impl RpcSession {
let (data, data_receiver) = mpsc::channel(limits.maximum_calls);
let (control, control_receiver) =
mpsc::channel(limits.maximum_callbacks + 2 * limits.maximum_calls + 8);
let (stopped, _) = watch::channel(false);
let callback_slots = Arc::new(Semaphore::new(limits.maximum_callbacks));
let shared = Arc::new(Shared {
state: Mutex::new(State {
failure: None,
calls: BTreeMap::new(),
}),
started_at: Instant::now(),
expected_stop: AtomicBool::new(false),
failure_observer: lifecycle
let shared = Arc::new(Shared::new(
limits,
process,
control,
lifecycle
.as_ref()
.map(|lifecycle| Arc::clone(&lifecycle.failure)),
control,
process,
stopped,
callback_slots,
next_slots: Arc::new(Semaphore::new(limits.maximum_calls)),
execution: ExecutionCapacity::new(
limits.maximum_executing_calls,
limits.maximum_queued_calls,
limits.maximum_queue_timeout,
),
cancellations_changed: Notify::new(),
});
));
dispatch::start(
input,
output,
@@ -813,23 +246,17 @@ impl RpcSession {
#[must_use]
pub fn is_ready(&self) -> bool {
self.shared.failure_diagnostic().is_none()
}
pub(crate) fn load(&self) -> RpcSessionLoad {
self.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.failure
.is_none()
.load(self.limits, self.slots.available_permits())
}
/// 仅返回稳定分类与固定文案,绝不展开插件 fault、stderr 或请求内容
pub(crate) fn diagnostic(&self) -> RpcSessionDiagnostic {
let state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(error) = &state.failure {
let (code, message) = error.diagnostic();
if let Some((code, message)) = self.shared.failure_diagnostic() {
return RpcSessionDiagnostic::Failed { code, message };
}
if self.slots.is_closed() {
@@ -874,44 +301,6 @@ impl RpcSession {
Ok(Duration::from_millis(context.timeout_ms))
}
fn begin_call(
&self,
context: CallContext,
deadline: tokio::time::Instant,
stream: Option<StreamIngress>,
slot: Arc<OwnedSemaphorePermit>,
execution: OwnedSemaphorePermit,
) -> Result<StartedCall, RpcError> {
let id = context.call_id;
let (result, received) = oneshot::channel();
let mut state = self.shared.state();
if let Some(error) = &state.failure {
return Err(error.clone());
}
let deadline = CallDeadline::new(deadline);
self.callbacks.begin(&context, &deadline);
state.calls.insert(
id,
PendingCall {
_slot: slot,
execution: Some(execution),
deadline: deadline.clone(),
handler: self.callbacks.clone(),
transmitted: false,
cancellation: None,
context,
result: Some(result),
stream,
callbacks: Vec::new(),
},
);
Ok(StartedCall {
id,
deadline,
received,
})
}
async fn send_call(
&self,
method: &str,
@@ -929,7 +318,19 @@ impl RpcSession {
RpcError::Overloaded
}
})?);
let execution = self.shared.execution.enter().await?;
let queued_at = Instant::now();
let execution = self.shared.execution.enter().await;
tracing::debug!(
event = "plugin.rpc.admission",
instance_id = context.instance_id,
stage = ?context.stage,
request_id = context.request_id,
attempt_id = context.attempt_id,
wait_ms = queued_at.elapsed().as_millis() as u64,
outcome = execution.as_ref().map_or_else(|error| error.diagnostic().0, |_| "admitted"),
"插件 RPC 执行准入完成"
);
let execution = execution?;
let deadline = tokio::time::Instant::now() + timeout;
// FIFO 锁同时拥有 ID 与 data 入队顺序;Call/初始 Credit 入队后必须立即释放,
// 不能把插件回复或重入回调纳入串行区,也不能为等待锁重置调用期限
@@ -953,7 +354,14 @@ impl RpcSession {
let frame = EncodedFrame::new(frame).map_err(|source| {
RpcError::Context(Some(gateway_core::error::ErrorSource::new(source)))
})?;
let started = self.begin_call(context, deadline, stream, slot.clone(), execution)?;
let started = self.shared.begin_call(
context,
deadline,
stream,
slot.clone(),
execution,
self.callbacks.clone(),
)?;
let mut guard = CallGuard {
shared: Arc::clone(&self.shared),
id: started.id,
@@ -1127,36 +535,9 @@ impl Drop for CallGuard {
}
}
struct StartedCall {
id: u64,
deadline: CallDeadline,
received: oneshot::Receiver<Result<RpcReply, RpcError>>,
}
struct CompletedCall {
slot: Arc<OwnedSemaphorePermit>,
id: u64,
deadline: CallDeadline,
reply: RpcReply,
}
fn log_timeout(
context: &CallContext,
owner: &'static str,
phase: &'static str,
reason: &'static str,
) {
tracing::warn!(
event = "plugin.rpc.timeout",
owner,
phase,
instance_id = context.instance_id,
call_id = context.call_id,
request_id = context.request_id,
attempt_id = context.attempt_id,
stage = ?context.stage,
budget_ms = context.timeout_ms,
reason,
"插件 RPC 调用超时"
);
}
@@ -0,0 +1,72 @@
//! 验证回调方法精确分派,未知方法不进入其他能力的授权或资源处理
use gateway_admin::{
model::plugins::management::PluginManagementRequest, ports::plugin_management::PluginManagement,
};
use serde_json::{Value, json};
#[tokio::test]
async fn unknown_callback_methods_are_unsupported_without_affecting_known_methods() {
let Some(environment) = crate::support::environment::Environment::create().await else {
eprintln!("SKIP: plugin integration environment absent");
return;
};
let (runtime, core) = environment.plugin(json!({
"management_registration":{"routes":[{"method":"GET","path":"dispatch","request_content_types":[],"response_content_types":["application/json"]}]},
"data_queries":[
{"method":"host.unknown","query":{}},
{"method":"host.middleware.unknown","query":{}},
{"method":"host.upstream.unknown","query":{}},
{"method":"host.http.unknown","query":{}},
{"method":gateway_plugin_sdk::call::host::HTTP_DO,"query":{}},
{"method":gateway_plugin_sdk::call::middleware::NEXT_METHOD,"query":{}}
]
})).await;
let generation = core
.snapshots()
.acquire()
.unwrap()
.extensions()
.unwrap()
.clone();
let view = runtime.views(&generation).await.unwrap().remove(0);
let response = runtime
.handle(
&generation,
&view.target,
PluginManagementRequest {
request_context: None,
headers: Vec::new(),
method: "GET".into(),
path: "dispatch".into(),
query: String::new(),
content_type: None,
body: Vec::new(),
request_id: "callback-dispatch".into(),
},
)
.await
.unwrap();
let results: Vec<Value> = serde_json::from_slice(&response.body).unwrap();
let codes: Vec<_> = results
.iter()
.map(|result| result["error"].as_str().unwrap())
.collect();
assert_eq!(
codes,
[
"unsupported",
"unsupported",
"unsupported",
"unsupported",
"invalid_input",
"permission_denied"
]
);
drop(generation);
environment.release_plugin_accounts(&runtime);
runtime.shutdown().await;
drop(core);
drop(runtime);
environment.close().await;
}
@@ -1,5 +1,6 @@
//! 插件宿主回调的测试入口
mod dispatch;
mod key_facts;
mod keys;
mod log;
@@ -273,3 +273,117 @@ async fn startup_failure_of_an_unrelated_plugin_preserves_native_authentication_
}
close(environment, runtime, core).await;
}
#[tokio::test]
async fn callback_verification_reenters_the_authentication_instance_without_model_binding() {
assert_callback_identity_queries(true).await;
}
#[tokio::test]
async fn client_queries_do_not_require_model_execution_ports() {
assert_callback_identity_queries(false).await;
}
async fn assert_callback_identity_queries(include_models: bool) {
use gateway_admin::{
model::plugins::management::PluginManagementRequest,
ports::plugin_management::PluginManagement,
};
use gateway_plugin_runtime::RpcLimits;
let Some(environment) = Environment::create().await else {
return;
};
environment
.client_key("key_frontend", "sk-native-fixture")
.await;
let account = environment.account(None).await;
let marker = environment
.directory
.path()
.join("authenticated-facts.json");
let mut facts = vec![json!(
{"method":"host.data.access.check", "query":{"client_key_id":"key_frontend", "account_ids":[account.as_str(), "acct_missing"]}}
)];
if include_models {
facts.push(json!(
{"method":"host.models.list", "params":{"client_key_id":"key_frontend", "protocol":"codex", "client_version":"0.154.0"}}
));
}
let mut config = configuration(
json!({"outcome":"authenticated","principal":"external-user"}),
true,
"External controlled-fixture",
);
config["sdk_session"] = json!(true);
config["frontend_authentication_marker"] = json!(marker);
config["auth_queries"] = json!(facts);
config["management_registration"] = json!({"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]});
let mut queries = vec![json!(
{"method":"host.client.verify", "query":{"authorization":"External controlled-fixture"}}
)];
queries.extend(facts);
config["data_queries"] = json!(queries);
environment.install_plugin(config).await;
let (runtime, core) = environment
.runtime_with_identity_port(
RpcLimits {
maximum_executing_calls: 1,
maximum_queue_timeout: std::time::Duration::from_millis(500),
..RpcLimits::default()
},
crate::support::native::provider_registry(),
)
.await;
if include_models {
runtime
.bind_model_ports(
&core.nested_model_execution_port(),
&core.affinity_lookup_port(),
)
.unwrap();
}
let snapshot = core.snapshots().acquire().unwrap();
let generation = snapshot.extensions().unwrap();
let view = runtime.views(generation).await.unwrap().remove(0);
let response = runtime
.handle(
generation,
&view.target,
PluginManagementRequest {
request_context: None,
headers: Vec::new(),
method: "GET".into(),
path: "facts".into(),
query: String::new(),
content_type: None,
body: Vec::new(),
request_id: "verify-composition".into(),
},
)
.await
.unwrap();
let results: Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(results[0], json!({"client_key_id":"key_frontend"}));
let authenticated_facts: Value =
serde_json::from_slice(&std::fs::read(marker).unwrap()).unwrap();
assert_eq!(authenticated_facts[0], results[1]);
assert_eq!(
authenticated_facts[0]["accounts"],
json!([
{"account_id":account.as_str(),"allowed":true}, {"account_id":"acct_missing","allowed":false}
])
);
if include_models {
assert_eq!(authenticated_facts[1], results[2]);
// 此替身只登记执行能力,没有原生目录;查询应成功返回空目录而不是执行绑定错误
assert_eq!(results[2], json!({"models":[]}));
}
assert!(
environment
.bound_model_requests("key_frontend")
.await
.is_empty()
);
drop(snapshot);
close(environment, runtime, core).await;
}
@@ -22,7 +22,12 @@ use gateway_plugin_sdk::{Capability, Contributions, Stage};
use serde_json::{Value, json};
#[derive(Default)]
struct Facts(AtomicUsize, AtomicUsize, Option<String>);
struct Facts {
reads: AtomicUsize,
refreshes: AtomicUsize,
email: Option<String>,
runtime_account: Option<AccountRecord>,
}
fn account() -> AccountRecord {
let at = "2026-01-01T00:00:00Z".parse().unwrap();
@@ -71,7 +76,7 @@ impl PluginAccountAccess for Facts {
&self,
account: &ProviderAccountId,
) -> Result<ProviderQuota, AdminError> {
self.1.fetch_add(1, Ordering::SeqCst);
self.refreshes.fetch_add(1, Ordering::SeqCst);
if account.as_str() == "acct_missing" {
return Err(AdminError::not_found("missing account"));
}
@@ -88,19 +93,26 @@ impl PluginAccountAccess for Facts {
}
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError> {
self.0.fetch_add(1, Ordering::SeqCst);
self.reads.fetch_add(1, Ordering::SeqCst);
assert_eq!(query.limit.get(), 1);
assert_eq!(query.provider_kind.unwrap().as_str(), "openai");
assert_eq!(query.cursor.unwrap().as_str(), "acct_before");
let mut account = account();
account.email.clone_from(&self.2);
account.email.clone_from(&self.email);
Ok(PluginAccountPage {
accounts: vec![account],
next_cursor: Some(ProviderAccountId::new("acct_facts").unwrap()),
})
}
async fn get_runtime(&self, _: &ProviderAccountId) -> Result<AccountRecord, AdminError> {
panic!("事实回调只使用对应的窄查询")
async fn get_runtime(&self, id: &ProviderAccountId) -> Result<AccountRecord, AdminError> {
let account = self
.runtime_account
.as_ref()
.expect("事实回调只使用对应的窄查询");
if account.id != id.as_str() {
return Err(AdminError::not_found("fixture account is unavailable"));
}
Ok(account.clone())
}
async fn get_credential(
&self,
@@ -116,10 +128,14 @@ impl PluginAccountAccess for Facts {
panic!("不允许修改账号")
}
async fn get_quota(&self, account: &ProviderAccountId) -> Result<ProviderQuota, AdminError> {
self.0.fetch_add(1, Ordering::SeqCst);
self.reads.fetch_add(1, Ordering::SeqCst);
assert_eq!(account.as_str(), "acct_facts");
Ok(ProviderQuota {
credits: None,
credits: Some(ProviderQuotaCredits {
has_credits: true,
unlimited: false,
balance: Some("123456789.123456789".into()),
}),
plan_type: Some("private plan".into()),
observed_at: None,
refresh_token_expires_at: None,
@@ -129,17 +145,24 @@ impl PluginAccountAccess for Facts {
label: "private label".into(),
limit_id: None,
limit_name: None,
role: None,
role: Some(ProviderQuotaWindowRole::Primary),
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: Some(604800),
used_percent: None,
reset_at: None,
limit_reached: false,
limit_reached: true,
local_usage: None,
provider_data: None,
}],
limit_reached: false,
provider_data: None,
limit_reached: true,
provider_data: Some(ProviderDocument::new(
gateway_core::account::OpaqueProviderData::new(
json!({"private":"provider-internal-fixture"})
.as_object()
.unwrap()
.clone(),
),
)),
})
}
}
@@ -157,11 +180,10 @@ async fn management_facts_validate_queries_without_permission_declarations() {
Default::default(),
)
.await;
let facts = Arc::new(Facts(
AtomicUsize::new(0),
AtomicUsize::new(0),
email.map(str::to_owned),
));
let facts = Arc::new(Facts {
email: email.map(str::to_owned),
..Default::default()
});
let port: Arc<dyn PluginAccountAccess> = facts.clone();
runtime.bind_account_ports(&port).unwrap();
{
@@ -172,6 +194,7 @@ async fn management_facts_validate_queries_without_permission_declarations() {
"data_queries":[
{"method":"host.data.accounts.list","query":{"provider_id":"openai","cursor":"acct_before","limit":1}},
{"method":"host.data.quota.get","query":{"account_id":"acct_facts"}},
{"method":"host.data.quota.details","query":{"account_id":"acct_facts"}},
{"method":"host.data.accounts.list","query":{"limit":0}},
{"method":"host.data.accounts.list","query":{"limit":65535}},
{"method":"host.data.quota.get","query":{"account_id":"acct_facts","sql":"SELECT secret"}}
@@ -209,7 +232,8 @@ async fn management_facts_validate_queries_without_permission_declarations() {
.unwrap();
let result: Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(facts.0.load(Ordering::SeqCst), 2, "{result}");
assert_eq!(facts.reads.load(Ordering::SeqCst), 3, "{result}");
assert_eq!(facts.refreshes.load(Ordering::SeqCst), 0);
assert_eq!(
result[0],
json!({"schema_version":1,"accounts":[{
@@ -227,7 +251,19 @@ async fn management_facts_validate_queries_without_permission_declarations() {
json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":null,
"windows":[{"key":"weekly","window_seconds":604800,"used_percent":null,"reset_at_ms":null}]})
);
for invalid in &result.as_array().unwrap()[2..] {
let details: gateway_plugin_sdk::call::data::QuotaDetails =
serde_json::from_value(result[2].clone()).unwrap();
assert_eq!(details.observed_at_ms, None);
assert_eq!(details.windows[0].used_percent, None);
assert_eq!(details.windows[0].role.as_deref(), Some("primary"));
assert_eq!(details.windows[0].label, "private label");
assert!(details.limit_reached && details.windows[0].limit_reached);
assert_eq!(
details.credits.unwrap().balance.as_deref(),
Some("123456789.123456789")
);
assert!(!result[2].to_string().contains("provider-internal-fixture"));
for invalid in &result.as_array().unwrap()[3..] {
assert_eq!(invalid["error"], "invalid_input");
}
@@ -301,7 +337,7 @@ async fn quota_refresh_is_available_without_permission_declarations() {
.unwrap();
let result: Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(facts.1.load(Ordering::SeqCst), 4);
assert_eq!(facts.refreshes.load(Ordering::SeqCst), 4);
assert_eq!(result[0]["observed_at_ms"], Value::Null);
assert_eq!(
result[1],
@@ -323,3 +359,149 @@ async fn quota_refresh_is_available_without_permission_declarations() {
super::wait_until_empty(cache.path()).await;
}
}
async fn management_queries(accounts: Arc<dyn PluginAccountAccess>, queries: Value) -> Value {
let (cache, store, runtime) =
super::setup_with_contributions(Contributions::from([crate::support::contribution(
Capability::Management,
vec![Stage::Management],
vec![],
vec![],
)]))
.await;
runtime.bind_account_ports(&accounts).unwrap();
store.snapshot.lock().unwrap().instances[0].configuration = json!({
"management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]},
"data_queries":queries,
"sdk_session":true,
});
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
let view = runtime.views(&generation).await.unwrap().remove(0);
let response = runtime
.handle(
&generation,
&view.target,
PluginManagementRequest {
request_context: None,
headers: vec![],
method: "GET".into(),
path: "facts".into(),
query: String::new(),
content_type: None,
body: vec![],
request_id: "account-http-fixture".into(),
},
)
.await
.unwrap();
let result = serde_json::from_slice(&response.body).unwrap();
drop(generation);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
result
}
async fn request_head(stream: &mut tokio::net::TcpStream) -> String {
use tokio::io::AsyncReadExt as _;
let mut bytes = Vec::new();
while !bytes.ends_with(b"\r\n\r\n") {
assert!(bytes.len() < 16 * 1024);
bytes.push(stream.read_u8().await.unwrap());
}
String::from_utf8(bytes).unwrap()
}
#[tokio::test]
async fn account_http_uses_the_selected_proxy_and_rejects_unavailable_account_versions() {
use tokio::io::AsyncWriteExt as _;
for scenario in [
"plain",
"explicit-auth",
"rotated",
"disabled",
"proxy-failed",
"missing",
] {
let target = wiremock::MockServer::start().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = gateway_core::account::OutboundProxy::parse(&format!(
"http://{}",
listener.local_addr().unwrap()
))
.unwrap();
let successful = matches!(scenario, "plain" | "explicit-auth");
let proxy_task = if successful {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = request_head(&mut socket).await;
assert!(request.starts_with("GET http://127.0.0.1:"));
socket
.write_all(
b"HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
request
}))
} else {
drop(listener);
None
};
let mut selected = account();
selected.outbound_proxy = Some(proxy);
selected.enabled = scenario != "disabled";
if scenario == "rotated" {
selected.credential_revision = Revision::new(2).unwrap();
}
let accounts: Arc<dyn PluginAccountAccess> = Arc::new(Facts {
runtime_account: Some(selected),
..Default::default()
});
let headers = if scenario == "explicit-auth" {
vec![("authorization", b"Bearer plugin-fixture".to_vec())]
} else {
vec![]
};
let results = management_queries(accounts, json!([{
"method":gateway_plugin_sdk::call::host::ACCOUNT_HTTP,
"params":{
"account_id":if scenario == "missing" {"acct_missing"} else {"acct_facts"},
"credential_revision":1,
"request":{"method":"GET","url":format!("{}/facts", target.uri()),"headers":headers},
},
}])).await;
if let Some(task) = proxy_task {
assert_eq!(results[0]["status"], 201, "{scenario}: {results}");
let request = tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap()
.to_ascii_lowercase();
assert!(
request.starts_with("get http://127.0.0.1:") && request.contains("/facts http/1.1"),
"{request}"
);
assert_eq!(
request.contains("authorization:"),
scenario == "explicit-auth"
);
if scenario == "explicit-auth" {
assert!(request.contains("authorization: bearer plugin-fixture\r\n"));
}
} else {
let expected = match scenario {
"rotated" => "conflict",
"disabled" => "permission_denied",
"missing" => "rejected",
_ => "upstream",
};
assert_eq!(results[0]["error"], expected, "{scenario}: {results}");
}
assert!(
target.received_requests().await.unwrap().is_empty(),
"{scenario} must not connect directly"
);
}
}
@@ -881,6 +881,74 @@ async fn invalid_client_key_and_group_scopes_are_rejected_before_publish() {
}
}
#[tokio::test]
async fn observer_call_load_tracks_the_active_lease_until_completion() {
use gateway_admin::ports::plugins::PluginRuntimeDiagnostics;
let markers = tempfile::tempdir().unwrap();
let started = markers.path().join("started.jsonl");
let completed = markers.path().join("completed.jsonl");
let (cache, store, runtime) = setup(
vec![(
"tracked",
serde_json::json!({
"observation_started_marker": started,
"observation_marker": completed,
"observation_delay_ms": 200,
}),
vec![binding(EventKind::RequestCompleted, 0)],
)],
RpcLimits::default(),
)
.await;
let snapshot = store.snapshot.lock().unwrap().clone();
let generation = gateway_core::routing::extensions::ExtensionPreparationPort::prepare(
&runtime,
ConfigRevision::new(1).unwrap(),
)
.await
.unwrap();
let plan = runtime.observer_registry().resolve(&generation).unwrap();
plan.dispatch(
generation.clone(),
observation("req_tracked", RequestObservationOutcome::Succeeded)
.with_provider(ProviderKind::new("openai").unwrap()),
);
wait_for_lines(&started, 1).await;
let read_load = || async {
runtime
.runtime_diagnostics(&snapshot, Some(1), Some(&generation))
.await
.unwrap()
.remove("tracked")
.unwrap()
.load
.expect("published session has a load snapshot")
};
let load = read_load().await;
assert_eq!(load.active_calls, 1);
assert_eq!(load.executing_calls, 1);
assert_eq!(load.queued_calls, 0);
assert_eq!(load.pending_callbacks, 0);
assert_eq!(load.active_streams, 0);
wait_for_lines(&completed, 1).await;
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let load = read_load().await;
if load.active_calls == 0 && load.executing_calls == 0 {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("completed calls release their observed resource occupancy");
drop(plan);
drop(generation);
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn observer_backpressure_drops_excess_and_inflight_dispatch_keeps_generation_alive() {
let marker_directory = tempfile::tempdir().unwrap();
@@ -2,7 +2,9 @@
use super::wait_until_unready;
use gateway_admin::model::Revision;
use gateway_admin::model::plugins::instances::PluginInstanceRuntimeStatus;
use gateway_admin::model::plugins::instances::{
PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus,
};
use gateway_admin::ports::plugins::PluginPreparation;
use gateway_core::routing::ConfigRevision;
use gateway_core::routing::extensions::ExtensionPreparationPort;
@@ -31,6 +33,17 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat
Some(snapshot.instances[0].artifact_sha256.as_str())
);
assert!(running.failure.is_none());
assert_eq!(
running.load,
Some(PluginInstanceRuntimeLoad {
active_calls: 0,
executing_calls: 0,
queued_calls: 0,
resuming_calls: 0,
pending_callbacks: 0,
active_streams: 0,
})
);
let mut failed = snapshot.clone();
failed.config_revision = Revision::new(2).unwrap();
@@ -56,6 +69,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat
);
assert!(failed_runtime.failure.is_some());
assert_eq!(failed_runtime.actual_revision, Some(1));
assert_eq!(failed_runtime.load, running.load);
let mut disabled = failed;
disabled.config_revision = Revision::new(3).unwrap();
@@ -105,6 +119,7 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() {
diagnostics["instance-one"].status,
PluginInstanceRuntimeStatus::Preparing
);
assert!(diagnostics["instance-one"].load.is_none());
let candidate = preparation.await.expect("delayed candidate");
drop(candidate);
@@ -29,7 +29,7 @@ fn limits() -> RpcLimits {
maximum_executing_calls: 1,
maximum_queued_calls: 1,
maximum_queue_timeout: Duration::from_millis(200),
maximum_callbacks: 1,
maximum_callbacks: 4,
..Default::default()
}
}
@@ -145,12 +145,13 @@ async fn next_resumption_does_not_need_a_vacant_initial_queue_slot() {
.await
}
});
started.recv().await.unwrap();
// 等待 next 不挤占唯一的普通回调额度
let mut occupying = Box::pin(call(&session, "callback"));
assert!(futures::poll!(&mut occupying).is_pending());
started.recv().await.unwrap();
occupying.await.unwrap();
tokio::time::timeout(Duration::from_secs(2), started.recv())
.await
.expect("next callback should start")
.unwrap();
// 回调通知可能先于 Suspend 到达,持续驱动普通调用才能在名额释放后完成
call(&session, "callback").await.unwrap();
started.try_recv().unwrap();
let mut active = Box::pin(call(&session, "hang"));
assert!(futures::poll!(&mut active).is_pending());
let mut queued = Box::pin(call(&session, "echo"));
@@ -165,7 +166,13 @@ async fn next_resumption_does_not_need_a_vacant_initial_queue_slot() {
assert!(!waiting.is_finished());
drop(active);
assert!(queued.await.is_ok());
assert!(waiting.await.unwrap().is_ok());
assert!(
tokio::time::timeout(Duration::from_secs(2), waiting)
.await
.expect("next call should resume after execution capacity is released")
.unwrap()
.is_ok()
);
assert!(session.is_ready());
session.shutdown(Duration::from_secs(1)).await;
}
@@ -326,3 +333,246 @@ async fn resumption_queue_wait_preserves_the_remaining_plugin_processing_budget(
assert!(session.is_ready());
session.shutdown(Duration::from_secs(1)).await;
}
async fn scheduled<C: CallbackHandler + 'static>(
callbacks: Arc<C>,
mut limits: RpcLimits,
) -> (tempfile::TempDir, Arc<RpcSession>) {
let (cache, prepared, mut handshake, processes) = prepared_session();
handshake.configuration = json!({"sdk_session": true});
limits.maximum_queue_timeout = Duration::from_secs(2);
let session = RpcSession::start(prepared, handshake, limits, &processes, callbacks)
.await
.unwrap();
(cache, Arc::new(session))
}
struct CrossCallbacks {
other: std::sync::Mutex<std::sync::Weak<RpcSession>>,
ready: Arc<tokio::sync::Barrier>,
}
impl CallbackHandler for CrossCallbacks {
fn call(
&self,
_context: CallContext,
_method: String,
_params: serde_json::Value,
_payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
let other = self.other.lock().unwrap().upgrade().unwrap();
let ready = self.ready.clone();
Box::pin(async move {
ready.wait().await;
other
.call(
"echo",
other.context(Stage::Request, Duration::from_secs(2)),
json!("child"),
Vec::new(),
)
.await
.map_err(|_| PluginFault::new(gateway_plugin_sdk::ErrorCode::Fault, "child failed"))
})
}
}
#[tokio::test]
async fn independent_sessions_release_execution_for_every_callback_method() {
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let left = Arc::new(CrossCallbacks {
other: Default::default(),
ready: barrier.clone(),
});
let right = Arc::new(CrossCallbacks {
other: Default::default(),
ready: barrier,
});
let (_left_cache, a) = scheduled(left.clone(), limits()).await;
let (_right_cache, b) = scheduled(right.clone(), limits()).await;
*left.other.lock().unwrap() = Arc::downgrade(&b);
*right.other.lock().unwrap() = Arc::downgrade(&a);
for method in [
"host.service.call",
"host.http.dispatch",
"host.model.execute",
"host.client.verify",
NEXT_METHOD,
"host.unclassified",
] {
let invoke = |session: Arc<RpcSession>| async move {
session
.call(
"callback",
session.context(Stage::Request, Duration::from_secs(2)),
json!({"method":method}),
Vec::new(),
)
.await
};
let (a_result, b_result) = tokio::time::timeout(Duration::from_secs(3), async {
tokio::join!(invoke(a.clone()), invoke(b.clone()))
})
.await
.unwrap();
assert_eq!(a_result.unwrap().result, json!("child"), "{method}");
assert_eq!(b_result.unwrap().result, json!("child"), "{method}");
}
a.shutdown(Duration::from_secs(1)).await;
b.shutdown(Duration::from_secs(1)).await;
}
struct PartialCallbacks {
entered: mpsc::UnboundedSender<String>,
fast: Arc<Semaphore>,
slow: Arc<Semaphore>,
}
impl CallbackHandler for PartialCallbacks {
fn call(
&self,
_context: CallContext,
method: String,
_params: serde_json::Value,
_payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
self.entered.send(method.clone()).unwrap();
let gate = match method.as_str() {
"fast" => Some(self.fast.clone()),
"slow" => Some(self.slow.clone()),
_ => None,
};
Box::pin(async move {
if let Some(gate) = gate {
gate.acquire().await.unwrap().forget();
}
Ok(RpcReply {
result: json!(method),
payload: Vec::new(),
})
})
}
}
#[tokio::test]
async fn a_partial_callback_result_requires_a_permit_and_can_wait_again() {
let (entered, mut events) = mpsc::unbounded_channel();
let fast = Arc::new(Semaphore::new(0));
let slow = Arc::new(Semaphore::new(0));
let (cache, session) = scheduled(
Arc::new(PartialCallbacks {
entered,
fast: fast.clone(),
slow: slow.clone(),
}),
limits(),
)
.await;
let parent = tokio::spawn({
let session = session.clone();
async move { call(&session, "partial").await }
});
for _ in 0..2 {
tokio::time::timeout(Duration::from_secs(2), events.recv())
.await
.unwrap()
.unwrap();
}
let marker = cache.path().join("processing");
let busy = tokio::spawn({
let session = session.clone();
let marker = marker.clone();
async move {
session
.call(
"busy",
session.context(Stage::Request, Duration::from_secs(2)),
json!({"marker":marker, "delay_ms":300}),
Vec::new(),
)
.await
}
});
tokio::time::timeout(Duration::from_secs(2), async {
while !marker.exists() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
fast.add_permits(1);
assert!(
tokio::time::timeout(Duration::from_millis(50), events.recv())
.await
.is_err()
);
busy.await.unwrap().unwrap();
assert_eq!(
tokio::time::timeout(Duration::from_secs(2), events.recv())
.await
.unwrap()
.as_deref(),
Some("processed")
);
assert!(!parent.is_finished());
// 再次等待慢回调后,独立调用可取得同一个执行名额
assert!(call(&session, "echo").await.is_ok());
slow.add_permits(1);
assert_eq!(parent.await.unwrap().unwrap().result, json!("complete"));
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn polling_wakeups_and_cancelled_suspensions_leave_the_session_usable() {
let (entered, mut events) = mpsc::unbounded_channel();
let slow = Arc::new(Semaphore::new(0));
let (_cache, session) = scheduled(
Arc::new(PartialCallbacks {
entered,
fast: Arc::new(Semaphore::new(0)),
slow,
}),
RpcLimits {
maximum_calls: 1,
..limits()
},
)
.await;
assert_eq!(
call(&session, "yield").await.unwrap().result,
json!("yielded")
);
let parent = tokio::spawn({
let session = session.clone();
async move {
session
.call(
"callback",
session.context(Stage::Request, Duration::from_secs(2)),
json!({"method":"slow"}),
Vec::new(),
)
.await
}
});
assert_eq!(events.recv().await.as_deref(), Some("slow"));
assert!(matches!(
call(&session, "echo").await,
Err(RpcError::Overloaded)
));
parent.abort();
assert!(matches!(parent.await, Err(error) if error.is_cancelled()));
tokio::time::timeout(Duration::from_secs(2), async {
loop {
match call(&session, "echo").await {
Ok(_) => break,
Err(RpcError::Overloaded) => tokio::task::yield_now().await,
Err(error) => panic!("{error}"),
}
}
})
.await
.unwrap();
assert!(session.is_ready());
session.shutdown(Duration::from_secs(1)).await;
}
@@ -0,0 +1,54 @@
//! 验证调用表在成功、远端错误和超时的结果交付前同步撤销回调资源
use std::{
sync::{Arc, atomic::Ordering},
time::Duration,
};
use gateway_plugin_runtime::{RpcError, RpcLimits, RpcSession};
use gateway_plugin_sdk::Stage;
use serde_json::json;
use super::session::{LifecycleCallbacks, prepared_session};
#[tokio::test]
async fn terminal_results_revoke_callback_resources_before_waking_callers() {
for method in ["echo", "deny", "hang"] {
let (_cache, prepared, handshake, processes) = prepared_session();
let callbacks = Arc::new(LifecycleCallbacks::default());
let session = RpcSession::start(
prepared,
handshake,
RpcLimits::default(),
&processes,
callbacks.clone(),
)
.await
.unwrap();
let timeout = if method == "hang" {
Duration::from_millis(50)
} else {
Duration::from_secs(2)
};
let result = session
.call(
method,
session.context(Stage::Request, timeout),
json!({}),
Vec::new(),
)
.await;
match method {
"echo" => assert!(result.is_ok()),
"deny" => assert!(matches!(result, Err(RpcError::Remote(_)))),
"hang" => assert!(matches!(result, Err(RpcError::Timeout))),
_ => unreachable!(),
}
assert_eq!(callbacks.begun.load(Ordering::Relaxed), 1);
assert!(
callbacks.finished.load(Ordering::Relaxed) >= 1,
"调用结果已经交付,资源权限必须先同步撤销:{method}"
);
session.shutdown(Duration::from_secs(1)).await;
}
}
@@ -1,5 +1,6 @@
//! 插件双向 RPC 分派与会话生命周期的测试入口
mod admission;
mod calls;
mod dispatch;
mod session;
@@ -31,7 +31,7 @@ struct BlockingCallbacks {
#[derive(Default)]
pub(super) struct LifecycleCallbacks {
pub(super) begun: AtomicUsize,
finished: AtomicUsize,
pub(super) finished: AtomicUsize,
}
struct OversizedReplyCallbacks;
@@ -463,6 +463,21 @@ impl Environment {
&self,
limits: RpcLimits,
providers: gateway_core::engine::provider::ProviderRegistry,
) -> (Arc<PluginRuntime>, gateway_core::CoreBundle) {
let (runtime, core) = self.runtime_with_identity_port(limits, providers).await;
runtime
.bind_model_ports(
&core.nested_model_execution_port(),
&core.affinity_lookup_port(),
)
.unwrap();
(runtime, core)
}
pub async fn runtime_with_identity_port(
&self,
limits: RpcLimits,
providers: gateway_core::engine::provider::ProviderRegistry,
) -> (Arc<PluginRuntime>, gateway_core::CoreBundle) {
let runtime = self.plugin_runtime_with_limits(limits);
let core = gateway_core::prepare(
@@ -487,10 +502,7 @@ impl Environment {
.insert(Arc::as_ptr(&runtime) as usize, access);
let core = core.activate().await.unwrap();
runtime
.bind_model_ports(
&core.nested_model_execution_port(),
&core.affinity_lookup_port(),
)
.bind_client_identity_port(&core.client_identity_port())
.unwrap();
(runtime, core)
}
@@ -511,6 +523,9 @@ impl Environment {
.await
.unwrap();
runtime.bind_snapshots(&core.snapshots()).unwrap();
runtime
.bind_client_identity_port(&core.client_identity_port())
.unwrap();
runtime
.bind_model_ports(
&core.nested_model_execution_port(),
@@ -23,6 +23,8 @@ use gateway_plugin_sdk::call::{
upstream_adapter::{UpstreamAdapterEvent, UpstreamAdapterRequest},
};
mod scheduled;
type CallbackResult = Result<(Value, Vec<u8>), PluginFault>;
struct PendingCallback {
parent: u64,
@@ -82,6 +84,7 @@ struct Peer {
output: mpsc::Sender<Frame>,
callbacks: Mutex<BTreeMap<u64, PendingCallback>>,
next_callback: AtomicU64,
resumptions: Mutex<BTreeMap<u64, oneshot::Sender<()>>>,
streams: Mutex<BTreeMap<u64, Arc<Credits>>>,
}
@@ -90,18 +93,21 @@ impl Peer {
let queries = self.configuration["data_queries"].as_array()?;
let mut results = Vec::new();
for query in queries {
let metadata_query = query["method"] == "host.keys.list";
let metadata_query =
query["method"] == "host.keys.list" || query.get("params").is_some();
let result = self
.callback_payload(
id,
query["method"].as_str().unwrap(),
if metadata_query {
query["query"].clone()
query.get("params").unwrap_or(&query["query"]).clone()
} else {
json!({})
},
if metadata_query {
vec![]
query["body"]
.as_str()
.map_or_else(Vec::new, |body| body.as_bytes().to_vec())
} else {
serde_json::to_vec(&query["query"]).unwrap()
},
@@ -243,6 +249,36 @@ impl Peer {
self.callback_payload(parent, method, params, vec![]).await
}
async fn send_callback(
&self,
parent: u64,
method: &str,
params: Value,
payload: Vec<u8>,
) -> (u64, oneshot::Receiver<CallbackResult>) {
let (response, received) = oneshot::channel();
let mut callbacks = self.callbacks.lock().await;
let id = self.next_callback.fetch_add(2, Ordering::Relaxed);
callbacks.insert(
id,
PendingCallback {
parent,
response: Some(response),
},
);
self.send(
Message::Callback {
id,
parent_id: parent,
method: method.into(),
params,
},
payload,
)
.await;
(id, received)
}
async fn callback_payload(
&self,
parent: u64,
@@ -250,29 +286,17 @@ impl Peer {
params: Value,
payload: Vec<u8>,
) -> CallbackResult {
let (response, received) = oneshot::channel();
{
let mut callbacks = self.callbacks.lock().await;
let id = self.next_callback.fetch_add(2, Ordering::Relaxed);
callbacks.insert(
let (id, received) = self.send_callback(parent, method, params, payload).await;
let result = received.await.unwrap();
self.send(
Message::CallbackConsumed {
id,
PendingCallback {
parent,
response: Some(response),
},
);
self.send(
Message::Callback {
id,
parent_id: parent,
method: method.into(),
params,
},
payload,
)
.await;
}
received.await.unwrap()
parent_id: parent,
},
vec![],
)
.await;
result
}
async fn run_nested_model_fixture(
@@ -1349,31 +1373,43 @@ impl Peer {
"slow" => tokio::time::sleep(Duration::from_millis(400)).await,
"hang" | "hang_uncancellable" => return,
"crash" => std::process::exit(7),
"callback" | "callback_method" | "forged_callback" => {
"callback" | "callback_method" => {
let callback_method = if method == "callback_method" {
params["method"].as_str().unwrap().to_owned()
} else {
"host.log".into()
};
// 分配与入队保持同一顺序,避免并发回调制造不合法 ID 序列
let mut callbacks = self.callbacks.lock().await;
let callback = self.next_callback.fetch_add(2, Ordering::Relaxed);
callbacks.insert(
callback,
PendingCallback {
parent: id,
response: None,
let (callback, received) = self
.send_callback(id, &callback_method, params, payload)
.await;
self.send(Message::Suspend { id, epoch: 1 }, vec![]).await;
let result = received.await.unwrap();
let (resumed, granted) = oneshot::channel();
self.resumptions.lock().await.insert(id, resumed);
self.send(Message::Resume { id, epoch: 1 }, vec![]).await;
granted.await.unwrap();
self.send(
Message::CallbackConsumed {
id: callback,
parent_id: id,
},
);
vec![],
)
.await;
match result {
Ok((result, payload)) => {
self.send(Message::Result { id, result }, payload).await
}
Err(error) => self.send(Message::Error { id, error }, vec![]).await,
}
return;
}
"forged_callback" => {
self.send(
Message::Callback {
id: callback,
parent_id: if method == "forged_callback" {
999999
} else {
id
},
method: callback_method,
id: self.next_callback.fetch_add(2, Ordering::Relaxed),
parent_id: 999999,
method: "host.log".into(),
params,
},
payload,
@@ -1426,7 +1462,7 @@ impl Peer {
}
}
#[tokio::main(flavor = "current_thread")]
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
let mut input = tokio::io::stdin();
let (output, mut frames) = mpsc::channel::<Frame>(64);
@@ -1437,6 +1473,11 @@ async fn main() {
}
});
let hello = read_frame(&mut input).await.unwrap();
if matches!(&hello.message, Message::Hello { handshake } if handshake.configuration["sdk_session"] == true)
{
scheduled::run(hello, input).await;
return;
}
let Message::Hello { mut handshake } = hello.message else {
panic!("expected handshake")
};
@@ -1491,6 +1532,7 @@ async fn main() {
output,
callbacks: Mutex::new(BTreeMap::new()),
next_callback: AtomicU64::new(2),
resumptions: Mutex::new(BTreeMap::new()),
streams: Mutex::new(BTreeMap::new()),
});
let exit_after_ready = peer.configuration["exit_after_ready_delays_ms"]
@@ -1603,6 +1645,11 @@ async fn main() {
),
);
}
Message::Resumed { id, epoch: 1 } => {
if let Some(resumed) = peer.resumptions.lock().await.remove(&id) {
let _ = resumed.send(());
}
}
Message::Cancel { id } => {
if uncancellable.contains(&id) {
continue;
@@ -0,0 +1,173 @@
//! 使用公开 SDK 驱动真实子进程,覆盖受管任务的并发与恢复
use gateway_plugin_sdk::{
ErrorCode, Frame, PluginFault,
client::{
CallFuture, CallReply, PluginCall, PluginHandler, PluginSession, SessionConfig, write_frame,
},
};
use serde_json::json;
use tokio::io::AsyncReadExt as _;
pub(super) async fn run(hello: Frame, input: tokio::io::Stdin) {
let mut prefix = Vec::new();
write_frame(&mut prefix, &hello).await.unwrap();
let input = std::io::Cursor::new(prefix).chain(input);
let session = PluginSession::accept(input, tokio::io::stdout(), SessionConfig::default())
.await
.unwrap();
let handler = Handler {
configuration: session.handshake().configuration.clone(),
contributes: session.handshake().contributes.clone(),
};
session.run(handler).await.unwrap();
}
struct Handler {
configuration: serde_json::Value,
contributes: gateway_plugin_sdk::Contributions,
}
impl Handler {
async fn queries(&self, call: &PluginCall, key: &str) -> Vec<serde_json::Value> {
let mut results = Vec::new();
for query in self.configuration[key].as_array().unwrap() {
let metadata = query.get("params").is_some() || query["method"] == "host.keys.list";
let params = if metadata {
query.get("params").unwrap_or(&query["query"]).clone()
} else {
json!({})
};
let payload = if metadata {
query["body"]
.as_str()
.map_or_else(Vec::new, |body| body.as_bytes().to_vec())
} else {
serde_json::to_vec(&query["query"]).unwrap()
};
let result = call
.host
.call(query["method"].as_str().unwrap(), params, payload)
.await;
results.push(match result {
Ok(reply) if metadata => {
assert!(reply.payload.is_empty());
reply.result
}
Ok(reply) => {
assert_eq!(reply.result, json!({}));
serde_json::from_slice(&reply.payload).unwrap()
}
Err(error) => json!({"error":error.into_plugin_fault().code}),
});
}
results
}
}
impl PluginHandler for Handler {
fn call(&self, call: PluginCall) -> CallFuture<'_> {
Box::pin(async move {
match call.method.as_str() {
"plugin.register" => Ok(CallReply::unary(
serde_json::to_value(gateway_plugin_sdk::call::registration::Registration {
contributes: self.contributes.clone(),
})
.unwrap(),
Vec::new(),
)),
"management.register" => Ok(CallReply::unary(
json!({}),
serde_json::to_vec(&self.configuration["management_registration"]).unwrap(),
)),
"frontend_auth.identifier" => Ok(CallReply::unary(
json!({"identifier":"fixture-auth"}),
Vec::new(),
)),
"frontend_auth.authenticate" => {
let request: gateway_plugin_sdk::call::frontend_authentication::FrontendAuthenticationRequest = serde_json::from_slice(&call.payload).unwrap();
assert_eq!(
request.authorization,
self.configuration["expected_frontend_authorization"]
.as_str()
.unwrap()
);
let facts = if self.configuration.get("auth_queries").is_some() {
self.queries(&call, "auth_queries").await
} else {
Vec::new()
};
if let Some(path) =
self.configuration["frontend_authentication_marker"].as_str()
{
std::fs::write(path, serde_json::to_vec(&facts).unwrap()).unwrap();
}
Ok(CallReply::unary(
json!({}),
serde_json::to_vec(&self.configuration["frontend_authentication_result"])
.unwrap(),
))
}
"management.handle" => {
let results = self.queries(&call, "data_queries").await;
Ok(CallReply::unary(
json!({"status":200,"content_type":"application/json"}),
serde_json::to_vec(&results).unwrap(),
))
}
"callback" => {
let reply = call
.host
.call(
call.params["method"].as_str().unwrap(),
json!({}),
Vec::new(),
)
.await
.map_err(|error| error.into_plugin_fault())?;
Ok(CallReply::unary(reply.result, reply.payload))
}
"partial" => {
let fast = Box::pin(call.host.call("fast", json!({}), Vec::new()));
let slow = Box::pin(call.host.call("slow", json!({}), Vec::new()));
let (first, remaining) = match futures::future::select(fast, slow).await {
futures::future::Either::Left(pair)
| futures::future::Either::Right(pair) => pair,
};
first.map_err(|error| error.into_plugin_fault())?;
call.host
.call("processed", json!({}), Vec::new())
.await
.map_err(|error| error.into_plugin_fault())?;
remaining.await.map_err(|error| error.into_plugin_fault())?;
Ok(CallReply::unary(json!("complete"), Vec::new()))
}
"busy" => {
std::fs::write(call.params["marker"].as_str().unwrap(), "entered").unwrap();
// 保持一次 poll 未返回,代表确实占用执行名额的工作
std::thread::sleep(std::time::Duration::from_millis(
call.params["delay_ms"].as_u64().unwrap(),
));
Ok(CallReply::unary(json!("busy complete"), Vec::new()))
}
"yield" => {
for _ in 0..16 {
tokio::task::yield_now().await;
}
Ok(CallReply::unary(json!("yielded"), Vec::new()))
}
"drop_callback" => {
let mut pending = Box::pin(call.host.call("slow", json!({}), Vec::new()));
assert!(futures::poll!(&mut pending).is_pending());
drop(pending);
Ok(CallReply::unary(json!("dropped"), Vec::new()))
}
"echo" => Ok(CallReply::unary(call.params, call.payload)),
_ => Err(PluginFault::new(
ErrorCode::InvalidInput,
"unknown fixture method",
)),
}
})
}
}
+27 -9
View File
@@ -68,9 +68,13 @@ sequenceDiagram
H->>P: 注册能力,核对清单
P-->>H: 注册结果
H->>P: Call(ID、上下文、载荷)
opt 需要宿主资源
opt 父处理等待宿主回调
P->>H: 回调(关联父调用 ID)
P->>H: Suspend(父处理 Pending)
H-->>P: 回调结果
P->>H: Resume
H-->>P: Resumed(取得执行名额)
P->>H: CallbackConsumed
end
P-->>H: 结果或错误;流以 End 结束
```
@@ -78,14 +82,17 @@ sequenceDiagram
### 并发与期限
每个宿主插件会话默认允许 64 个执行中的 RPC 调用、64 个初次排队调用和 256 个总在途上下文。
初次排队和等待 `next` 后的恢复排队各限 30 秒,不扣插件自身的处理预算。
恢复等待占总在途名额,不占初次排队名额。
等待 `next` 时释放执行名额并暂停处理预算,返回后重新取得名额,继续同一次调用;
已返回响应头的流继续占用在途预算,不占首结果执行名额。具体组合行为见[洋葱中间件](docs/capabilities.md#洋葱中间件)
初次排队和恢复排队各限 30 秒;恢复等待占总在途名额,不占初次排队名额
插件侧 `SessionConfig.maximum_calls` 默认 256,限制包含下游等待和响应流在内的在途调用;
`maximum_callbacks` 默认 64,限制普通宿主回调。宿主普通回调的默认上限同样为 64,
`next` 使用总在途上限对应的独立回调预算,不挤占普通回调容量
SDK 只在父处理 future 返回 `Pending` 后报告挂起,Runtime 随即归还执行名额。
收到唤醒后,SDK 请求恢复,取得授权才继续驱动父任务;并行回调只完成一个也遵守这一边界。
等待普通回调及其恢复排队继续计入处理预算;只有父任务挂起且驻留回调全部为 `next` 时暂停预算。
外部取消、请求期限和排队上限独立生效,调度不保证插件自行创建的线程、任务或锁能够推进
插件侧 `SessionConfig.maximum_calls` 默认 256,限制包含下游等待和响应流在内的在途调用。
SDK 和宿主的 `maximum_callbacks` 默认 320、最大 512,统一限制全部宿主回调,包含已返回但尚未消费的结果。
取消或丢弃回调 future 会释放该回调;宿主不保证已发出的业务操作可以撤销,不自动重试或补偿。
已返回响应头的流继续占用总在途预算,不占首结果执行名额,由正文或连接的 owner 管理真实寿命
实际容量由宿主预算与 SDK 会话配置共同限制,较小的插件侧上限会提前拒绝新调用。
达到容量或等待上限时,中间件返回过载错误;HTTP 响应为 503(`middleware_capacity_unavailable`),
@@ -144,7 +151,7 @@ session.run(handler).await?;
首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。
普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束
协议 v6 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
协议 v7 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付
@@ -165,6 +172,17 @@ SDK 帧读写器自动拆装这些字段,不拼接复制二进制正文。`Mes
底层帧读写方法不是取消安全的;自行编排会话时,不能在 `select!` 的其他分支完成后
重建半途中的帧读写。`PluginSession` 会在处理调用完成通知时保留同一个读取 future,直到完整帧到达或会话关闭
RPC v7 的调度控制消息没有载荷:`Suspend { id, epoch }` 归还执行名额,`Resume { id, epoch }`
请求同一轮恢复,宿主 `Resumed { id, epoch }` 授权后才能继续首结果前的处理。`epoch` 从 1 递增,
每次挂起与恢复严格配对。插件发出的控制消息和业务帧使用同一有界 FIFO,保证回调登记先于挂起
`CallbackConsumed { id, parent_id }` 表示回调结果已消费或 future 已丢弃,宿主据此释放驻留额度并取消尚未完成的回调。
结果接收本身不恢复父任务、不释放驻留额度;父调用终结统一回收剩余回调。
取消、恢复授权和结果可能交错,终结后的消息不得重新打开调用
宿主只接受 RPC v7,不提供旧协议兼容或降级。使用旧 SDK 的插件须更新 SDK、重新构建并由 CLI 生成安装包后安装,
只修改清单中的版本号不能满足任务挂起、恢复和回调回收合同
插件须校验 `Hello` 的协议版本。一次 `Call` 的结果、错误和流帧按 ID 关联;实例、代次、阶段、账号或资源 ID 用于关联调用和管理资源生命周期
## 本地验证
@@ -81,11 +81,14 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。宿主开放以下 11 类扩
| SDK 方法 | 回调 | 查询与结果 |
| --- | --- | --- |
| `call.host.account_facts(query)` | `host.data.accounts.list` | `AccountFactsQuery`:可选 `provider_id`、`cursor`,必填 `limit`(1~200);按账号 ID 升序,`next_cursor=null` 表示本页已结束 |
| `call.host.quota_details(query)` | `host.data.quota.details` | `QuotaFactsQuery { account_id }`:读取完整额度事实,包含窗口归属、触顶和点数 |
| `call.host.check_access(query)` | `host.data.access.check` | `AccessQuery { client_key_id, account_ids, upstream_model }`:读取 Key 对指定账号及可选上游模型的冻结授权 |
| `call.host.verify_client(request)` | `host.client.verify` | `VerifyClientRequest { authorization }`:复用入口认证,返回 `VerifiedClient { client_key_id }` |
| `call.host.quota_facts(query)` | `host.data.quota.get` | `QuotaFactsQuery { account_id }`:读取 Provider 现有观测,不访问上游刷新 |
| `call.host.key_facts(query)` | `host.data.keys.get` | `ClientKeyFactsQuery { client_key_id }`:读取当前 Key 的启用状态及显式分组 ID,不返回密钥 |
类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;结果固定 `schema_version=1`。
SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗口;已知字段按声明校验类型与必填性,查询拒绝未知字段。
类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;事实结果固定 `schema_version=1`,认证结果只包含 `client_key_id`。
SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗口;已知字段按声明校验类型与必填性,账号、Key 与额度查询拒绝未知字段,认证与权限查询忽略未知字段。
插件须通过 `engines.codex-proxy-rs` 限定支持所用接口和字段的宿主版本
`key_facts` 每次读取当前管理数据,返回 `client_key_id`、`enabled`、`group_ids`;不存在的 Key 沿用事实接口的 `rejected` 错误
@@ -93,10 +96,22 @@ SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗
`group_ids` 表示显式绑定,包含停用分组,不是最终可路由账号集合;空绑定也不代表单账号范围。通过 `account_facts` 关联账号时需完整遍历分页,结果包含停用账号。跨查询关联及同步策略由插件负责,这些调用不构成跨查询事务,管理员修改绑定后应重新检查。
账号仅返回 `account_id`、`provider_id`、`name`、`email`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带令牌或代理信息。
`name` 和 `email` 来自宿主已保存的账号资料;没有邮箱时 `email=null`,读取不会请求上游个人信息。
额度仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。
`quota_facts` 仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。
时间均为 UTC Unix 毫秒,比例为百分数;未知值保留 `null`,不能解释为 0。`observed_at_ms=null` 表示没有可用观测时间,
不保证当前缓存新鲜,不提供历史快照或多个查询之间的原子一致性
`quota_details` 保留 Provider 已解释的 `limit_reached`、窗口 `group`、`label`、`limit_id`、`limit_name`、`role`
及点数 `has_credits`、`unlimited`、`balance`;缺失值仍为 `null`,不返回上游原始文档,不发起刷新
`verify_client` 使用当前请求设置和原认证计划,包含外部 principal 到 Key 的映射;没有 HTTP 入口时使用当前发布配置。
认证可以调用认证插件,继续扣减父预算并响应取消;验证成功不做推理准入、账号租约、使用记录或计费。
授权原文只作本次认证输入,不进入返回值
`check_access` 与 `host.models.list`、显式 Key 模型执行共用父调用内的身份和冻结权限快照。
账号或模型不在冻结范围内时返回 `allowed=false`,不存在或不可用的 Key 沿用 Core 的身份拒绝。
它们不建立模型执行图,不受模型递归深度或当前实例已入链影响;健康、冷却、额度、租约与实时可用性由实际执行检查。
`upstream_model` 使用上游模型名,省略时只判断账号范围;返回结果不承诺后续请求一定可以执行
```rust,ignore
use gateway_plugin_sdk::call::data::{AccountFactsQuery, QuotaFactsQuery};
let page = call.host.account_facts(AccountFactsQuery {
@@ -217,8 +232,8 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限
原有 8 MiB 编码上限、Runtime 缓冲预算及流控窗口限制
HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt、outbound、路由、调度、重试和上游适配器
沿用该次模型请求冻结的正文预算。显式 Key 模型调用的预检与执行使用同一绑定快照,
同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新绑定才读取新配置,无需重新启用插件
沿用该次模型请求冻结的正文预算。显式 Key 模型调用的预检与执行使用同一身份快照,
同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新解析身份才读取新配置,无需重新启用插件
`ModelExecuteRequest.client_key_id` 可显式选择执行 Key,不与触发插件的父请求 Key 求交。省略时继承现有模型父请求身份;没有模型父请求的调用必须提供 Key。所选 Key 的启用状态、模型规则、账号组、并发和预算继续由 Core 执行
嵌套调用与显式 Key 调用继承入口快照中的客户端 IP 和 User-Agent;没有 HTTP 入口的后台调用保持来源未知
@@ -238,12 +253,17 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。取消一次 `read()` 等待后,再次读取会继续同一次操作,不丢弃已经返回的分块。
账号上游使用同一正文对象,使用方式见[受管上游](upstream-adapters.md)
`HostClient::account_http(AccountHttpRequest { account_id, credential_revision, request }, body)` 使用指定账号的当前代理配置,
经 `host.http.account` 返回同一 `HostHttpBody`。每次发送核对账号存在、启用和凭据版本,版本变化须由插件重新读取后决定下一步。
请求 headers 与正文完全由插件提供;宿主不注入账号认证、不申请推理租约,代理不可用时直接失败,不回退直连。
这项能力与上游适配器的账号推理 HTTP 分开,适用于插件自行构造的账号网络请求
请求和响应 `headers` 使用 `(String, Vec<u8>)`,保留重复头与非 UTF-8 值;响应另有 `http_version`。
读取至 EOF 后通过 `body.trailers()` 取得完整尾部头,读取中只包含已收到的部分。
`collect` 只返回正文,需要尾部头时使用 `read`;底层非流式回复携带 `trailers`,流式回复在 `stream_read` 中交付尾部头
`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。
受管 HTTP(包括 `host.upstream.http.do[_stream]`)的请求正文按有效设置
受管 HTTP(包括 `host.http.account` 和 `host.upstream.http.do[_stream]`)的请求正文按有效设置
`responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置;
attempt、outbound、路由、调度、重试和上游适配器使用执行冻结预算,其余调用读取当前发布配置。
超限返回带 HTTP 413 的 `invalid_input` 和 `not_sent`,不向目标发送请求。
@@ -275,7 +295,7 @@ attempt、outbound、路由、调度、重试和上游适配器使用执行冻
`next.run(input)` 消费自身,最多执行一次;结果按相反顺序经过外层。重试或独立子请求通过宿主执行服务发起
调用上下文的 `timeout_ms` 限制插件自身处理时间,入站与返回处理合计使用同一预算。
等待 `next` 时暂停该预算并释放执行名额;恢复后继续同一次调用,普通宿主回调不会暂停计时。
父处理任务真正挂起时释放执行名额,恢复取得授权后继续同一次调用。仅等待 `next` 时暂停处理预算;并行包含普通回调时继续计时,具体规则见[并发与期限](../README.md#并发与期限)。
排队、在途容量与过载行为见 [SDK 并发与期限](../README.md#并发与期限)。
请求总期限、下游操作超时和取消信号独立生效,客户端取消或插件退出会结束等待并回收资源
@@ -550,7 +570,7 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))?
.build()?;
```
按 binding 顺序、插件 ID、实例 ID 确定委托链,首个非委托决定交给 Core 处理。整条链最多等待 2 秒,并受请求剩余期限约束。
按 binding 顺序、插件 ID、实例 ID 确定委托链,首个非委托决定交给 Core 处理。整条链共享匹配实例中最大的重试阶段预算,并受请求剩余期限约束;单个实例同时受自身声明预算约束。未声明时使用默认的 2 秒,预算规则见 [并发与期限](../README.md#并发与期限)。
退出、超时或无法解码的决定委托后续处理,最终回到宿主决定;额外字段忽略,故障不改写原始上游错误。
middleware 的 `next` 最多调用一次,重试与额外模型执行通过独立调用创建
@@ -26,7 +26,7 @@
| 版本字段 | 当前值 | 对应合同 |
| --- | --- | --- |
| `manifestVersion` / `manifest_schema_versions` | `2` | 插件清单格式 |
| `package.protocolVersion` / `protocol_versions` | `6` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
| `package.protocolVersion` / `protocol_versions` | `7` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
| `contributes.<capability>.version` / `capabilities[].versions` | 见[扩展项简写](#扩展项简写) | 指定扩展能力的行为合同 |
| 宿主声明的 `schema_version` | `2` | 宿主兼容声明自身的格式 |
@@ -34,7 +34,7 @@
清单不接受 `permissions` 字段;插件按[完整信任](#完整信任)运行
RPC v6 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
RPC v7 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本;
数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。
@@ -1,5 +1,5 @@
//! 基础事实查询与主动额度刷新
//! 响应忽略未知字段,以兼容宿主新增事实;查询仍严格校验字段
//! 入口认证、权限与账号额度事实,以及主动额度刷新合同
//! 响应忽略未知字段,以兼容宿主新增事实
use serde::{Deserialize, Serialize};
@@ -7,6 +7,44 @@ pub const ACCOUNTS_LIST: &str = "host.data.accounts.list";
pub const QUOTA_REFRESH: &str = "host.quota_observations.refresh";
pub const QUOTA_GET: &str = "host.data.quota.get";
pub const KEYS_GET: &str = "host.data.keys.get";
pub const CLIENT_VERIFY: &str = "host.client.verify";
pub const ACCESS_CHECK: &str = "host.data.access.check";
pub const QUOTA_DETAILS: &str = "host.data.quota.details";
/// 交给宿主入口认证计划的凭据;只通过二进制载荷传输,不提供内容型 Debug
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifyClientRequest {
pub authorization: String,
}
/// 宿主认证与 principal 映射后的执行身份,不包含 Key 明文
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifiedClient {
pub client_key_id: String,
}
/// 候选账号的冻结权限查询;上游模型名不解析为公开模型别名
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessQuery {
pub client_key_id: String,
pub account_ids: Vec<String>,
pub upstream_model: Option<String>,
}
/// 授权结果不代表账号健康、额度或执行准入结果
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessFacts {
pub schema_version: u32,
pub client_key_id: String,
/// 与查询候选保持相同顺序
pub accounts: Vec<AccountAccessFacts>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountAccessFacts {
pub account_id: String,
pub allowed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -72,3 +110,37 @@ pub struct QuotaWindowFacts {
pub used_percent: Option<f64>,
pub reset_at_ms: Option<i64>,
}
/// Provider 已解析的窗口与点数事实,不包含原始 Provider 文档或宿主预测
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuotaDetails {
pub schema_version: u32,
pub account_id: String,
pub observed_at_ms: Option<i64>,
pub limit_reached: bool,
pub windows: Vec<QuotaWindowDetails>,
pub credits: Option<QuotaCredits>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuotaWindowDetails {
pub key: String,
pub group: String,
pub label: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
/// 百分比而非 0~1 比率;未知值为 null
pub used_percent: Option<f64>,
pub reset_at_ms: Option<i64>,
pub limit_reached: bool,
}
/// 余额沿用上游的十进制文本,不转为浮点数或把未知值解释为零
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuotaCredits {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
@@ -1,10 +1,30 @@
//! 宿主回调的数据合同;这里只传递数据,不持有宿主服务或执行资源
//! 宿主回调的方法名与数据合同;不持有宿主服务或执行资源
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::model::ExecutionEvent;
pub const LOG: &str = "host.log";
pub const STATE_GET: &str = "host.state.get";
pub const STATE_PUT: &str = "host.state.put";
pub const STATE_DELETE: &str = "host.state.delete";
pub const KEYS_LIST: &str = "host.keys.list";
pub const MODELS_LIST: &str = "host.models.list";
pub const MODEL_EXECUTE: &str = "host.model.execute";
pub const MODEL_EXECUTE_STREAM: &str = "host.model.execute_stream";
pub const MODEL_STREAM_READ: &str = "host.model.stream_read";
pub const MODEL_STREAM_CLOSE: &str = "host.model.stream_close";
pub const AFFINITY_LOOKUP: &str = "host.affinity.lookup";
pub const AUTH_LIST: &str = "host.auth.list";
pub const AUTH_GET: &str = "host.auth.get";
pub const AUTH_GET_RUNTIME: &str = "host.auth.get_runtime";
pub const AUTH_SAVE: &str = "host.auth.save";
pub const HTTP_DO: &str = "host.http.do";
pub const HTTP_DO_STREAM: &str = "host.http.do_stream";
pub const HTTP_STREAM_READ: &str = "host.http.stream_read";
pub const HTTP_STREAM_CLOSE: &str = "host.http.stream_close";
const MODEL_EVENT_BATCH_PREFIX: [u8; 4] = *b"HME1";
const MAX_MODEL_EVENT_BATCH_BYTES: usize = 8 * 1024 * 1024;
@@ -66,6 +86,16 @@ pub struct HttpRequest {
pub headers: Vec<(String, Vec<u8>)>,
}
pub const ACCOUNT_HTTP: &str = "host.http.account";
/// 显式选择账号代理和凭据版本;认证头仍由插件提供,不取得推理租约
#[derive(Clone, Serialize, Deserialize)]
pub struct AccountHttpRequest {
pub account_id: String,
pub credential_revision: u64,
pub request: HttpRequest,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpResponse {
@@ -9,6 +9,15 @@ mod codec;
pub const REGISTER_METHOD: &str = "upstream_adapter.register";
pub const EXECUTE_METHOD: &str = "upstream_adapter.execute";
pub const HTTP_DO: &str = "host.upstream.http.do";
pub const HTTP_DO_STREAM: &str = "host.upstream.http.do_stream";
pub const HTTP_STREAM_READ: &str = "host.upstream.http.stream_read";
pub const HTTP_STREAM_CLOSE: &str = "host.upstream.http.stream_close";
pub const WEBSOCKET_OPEN: &str = "host.upstream.websocket.open";
pub const WEBSOCKET_SEND: &str = "host.upstream.websocket.send";
pub const WEBSOCKET_READ: &str = "host.upstream.websocket.read";
pub const WEBSOCKET_CLOSE: &str = "host.upstream.websocket.close";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BuiltinProvider {
@@ -5,6 +5,28 @@ use crate::{PluginFault, call::data};
use super::{HostClient, payload_call};
impl HostClient {
/// 通过宿主入口认证计划校验凭据并取得执行身份,不执行模型或记录使用事实
///
/// # Errors
/// 凭据无效、认证失败或父调用取消时返回错误
pub async fn verify_client(
&self,
request: data::VerifyClientRequest,
) -> Result<data::VerifiedClient, PluginFault> {
payload_call(self, data::CLIENT_VERIFY, request).await
}
/// 读取所选 Key 对候选账号及上游模型的有效权限,不判断健康、额度或租约
///
/// # Errors
/// 身份不可用、查询无效或父调用取消时返回错误
pub async fn check_access(
&self,
query: data::AccessQuery,
) -> Result<data::AccessFacts, PluginFault> {
payload_call(self, data::ACCESS_CHECK, query).await
}
/// 读取 Key 当前启用状态与显式分组绑定,不返回密钥
///
/// # Errors
@@ -52,4 +74,15 @@ impl HostClient {
) -> Result<data::QuotaFacts, PluginFault> {
payload_call(self, data::QUOTA_GET, query).await
}
/// 读取已有额度窗口标签、触顶与点数事实,不触发刷新或补算
///
/// # Errors
/// 账号不存在或宿主读取失败时返回错误
pub async fn quota_details(
&self,
query: data::QuotaFactsQuery,
) -> Result<data::QuotaDetails, PluginFault> {
payload_call(self, data::QUOTA_DETAILS, query).await
}
}
@@ -5,7 +5,10 @@ use serde::{Serialize, de::DeserializeOwned};
use super::{HostClient, HostReply, SessionError, read::PendingRead};
use crate::{
ErrorCode, PluginFault,
call::{host as wire, upstream_adapter::UpstreamHttpRequest},
call::{
host as wire,
upstream_adapter::{self, UpstreamHttpRequest},
},
};
/// 响应头与惰性正文;不包含宿主的内部流句柄
@@ -35,7 +38,21 @@ impl HostClient {
request: wire::HttpRequest,
body: Vec<u8>,
) -> Result<HostHttpResponse, PluginFault> {
self.open_http("host.http.do_stream", request, body, false)
self.open_http(wire::HTTP_DO_STREAM, request, body, false)
.await
}
/// 使用指定账号的当前代理发起请求,认证头由调用方显式提供
/// 账号必须启用且凭据版本匹配;代理失败不回退直连
///
/// # Errors
/// 账号不可用、版本冲突、期限或网络请求失败时返回错误
pub async fn account_http(
&self,
request: wire::AccountHttpRequest,
body: Vec<u8>,
) -> Result<HostHttpResponse, PluginFault> {
self.open_http(wire::ACCOUNT_HTTP, request, body, false)
.await
}
@@ -48,7 +65,7 @@ impl HostClient {
request: UpstreamHttpRequest,
body: Vec<u8>,
) -> Result<HostHttpResponse, PluginFault> {
self.open_http("host.upstream.http.do_stream", request, body, true)
self.open_http(upstream_adapter::HTTP_DO_STREAM, request, body, true)
.await
}
@@ -110,9 +127,9 @@ impl HostHttpBody {
let host = self.host.clone();
let params = serde_json::json!({"stream": stream, "maximum_bytes": maximum_bytes});
let method = if self.upstream {
"host.upstream.http.stream_read"
upstream_adapter::HTTP_STREAM_READ
} else {
"host.http.stream_read"
wire::HTTP_STREAM_READ
};
async move { host.call(method, params, vec![]).await }
})
@@ -169,9 +186,9 @@ impl HostHttpBody {
empty_callback(
&self.host,
if self.upstream {
"host.upstream.http.stream_close"
upstream_adapter::HTTP_STREAM_CLOSE
} else {
"host.http.stream_close"
wire::HTTP_STREAM_CLOSE
},
wire::StreamClose {
stream: stream.clone(),
@@ -3,7 +3,7 @@
use crate::{
ErrorCode, PluginFault,
call::{
host::{KeyListRequest, KeyListResult},
host::{KEYS_LIST, KeyListRequest, KeyListResult},
key_budgets,
},
};
@@ -41,7 +41,7 @@ impl HostClient {
let invalid = || PluginFault::new(ErrorCode::InvalidInput, "invalid key list payload");
let reply = self
.call(
"host.keys.list",
KEYS_LIST,
serde_json::to_value(query).map_err(|_| invalid())?,
Vec::new(),
)
@@ -6,6 +6,7 @@ use super::SessionError;
const MAXIMUM_STREAM_CHUNK_BYTES: usize = 16 * 1024 * 1024;
const MAXIMUM_CONCURRENCY: usize = 256;
const MAXIMUM_CALLBACKS: usize = 512;
const MAXIMUM_BUFFERED_STREAM_CHUNKS: usize = 65_536;
/// 插件侧会话的内存、并发与期限边界
@@ -14,6 +15,7 @@ pub struct SessionConfig {
/// 单个业务流分块的预算,不限制普通调用正文的总长度
pub maximum_stream_chunk_bytes: usize,
pub maximum_calls: usize,
/// 包含等待下游与尚未消费结果的全部宿主回调
pub maximum_callbacks: usize,
pub maximum_buffered_stream_chunks: usize,
pub handshake_timeout: Duration,
@@ -25,7 +27,7 @@ impl Default for SessionConfig {
// 流分块还需匹配宿主授予的信用窗口
maximum_stream_chunk_bytes: 1024 * 1024,
maximum_calls: 256,
maximum_callbacks: 64,
maximum_callbacks: 320,
maximum_buffered_stream_chunks: 1_024,
handshake_timeout: Duration::from_secs(5),
}
@@ -39,7 +41,7 @@ impl SessionConfig {
|| self.maximum_calls == 0
|| self.maximum_calls > MAXIMUM_CONCURRENCY
|| self.maximum_callbacks == 0
|| self.maximum_callbacks > MAXIMUM_CONCURRENCY
|| self.maximum_callbacks > MAXIMUM_CALLBACKS
|| self.maximum_buffered_stream_chunks == 0
|| self.maximum_buffered_stream_chunks > MAXIMUM_BUFFERED_STREAM_CHUNKS
|| self.handshake_timeout.is_zero()
@@ -1,13 +1,21 @@
//! 宿主与插件共用的调用处理预算,受管续体等待期间暂停并保留剩余额度
use std::time::Duration;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{sync::watch, time::Instant};
use tokio::{sync::Notify, time::Instant};
/// 同一次调用的所有等待点共享此期限,避免回调恢复后仍使用旧截止时间
#[derive(Clone)]
pub struct CallDeadline {
state: watch::Sender<State>,
shared: Arc<Shared>,
}
struct Shared {
state: Mutex<State>,
changed: Notify,
}
#[derive(Clone, Copy)]
@@ -25,13 +33,22 @@ pub struct CallDeadlinePause {
impl CallDeadline {
#[must_use]
pub fn new(deadline: Instant) -> Self {
let (state, _) = watch::channel(State::Running(deadline));
Self { state }
Self {
shared: Arc::new(Shared {
state: Mutex::new(State::Running(deadline)),
changed: Notify::new(),
}),
}
}
#[must_use]
pub fn remaining(&self) -> Option<Duration> {
match *self.state.borrow() {
match *self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
{
State::Running(at) => Some(at.saturating_duration_since(Instant::now())),
State::Paused(remaining) => Some(remaining),
State::Unlimited => None,
@@ -47,32 +64,46 @@ impl CallDeadline {
/// 只有尚未耗尽的运行预算可以暂停;重复 next 不能重置或复活预算
#[must_use]
pub fn pause(&self) -> Option<CallDeadlinePause> {
self.state
.send_if_modified(|state| {
let State::Running(at) = *state else {
return false;
};
let remaining = at.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return false;
}
*state = State::Paused(remaining);
true
})
.then(|| CallDeadlinePause {
deadline: self.clone(),
})
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let State::Running(at) = *state else {
return None;
};
let remaining = at.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return None;
}
*state = State::Paused(remaining);
// 暂停只延后期限,无需唤醒处理任务;旧计时器到期会重新核对状态
// 否则父任务会把自身停表通知误当成业务唤醒,反复请求恢复
Some(CallDeadlinePause {
deadline: self.clone(),
})
}
/// 初始资源流响应已交付,后续期限由资源 owner 持有
pub fn clear(&self) {
self.state.send_replace(State::Unlimited);
*self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = State::Unlimited;
self.shared.changed.notify_waiters();
}
pub async fn wait(&self) {
let mut state = self.state.subscribe();
loop {
let current = *state.borrow_and_update();
let changed = self.shared.changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
let current = *self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let elapsed = async {
match current {
State::Running(at) => tokio::time::sleep_until(at).await,
@@ -81,7 +112,7 @@ impl CallDeadline {
};
tokio::select! {
biased;
_ = state.changed() => {}
() = changed => {}
() = elapsed => {
// 暂停可与旧计时器唤醒并发,返回前重新核对当前预算
if self.is_elapsed() {
@@ -95,12 +126,16 @@ impl CallDeadline {
impl Drop for CallDeadlinePause {
fn drop(&mut self) {
self.deadline.state.send_if_modified(|state| {
let State::Paused(remaining) = *state else {
return false;
};
let mut state = self
.deadline
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let State::Paused(remaining) = *state {
*state = State::Running(Instant::now() + remaining);
true
});
drop(state);
self.deadline.shared.changed.notify_waiters();
}
}
}
@@ -23,6 +23,7 @@ use super::{
lifecycle::{CancellationSource, LifecycleHooks},
output::{Output, writer_loop},
registry::CallbackRegistry,
scheduling::CallSchedule,
stream::validate_stream_chunk,
};
@@ -107,12 +108,13 @@ where
.config
.maximum_calls
.checked_add(self.config.maximum_callbacks)
.and_then(|capacity| capacity.checked_add(self.config.maximum_calls))
.ok_or(SessionError::Configuration)?;
let control_reserve = self
.config
.maximum_calls
.checked_add(8)
.checked_mul(2)
.and_then(|capacity| capacity.checked_add(self.config.maximum_callbacks))
.and_then(|capacity| capacity.checked_add(8))
.ok_or(SessionError::Configuration)?;
let queue_capacity = data_capacity
.checked_add(control_reserve)
@@ -121,17 +123,15 @@ where
let output = Output {
sender: outbound,
data_slots: Arc::new(Semaphore::new(data_capacity)),
failed: tokio::sync::watch::channel(false).0,
};
let callbacks = Arc::new(CallbackRegistry::new(
self.config.maximum_callbacks,
self.config.maximum_calls,
));
let callbacks = Arc::new(CallbackRegistry::new(self.config.maximum_callbacks));
let handler: Arc<dyn PluginHandler> = Arc::new(handler);
let lifecycle = LifecycleHooks::new(Arc::clone(&handler), control_reserve)?;
write_frame(
&mut self.writer,
&Frame::control(Message::Ready {
protocol_version: PROTOCOL_VERSION,
protocol_version: self.handshake.protocol_version,
incarnation: self.handshake.incarnation.clone(),
call_budgets: self.call_budgets,
}),
@@ -167,6 +167,7 @@ struct ActiveCall {
credits: Arc<CreditWindow>,
cancellation: CancellationSource,
task: JoinHandle<()>,
schedule: Arc<CallSchedule>,
}
#[derive(Clone, Copy)]
@@ -243,6 +244,7 @@ impl SessionDriver {
let frame = loop {
tokio::select! {
biased;
() = self.output.failed() => return Err(SessionError::Capacity),
writer = &mut self.writer => {
return writer.map_err(|_| SessionError::Closed)?;
}
@@ -296,6 +298,16 @@ impl SessionDriver {
}
Ok(false)
}
Message::Resumed { id, epoch } if frame.payload.is_empty() => {
if id == 0 || id.is_multiple_of(2) || id > self.last_call || epoch == 0 {
return Err(SessionError::Protocol);
}
// 取消和完成可能先于同一授权抵达,已退休调用不能被重新打开
if let Some(active) = self.active.get(&id) {
active.schedule.resumed(epoch)?;
}
Ok(false)
}
Message::Cancel { id } if frame.payload.is_empty() => {
self.cancel_call(id)?;
Ok(false)
@@ -367,6 +379,7 @@ impl SessionDriver {
let deadline = CallDeadline::new(deadline);
let credits = Arc::new(CreditWindow::default());
let (cancellation, cancellation_signal) = CancellationSource::new();
let schedule = Arc::new(CallSchedule::new(id, self.output.clone(), deadline.clone()));
let host = HostClient {
parent_id: id,
deadline: deadline.clone(),
@@ -375,6 +388,7 @@ impl SessionDriver {
output: self.output.clone(),
maximum_stream_chunk_bytes: self.config.maximum_stream_chunk_bytes,
credits: credits.clone(),
schedule: schedule.clone(),
};
let call = PluginCall {
method,
@@ -392,6 +406,8 @@ impl SessionDriver {
let maximum_chunks = self.config.maximum_buffered_stream_chunks;
let task_context = context.clone();
let lifecycle = self.lifecycle.clone();
let task_schedule = schedule.clone();
let callbacks = Arc::clone(&self.callbacks);
let task = tokio::spawn(async move {
let guard = CompletionGuard::new(id, completed);
let result = run_call(
@@ -404,6 +420,8 @@ impl SessionDriver {
deadline,
maximum_stream_chunk_bytes,
maximum_chunks,
task_schedule,
callbacks,
)
.await;
guard.finish(if result.is_ok() {
@@ -419,6 +437,7 @@ impl SessionDriver {
credits,
cancellation,
task,
schedule,
},
);
Ok(())
@@ -478,9 +497,12 @@ async fn run_call(
deadline: CallDeadline,
maximum_stream_chunk_bytes: usize,
maximum_chunks: usize,
schedule: Arc<CallSchedule>,
callbacks: Arc<CallbackRegistry>,
) -> Result<(), SessionError> {
let id = context.call_id;
let cancellation = call.cancellation.clone();
let response = schedule.drive(handler.call(call), || callbacks.pauses_budget(id));
let response = tokio::select! {
biased;
() = cancellation.cancelled() => return Ok(()),
@@ -492,7 +514,7 @@ async fn run_call(
))?;
return Ok(());
}
response = handler.call(call) => response,
response = response => response?,
};
let mut reply = match response {
Ok(reply) => reply,
@@ -9,6 +9,7 @@ mod error;
mod lifecycle;
mod output;
mod registry;
mod scheduling;
mod stream;
pub use call::{CallFuture, CallReply, PluginCall, PluginHandler};
@@ -4,7 +4,7 @@ use std::sync::Arc;
use tokio::{
io::AsyncWrite,
sync::{OwnedSemaphorePermit, Semaphore, mpsc},
sync::{OwnedSemaphorePermit, Semaphore, mpsc, watch},
};
use crate::Frame;
@@ -15,6 +15,7 @@ use super::{CallCancellation, CallDeadline, SessionError};
pub(super) struct Output {
pub(super) sender: mpsc::Sender<Outbound>,
pub(super) data_slots: Arc<Semaphore>,
pub(super) failed: watch::Sender<bool>,
}
impl Clone for Output {
@@ -22,6 +23,7 @@ impl Clone for Output {
Self {
sender: self.sender.clone(),
data_slots: Arc::clone(&self.data_slots),
failed: self.failed.clone(),
}
}
}
@@ -65,7 +67,16 @@ impl Output {
frame,
_data_slot: None,
})
.map_err(|_| SessionError::Capacity)
.map_err(|_| {
// drop 路径也会提交资源确认,队列耗尽不能静默丢失生命周期消息
self.failed.send_replace(true);
SessionError::Capacity
})
}
pub(super) async fn failed(&self) {
let mut failed = self.failed.subscribe();
let _ = failed.wait_for(|failed| *failed).await;
}
}
@@ -12,8 +12,8 @@ use crate::{Frame, Message, call::middleware::NEXT_METHOD};
use super::super::frame::EncodedFrame;
use super::{
CallCancellation, CallDeadline, CallDeadlinePause, SessionError, credit::CreditWindow,
output::Output,
CallCancellation, CallDeadline, SessionError, credit::CreditWindow, output::Output,
scheduling::CallSchedule,
};
/// 宿主回调的成功结果;不实现 `Debug`,避免载荷进入普通诊断
@@ -32,6 +32,7 @@ pub struct HostClient {
pub(super) output: Output,
pub(super) maximum_stream_chunk_bytes: usize,
pub(super) credits: Arc<CreditWindow>,
pub(super) schedule: Arc<CallSchedule>,
}
impl HostClient {
@@ -75,6 +76,16 @@ impl HostClient {
};
let managed_next = method == NEXT_METHOD;
let (id, received) = self.callbacks.begin(self.parent_id, managed_next)?;
let mut guard = CallbackGuard {
id,
parent_id: self.parent_id,
callbacks: &self.callbacks,
output: &self.output,
sent: false,
};
if !managed_next {
self.schedule.charge_wait();
}
let frame = Frame {
message: Message::Callback {
id,
@@ -84,32 +95,23 @@ impl HostClient {
},
payload,
};
let frame = EncodedFrame::new(frame).map_err(|_| {
self.callbacks.retire(id);
SessionError::Protocol
})?;
let frame = EncodedFrame::new(frame).map_err(|_| SessionError::Protocol)?;
let sent = tokio::select! {
biased;
() = self.cancellation.cancelled() => Err(SessionError::Cancelled),
() = self.deadline.wait() => Err(SessionError::Timeout),
result = self.output.send_data(frame) => result,
};
if let Err(error) = sent {
self.callbacks.retire(id);
return Err(error);
}
if managed_next {
self.callbacks.pause_next(id, &self.deadline);
}
sent?;
guard.sent = true;
self.callbacks.mark_sent(id);
drop(ordered);
tokio::select! {
biased;
() = self.cancellation.cancelled() => {
self.callbacks.retire(id);
Err(SessionError::Cancelled)
}
() = self.deadline.wait() => {
self.callbacks.retire(id);
Err(SessionError::Timeout)
}
result = received => result.map_err(|_| SessionError::Closed)?,
@@ -121,7 +123,6 @@ pub(super) struct CallbackRegistry {
state: Mutex<CallbackState>,
order: tokio::sync::Mutex<()>,
maximum: usize,
maximum_next: usize,
maximum_retired: usize,
}
@@ -135,12 +136,33 @@ struct CallbackState {
struct PendingCallback {
parent_id: u64,
managed_next: bool,
pause: Option<CallDeadlinePause>,
result: oneshot::Sender<Result<HostReply, SessionError>>,
sent: bool,
result: Option<oneshot::Sender<Result<HostReply, SessionError>>>,
}
struct CallbackGuard<'a> {
id: u64,
parent_id: u64,
callbacks: &'a CallbackRegistry,
output: &'a Output,
sent: bool,
}
impl Drop for CallbackGuard<'_> {
fn drop(&mut self) {
if self.callbacks.retire(self.id) && self.sent {
let _ = self
.output
.send_control(Frame::control(Message::CallbackConsumed {
id: self.id,
parent_id: self.parent_id,
}));
}
}
}
impl CallbackRegistry {
pub(super) fn new(maximum: usize, maximum_next: usize) -> Self {
pub(super) fn new(maximum: usize) -> Self {
Self {
state: Mutex::new(CallbackState {
next_id: 2,
@@ -150,8 +172,7 @@ impl CallbackRegistry {
}),
order: tokio::sync::Mutex::new(()),
maximum,
maximum_next,
maximum_retired: (maximum + maximum_next).saturating_mul(2),
maximum_retired: maximum.saturating_mul(2),
}
}
@@ -164,18 +185,7 @@ impl CallbackRegistry {
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let maximum = if managed_next {
self.maximum_next
} else {
self.maximum
};
if state
.pending
.values()
.filter(|pending| pending.managed_next == managed_next)
.count()
>= maximum
{
if state.pending.len() >= self.maximum {
return Err(SessionError::Capacity);
}
let id = state.next_id;
@@ -186,35 +196,54 @@ impl CallbackRegistry {
PendingCallback {
parent_id,
managed_next,
result,
pause: None,
sent: false,
result: Some(result),
},
);
Ok((id, received))
}
fn pause_next(&self, id: u64, deadline: &CallDeadline) {
fn mark_sent(&self, id: u64) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(pending) = state.pending.get_mut(&id) {
pending.pause = deadline.pause();
if let Some(callback) = state.pending.get_mut(&id) {
callback.sent = true;
}
}
pub(super) fn pauses_budget(&self, parent_id: u64) -> bool {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut has_next = false;
for callback in state
.pending
.values()
.filter(|call| call.parent_id == parent_id)
{
if !callback.managed_next || !callback.sent {
return false;
}
has_next = true;
}
has_next
}
pub(super) fn resolve(&self, frame: Frame) -> Result<(), SessionError> {
let id = match &frame.message {
Message::Result { id, .. } | Message::Error { id, .. } => *id,
_ => return Err(SessionError::Protocol),
};
let pending = {
let sender = {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(pending) = state.pending.remove(&id) {
Some(pending)
if let Some(pending) = state.pending.get_mut(&id) {
Some(pending.result.take().ok_or(SessionError::Protocol)?)
} else if state.retired.remove(&id) {
state.retired_order.retain(|retired| *retired != id);
None
@@ -222,7 +251,7 @@ impl CallbackRegistry {
return Err(SessionError::Protocol);
}
};
let Some(mut pending) = pending else {
let Some(sender) = sender else {
return Ok(());
};
let result = match frame.message {
@@ -235,20 +264,22 @@ impl CallbackRegistry {
}
_ => return Err(SessionError::Protocol),
};
// 收到宿主结果就恢复预算,不依赖插件再次轮询 next future
pending.pause.take();
let _ = pending.result.send(result);
let _ = sender.send(result);
Ok(())
}
fn retire(&self, id: u64) {
fn retire(&self, id: u64) -> bool {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.pending.remove(&id).is_some() {
retire_callback(&mut state, id, self.maximum_retired);
if let Some(pending) = state.pending.remove(&id) {
if pending.result.is_some() {
retire_callback(&mut state, id, self.maximum_retired);
}
return true;
}
false
}
pub(super) fn finish_parent(&self, parent_id: u64) {
@@ -261,7 +292,9 @@ impl CallbackRegistry {
let mut finished = Vec::new();
for (id, pending) in std::mem::take(&mut state.pending) {
if pending.parent_id == parent_id {
retire_callback(&mut state, id, self.maximum_retired);
if pending.result.is_some() {
retire_callback(&mut state, id, self.maximum_retired);
}
finished.push(pending);
} else {
remaining.insert(id, pending);
@@ -271,7 +304,9 @@ impl CallbackRegistry {
finished
};
for pending in pending {
let _ = pending.result.send(Err(SessionError::Cancelled));
if let Some(result) = pending.result {
let _ = result.send(Err(SessionError::Cancelled));
}
}
}
@@ -284,7 +319,9 @@ impl CallbackRegistry {
std::mem::take(&mut state.pending)
};
for pending in pending.into_values() {
let _ = pending.result.send(Err(SessionError::Closed));
if let Some(result) = pending.result {
let _ = result.send(Err(SessionError::Closed));
}
}
}
}
@@ -0,0 +1,156 @@
//! 父处理任务的协作式挂起、唤醒与宿主恢复授权
use std::{
future::{Future, poll_fn},
sync::{
Arc, Mutex, MutexGuard,
atomic::{AtomicBool, Ordering},
},
task::{Context, Poll, Wake, Waker},
};
use crate::{Frame, Message};
use super::{CallDeadline, CallDeadlinePause, SessionError, output::Output};
/// 这里只保存协议镜像;执行名额与准入决策始终由宿主持有
pub(super) struct CallSchedule {
id: u64,
output: Output,
deadline: CallDeadline,
state: Mutex<State>,
ready: AtomicBool,
waker: Mutex<Option<Waker>>,
}
struct State {
epoch: u64,
phase: Phase,
pause: Option<CallDeadlinePause>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Phase {
Running,
Suspended,
Resuming,
}
impl CallSchedule {
pub(super) fn new(id: u64, output: Output, deadline: CallDeadline) -> Self {
Self {
id,
output,
deadline,
state: Mutex::new(State {
epoch: 0,
phase: Phase::Running,
pause: None,
}),
ready: AtomicBool::new(false),
waker: Mutex::new(None),
}
}
fn state(&self) -> MutexGuard<'_, State> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(super) fn resumed(&self, epoch: u64) -> Result<(), SessionError> {
let waker = {
let mut state = self.state();
if state.phase != Phase::Resuming || state.epoch != epoch {
return Err(SessionError::Protocol);
}
state.phase = Phase::Running;
state.pause.take();
self.waker
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
};
if let Some(waker) = waker {
waker.wake();
}
Ok(())
}
/// 后台工作发起应计时的回调时,不能借父任务等待 next 停表
pub(super) fn charge_wait(&self) {
self.state().pause.take();
}
pub(super) async fn drive<F: Future>(
self: &Arc<Self>,
future: F,
pauses_budget: impl Fn() -> bool,
) -> Result<F::Output, SessionError> {
let mut future = std::pin::pin!(future);
let waker = Waker::from(Arc::clone(self));
poll_fn(|cx| {
{
let mut state = self.state();
*self
.waker
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(cx.waker().clone());
match state.phase {
Phase::Resuming => return Poll::Pending,
Phase::Suspended => {
if self.ready.load(Ordering::Acquire) {
self.output.send_control(Frame::control(Message::Resume {
id: self.id,
epoch: state.epoch,
}))?;
state.phase = Phase::Resuming;
}
return Poll::Pending;
}
Phase::Running => self.ready.store(false, Ordering::Release),
}
}
let mut task_context = Context::from_waker(&waker);
if let Poll::Ready(result) = future.as_mut().poll(&mut task_context) {
return Poll::Ready(Ok(result));
}
let mut state = self.state();
state.epoch = state.epoch.checked_add(1).ok_or(SessionError::Capacity)?;
// 与回调共享单一 FIFO,宿主看到挂起前一定已看到本次轮询提交的回调
self.output.send_control(Frame::control(Message::Suspend {
id: self.id,
epoch: state.epoch,
}))?;
state.phase = Phase::Suspended;
if pauses_budget() {
state.pause = self.deadline.pause();
}
if self.ready.load(Ordering::Acquire) {
// future 可以在 poll 返回 Pending 前唤醒自己,这次唤醒也必须经过授权
cx.waker().wake_by_ref();
}
Poll::Pending
})
.await
}
}
impl Wake for CallSchedule {
fn wake(self: Arc<Self>) {
self.wake_by_ref();
}
fn wake_by_ref(self: &Arc<Self>) {
// 期限恢复会同步唤醒回调等待者;唤醒路径不能再次取得调度状态锁
self.ready.store(true, Ordering::Release);
let waker = self
.waker
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Some(waker) = waker {
waker.wake();
}
}
}
@@ -12,7 +12,7 @@ use crate::{
call::{
host::HttpResponse,
upstream_adapter::{
UpstreamWebSocketMessage, UpstreamWebSocketRead, UpstreamWebSocketRequest,
self, UpstreamWebSocketMessage, UpstreamWebSocketRead, UpstreamWebSocketRequest,
WebSocketMessageKind,
},
},
@@ -54,7 +54,7 @@ impl HostClient {
request: UpstreamWebSocketRequest,
) -> Result<UpstreamWebSocketUpgrade, PluginFault> {
let (response, body): (HttpResponse, _) =
callback(self, "host.upstream.websocket.open", request, vec![]).await?;
callback(self, upstream_adapter::WEBSOCKET_OPEN, request, vec![]).await?;
if response.stream.is_some() || (response.status == 101 && !body.is_empty()) {
return Err(invalid());
}
@@ -94,7 +94,7 @@ impl UpstreamWebSocket {
}
empty_callback(
&self.host,
"host.upstream.websocket.send",
upstream_adapter::WEBSOCKET_SEND,
UpstreamWebSocketMessage { kind },
body,
)
@@ -119,7 +119,7 @@ impl UpstreamWebSocket {
let host = self.host.clone();
async move {
host.call(
"host.upstream.websocket.read",
upstream_adapter::WEBSOCKET_READ,
serde_json::json!({}),
vec![],
)
@@ -150,7 +150,7 @@ impl UpstreamWebSocket {
.get_or_try_init(|| async {
empty_callback(
&self.host,
"host.upstream.websocket.close",
upstream_adapter::WEBSOCKET_CLOSE,
serde_json::json!({}),
vec![],
)
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::{CallBudgets, CallContext, Handshake, PluginFault};
pub const PROTOCOL_VERSION: u32 = 6;
pub const PROTOCOL_VERSION: u32 = 7;
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64
#[derive(Clone, PartialEq, Serialize, Deserialize)]
@@ -42,6 +42,26 @@ pub enum Message {
#[serde(skip)]
params: serde_json::Value,
},
/// 父处理任务已交还执行权,epoch 在同一调用内严格递增
Suspend {
id: u64,
epoch: u64,
},
/// 已挂起任务被唤醒,请求重新取得执行名额
Resume {
id: u64,
epoch: u64,
},
/// 宿主已取得执行名额,插件才可再次驱动父处理任务
Resumed {
id: u64,
epoch: u64,
},
/// 回调 future 已消费结果或被释放,宿主可回收对应驻留额度
CallbackConsumed {
id: u64,
parent_id: u64,
},
Cancel {
id: u64,
},
@@ -102,6 +122,10 @@ impl std::fmt::Debug for Message {
Self::Result { id, .. } => ("Result", Some(id)),
Self::Error { id, .. } => ("Error", Some(id)),
Self::Callback { id, .. } => ("Callback", Some(id)),
Self::Suspend { id, .. } => ("Suspend", Some(id)),
Self::Resume { id, .. } => ("Resume", Some(id)),
Self::Resumed { id, .. } => ("Resumed", Some(id)),
Self::CallbackConsumed { id, .. } => ("CallbackConsumed", Some(id)),
Self::Cancel { id } => ("Cancel", Some(id)),
Self::Cancelled { id } => ("Cancelled", Some(id)),
Self::Stream { id, .. } => ("Stream", Some(id)),
@@ -5,7 +5,7 @@ use gateway_plugin_sdk::client::{CallFuture, CallReply, PluginCall, PluginHandle
use gateway_plugin_sdk::{ErrorCode, Frame, Message};
use gateway_plugin_sdk::{
call::{
host::HttpRequest,
host::{AccountHttpRequest, HttpRequest},
upstream_adapter::{UpstreamHttpRequest, UpstreamWebSocketRequest, WebSocketMessageKind},
},
client::UpstreamWebSocketUpgrade,
@@ -91,6 +91,21 @@ impl PluginHandler for NetworkHandler {
call.payload,
)
.await?
} else if call.method == "account" {
call.host
.account_http(
AccountHttpRequest {
account_id: "acct_one".into(),
credential_revision: 7,
request: HttpRequest {
method: "POST".into(),
url: "https://example.test/responses".into(),
headers: vec![],
},
},
call.payload,
)
.await?
} else {
call.host
.upstream_http(
@@ -157,7 +172,7 @@ async fn reply_callback(
#[tokio::test]
async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
for method in ["http", "upstream"] {
for method in ["http", "account", "upstream"] {
let (mut host, task) = start_session(NetworkHandler).await;
send_call(
&mut host,
@@ -167,19 +182,28 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
b"request".to_vec(),
)
.await;
let prefix = if method == "http" {
let prefix = if method != "upstream" {
"host.http"
} else {
"host.upstream.http"
};
let open_method = if method == "account" {
gateway_plugin_sdk::call::host::ACCOUNT_HTTP.to_owned()
} else {
format!("{prefix}.do_stream")
};
let frame = reply_callback(
&mut host,
&format!("{prefix}.do_stream"),
&open_method,
json!({"status":200,"http_version":"HTTP/1.1","trailers":[],"headers":[["x-bytes",[255]],["x-bytes",b"other".to_vec()]],"stream":"private-handle"}),
vec![],
)
.await;
assert_eq!(frame.payload, b"request");
if method == "account" {
assert!(matches!(frame.message, Message::Callback { params, .. }
if params["account_id"] == "acct_one" && params["credential_revision"] == 7));
}
let frame = reply_callback(
&mut host,
&format!("{prefix}.stream_read"),
@@ -519,10 +519,25 @@ async fn send_call(
}
async fn receive(host: &mut HostPeer) -> Frame {
tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
.await
.expect("plugin response timed out")
.expect("plugin response frame must be valid")
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let frame = read_frame(&mut host.reader).await?;
match frame.message {
Message::Suspend { .. } | Message::CallbackConsumed { .. } => {}
Message::Resume { id, epoch } => {
write_frame(
&mut host.writer,
&Frame::control(Message::Resumed { id, epoch }),
)
.await?;
}
_ => return Ok::<_, gateway_plugin_sdk::FrameError>(frame),
}
}
})
.await
.expect("plugin response timed out")
.expect("plugin response frame must be valid")
}
fn unwrap_result(frame: Frame, expected_id: u64) -> (Value, Vec<u8>) {
@@ -111,7 +111,7 @@ async fn verify_cancelled_read(method: &str) {
.await;
let first = callback_id(&mut host, read).await;
signals.cancel.notify_one();
signals.dropped.notified().await;
session::wait_suspended(&mut host, &signals.dropped).await;
// 已取消的读取随后成功,下一次 read 应恢复同一读取结果
reply(&mut host, first, data.clone(), b"first").await;
signals.resume.notify_one();
@@ -264,7 +264,7 @@ async fn middleware_http_and_websocket_reads_resume_the_pending_chunk() {
};
let id = callback_id(&mut host, method).await;
signals.cancel.notify_one();
signals.dropped.notified().await;
session::wait_suspended(&mut host, &signals.dropped).await;
reply(&mut host, id, json!({"eof":false}), b"first").await;
signals.resume.notify_one();
let frame = session::receive(&mut host).await;
@@ -296,7 +296,7 @@ async fn model_frame_survives_cancellation_while_fetching_its_facts() {
.await;
let id = callback_id(&mut host, BODY_FACTS_METHOD).await;
signals.cancel.notify_one();
signals.dropped.notified().await;
session::wait_suspended(&mut host, &signals.dropped).await;
reply(&mut host, id, json!({"present":false}), b"").await;
signals.resume.notify_one();
let frame = session::receive(&mut host).await;
@@ -314,7 +314,7 @@ async fn http_forwarding_retains_a_pending_read_under_output_backpressure() {
)
.await;
signals.cancel.notify_one();
signals.dropped.notified().await;
session::wait_suspended(&mut host, &signals.dropped).await;
reply(&mut host, id, json!({"eof":false}), b"first").await;
signals.resume.notify_one();
write_frame(
@@ -359,7 +359,7 @@ async fn pending_http_read_is_split_when_credit_arrives_after_the_callback() {
)
.await;
signals.cancel.notify_one();
signals.dropped.notified().await;
session::wait_suspended(&mut host, &signals.dropped).await;
let expected = vec![b'a'; 8192];
reply(&mut host, id, json!({"eof":false}), &expected).await;
signals.resume.notify_one();
@@ -417,7 +417,7 @@ async fn websocket_transfer_rejects_a_payload_with_a_pending_read() {
let (mut host, task, signals) = start_middleware(Action::WebSocketTransfer).await;
let _id = callback_id(&mut host, BODY_READ_METHOD).await;
signals.cancel.notify_one();
signals.dropped.notified().await;
session::wait_suspended(&mut host, &signals.dropped).await;
signals.resume.notify_one();
assert!(matches!(session::receive(&mut host).await.message,
Message::Error { id: 1, error } if error.code == ErrorCode::InvalidInput));
@@ -268,12 +268,29 @@ async fn completed_call_does_not_discard_a_partially_read_next_frame() {
tokio::time::timeout(Duration::from_secs(2), async {
// 单字节缓冲保证前缀已经进入读取 future,而不是仍滞留在传输队列
let (mut host, task) = start_session_with_capacity(TestHandler::default(), 1).await;
send_call(&mut host, 1, "slow", json!("first"), Vec::new()).await;
host.writer.write_all(&encoded[..split]).await.unwrap();
send_call(&mut host, 1, "delayed_stream", json!({}), Vec::new()).await;
send_control(
&mut host,
Message::Credit {
id: 1,
bytes: 1_024,
frames: 1,
},
)
.await;
assert!(matches!(
receive(&mut host).await.message,
Message::Result { id: 1, .. }
));
host.writer.write_all(&encoded[..split]).await.unwrap();
assert!(matches!(
receive(&mut host).await.message,
Message::Stream { id: 1, sequence: 0 }
));
assert!(matches!(
receive(&mut host).await.message,
Message::End { id: 1, error: None }
));
host.writer.write_all(&encoded[split..]).await.unwrap();
assert_eq!(
receive(&mut host).await,
@@ -5,6 +5,7 @@ mod deadline;
mod driver;
mod lifecycle;
mod registry;
mod scheduling;
use std::{
future::pending,
@@ -153,6 +154,14 @@ impl PluginHandler for TestHandler {
tokio::time::sleep(Duration::from_millis(80)).await;
Ok(CallReply::unary(call.params, call.payload))
}
"delayed_stream" => {
let (sender, stream) = ResponseStream::channel(NonZeroUsize::new(1).unwrap());
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(80)).await;
sender.send(b"done".to_vec()).await.unwrap();
});
Ok(CallReply::stream(json!({}), Vec::new(), stream))
}
"callback" | "next_callback" => {
let method = if call.method == "next_callback" {
NEXT_METHOD
@@ -466,10 +475,49 @@ async fn send_control(host: &mut HostPeer, message: Message) {
}
pub(super) async fn receive(host: &mut HostPeer) -> Frame {
tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
.await
.expect("plugin response timed out")
.expect("plugin response frame must be valid")
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let frame = read_frame(&mut host.reader).await?;
match frame.message {
Message::Suspend { .. } | Message::CallbackConsumed { .. } => {}
Message::Resume { id, epoch } => {
send_control(host, Message::Resumed { id, epoch }).await;
}
_ => return Ok::<_, gateway_plugin_sdk::FrameError>(frame),
}
}
})
.await
.expect("plugin response timed out")
.expect("plugin response frame must be valid")
}
// 等待测试事件时仍推进恢复授权,且只在完整挂起帧边界返回,避免取消半帧读取
pub(super) async fn wait_suspended(host: &mut HostPeer, event: &tokio::sync::Notify) {
let event = event.notified();
tokio::pin!(event);
loop {
let frame = tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
.await
.unwrap()
.unwrap();
match frame.message {
Message::Suspend { .. } => {
if std::future::poll_fn(|cx| {
std::task::Poll::Ready(event.as_mut().poll(cx).is_ready())
})
.await
{
return;
}
}
Message::Resume { id, epoch } => {
send_control(host, Message::Resumed { id, epoch }).await
}
Message::CallbackConsumed { .. } => {}
message => panic!("unexpected frame while waiting for suspension: {message:?}"),
}
}
}
pub(super) async fn shutdown(host: &mut HostPeer, task: JoinHandle<Result<(), SessionError>>) {
@@ -297,57 +297,3 @@ async fn typed_key_and_quota_calls_keep_payloads_and_do_not_retry_failures() {
}
}
}
#[tokio::test]
async fn managed_next_does_not_consume_the_bounded_ordinary_callback_budget() {
let (mut host, task) = start_configured_session(
TestHandler::default(),
MAXIMUM_STREAM_CHUNK_BYTES * 2,
SessionConfig {
maximum_calls: 4,
maximum_callbacks: 1,
..Default::default()
},
)
.await;
send_call(&mut host, 1, "next_callback", json!({}), Vec::new()).await;
let Message::Callback {
id: next,
parent_id: 1,
method,
..
} = receive(&mut host).await.message
else {
panic!("expected next callback")
};
assert_eq!(method, NEXT_METHOD);
send_call(&mut host, 3, "callback", json!({}), Vec::new()).await;
let Message::Callback {
id: ordinary,
parent_id: 3,
method,
..
} = receive(&mut host).await.message
else {
panic!("next must not occupy ordinary callback capacity")
};
assert_eq!(method, "host.log");
send_call(&mut host, 5, "callback", json!({}), Vec::new()).await;
assert!(
matches!(receive(&mut host).await.message, Message::Error { id: 5, error } if error.code == ErrorCode::Capacity)
);
for (callback, parent) in [(ordinary, 3), (next, 1)] {
send_control(
&mut host,
Message::Result {
id: callback,
result: json!({}),
},
)
.await;
assert!(
matches!(receive(&mut host).await.message, Message::Result { id, .. } if id == parent)
);
}
shutdown(&mut host, task).await;
}
@@ -0,0 +1,332 @@
//! 协作调度的部分完成、唤醒竞争、预算与回调回收合同测试
use std::{
future::{Future, poll_fn},
task::Poll,
};
use super::*;
struct ScheduledHandler {
progressed: Arc<AtomicUsize>,
}
impl PluginHandler for ScheduledHandler {
fn call(&self, call: PluginCall) -> CallFuture<'_> {
Box::pin(async move {
match call.method.as_str() {
"parallel" | "mixed" => {
let first_method = if call.method == "mixed" {
NEXT_METHOD
} else {
"host.first"
};
let first = async {
let reply = call.host.call(first_method, json!({}), Vec::new()).await;
self.progressed.fetch_add(1, Ordering::SeqCst);
reply
};
let second = async {
let reply = call.host.call("host.second", json!({}), Vec::new()).await;
self.progressed.fetch_add(1, Ordering::SeqCst);
reply
};
let (first, second) = tokio::join!(first, second);
first.map_err(SessionError::into_plugin_fault)?;
second.map_err(SessionError::into_plugin_fault)?;
}
"yield" => {
tokio::task::yield_now().await;
self.progressed.fetch_add(1, Ordering::SeqCst);
}
"drop_callback" => {
let mut callback =
Box::pin(call.host.call("host.detached", json!({}), Vec::new()));
poll_fn(|cx| {
assert!(callback.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
drop(callback);
}
"saturate" => {
let mut callbacks = [
Box::pin(call.host.call(NEXT_METHOD, json!({}), Vec::new())),
Box::pin(call.host.call(NEXT_METHOD, json!({}), Vec::new())),
];
for callback in &mut callbacks {
poll_fn(|cx| {
assert!(callback.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
}
let refused = call.host.call("host.third", json!({}), Vec::new()).await;
assert!(matches!(refused, Err(SessionError::Capacity)));
}
_ => {
return Err(PluginFault::new(
ErrorCode::Unsupported,
"unknown scheduling test call",
));
}
}
Ok(CallReply::unary(json!({}), Vec::new()))
})
}
}
async fn wire(host: &mut HostPeer) -> Message {
let frame = tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
.await
.expect("expected protocol progress")
.unwrap();
assert!(frame.payload.is_empty());
frame.message
}
async fn callback(host: &mut HostPeer, method: &str) -> u64 {
let Message::Callback {
id,
parent_id: 1,
method: actual,
..
} = wire(host).await
else {
panic!("expected callback before suspension");
};
assert_eq!(actual, method);
id
}
async fn callback_result(host: &mut HostPeer, id: u64) {
send_control(
host,
Message::Result {
id,
result: json!({}),
},
)
.await;
}
#[tokio::test]
async fn partial_callback_results_require_a_new_execution_grant_before_processing() {
let progressed = Arc::new(AtomicUsize::new(0));
let (mut host, task) = start_session(ScheduledHandler {
progressed: Arc::clone(&progressed),
})
.await;
send_call(&mut host, 1, "parallel", json!({}), Vec::new()).await;
let first = callback(&mut host, "host.first").await;
let second = callback(&mut host, "host.second").await;
assert!(matches!(
wire(&mut host).await,
Message::Suspend { id: 1, epoch: 1 }
));
callback_result(&mut host, first).await;
assert!(matches!(
wire(&mut host).await,
Message::Resume { id: 1, epoch: 1 }
));
assert_eq!(progressed.load(Ordering::SeqCst), 0);
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
assert!(
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == first)
);
assert!(matches!(
wire(&mut host).await,
Message::Suspend { id: 1, epoch: 2 }
));
assert_eq!(progressed.load(Ordering::SeqCst), 1);
callback_result(&mut host, second).await;
assert!(matches!(
wire(&mut host).await,
Message::Resume { id: 1, epoch: 2 }
));
assert_eq!(progressed.load(Ordering::SeqCst), 1);
send_control(&mut host, Message::Resumed { id: 1, epoch: 2 }).await;
assert!(
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == second)
);
assert!(matches!(
wire(&mut host).await,
Message::Result { id: 1, .. }
));
assert_eq!(progressed.load(Ordering::SeqCst), 2);
shutdown(&mut host, task).await;
}
#[tokio::test]
async fn wake_during_poll_is_preserved_but_cannot_skip_host_authorization() {
let progressed = Arc::new(AtomicUsize::new(0));
let (mut host, task) = start_session(ScheduledHandler {
progressed: Arc::clone(&progressed),
})
.await;
send_call(&mut host, 1, "yield", json!({}), Vec::new()).await;
assert!(matches!(
wire(&mut host).await,
Message::Suspend { id: 1, epoch: 1 }
));
assert!(matches!(
wire(&mut host).await,
Message::Resume { id: 1, epoch: 1 }
));
assert_eq!(progressed.load(Ordering::SeqCst), 0);
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
assert!(matches!(
wire(&mut host).await,
Message::Result { id: 1, .. }
));
assert_eq!(progressed.load(Ordering::SeqCst), 1);
shutdown(&mut host, task).await;
}
#[tokio::test]
async fn cancellation_during_resume_does_not_allow_late_grants_to_revive_the_call() {
let (mut host, task) = start_session(TestHandler::default()).await;
send_call(&mut host, 1, "callback", json!({}), Vec::new()).await;
let child = callback(&mut host, "host.log").await;
assert!(matches!(
wire(&mut host).await,
Message::Suspend { id: 1, epoch: 1 }
));
callback_result(&mut host, child).await;
assert!(matches!(
wire(&mut host).await,
Message::Resume { id: 1, epoch: 1 }
));
send_control(&mut host, Message::Cancel { id: 1 }).await;
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
assert!(matches!(
wire(&mut host).await,
Message::Cancelled { id: 1 }
));
send_call(&mut host, 3, "echo", json!("alive"), Vec::new()).await;
assert!(
matches!(wire(&mut host).await, Message::Result { id: 3, result } if result == json!("alive"))
);
shutdown(&mut host, task).await;
}
#[tokio::test(start_paused = true)]
async fn next_pause_covers_resume_wait_but_cannot_hide_parallel_chargeable_work() {
let (mut host, task) = start_session(TestHandler::default()).await;
send_call_with_timeout(
&mut host,
1,
"next_callback",
json!({}),
Vec::new(),
Duration::from_millis(100),
)
.await;
let child = callback(&mut host, NEXT_METHOD).await;
assert!(matches!(
wire(&mut host).await,
Message::Suspend { id: 1, epoch: 1 }
));
tokio::time::advance(Duration::from_secs(2)).await;
callback_result(&mut host, child).await;
assert!(matches!(
wire(&mut host).await,
Message::Resume { id: 1, epoch: 1 }
));
tokio::time::advance(Duration::from_secs(2)).await;
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
assert!(
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == child)
);
assert!(matches!(
wire(&mut host).await,
Message::Result { id: 1, .. }
));
shutdown(&mut host, task).await;
let (mut host, task) = start_session(ScheduledHandler {
progressed: Arc::new(AtomicUsize::new(0)),
})
.await;
send_call_with_timeout(
&mut host,
1,
"mixed",
json!({}),
Vec::new(),
Duration::from_millis(100),
)
.await;
callback(&mut host, NEXT_METHOD).await;
callback(&mut host, "host.second").await;
assert!(matches!(
wire(&mut host).await,
Message::Suspend { id: 1, epoch: 1 }
));
tokio::time::advance(Duration::from_millis(101)).await;
loop {
match wire(&mut host).await {
Message::CallbackConsumed { parent_id: 1, .. } => {}
Message::Error { id: 1, error } => {
assert_eq!(error.code, ErrorCode::Timeout);
break;
}
other => panic!("chargeable work must time out without a grant: {other:?}"),
}
}
shutdown(&mut host, task).await;
}
#[tokio::test]
async fn dropping_a_callback_reports_consumption_before_the_parent_result() {
let (mut host, task) = start_session(ScheduledHandler {
progressed: Arc::new(AtomicUsize::new(0)),
})
.await;
send_call(&mut host, 1, "drop_callback", json!({}), Vec::new()).await;
let child = callback(&mut host, "host.detached").await;
assert!(
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == child)
);
assert!(matches!(
wire(&mut host).await,
Message::Result { id: 1, .. }
));
callback_result(&mut host, child).await;
shutdown(&mut host, task).await;
}
#[tokio::test]
async fn all_callback_methods_share_the_same_bounded_resident_capacity() {
let (mut host, task) = start_configured_session(
ScheduledHandler {
progressed: Arc::new(AtomicUsize::new(0)),
},
MAXIMUM_STREAM_CHUNK_BYTES * 2,
SessionConfig {
maximum_calls: 1,
maximum_callbacks: 2,
..Default::default()
},
)
.await;
send_call(&mut host, 1, "saturate", json!({}), Vec::new()).await;
let first = callback(&mut host, NEXT_METHOD).await;
let second = callback(&mut host, NEXT_METHOD).await;
let mut consumed = Vec::new();
for _ in 0..2 {
let Message::CallbackConsumed { id, parent_id: 1 } = wire(&mut host).await else {
panic!("resident callbacks must be reclaimed");
};
consumed.push(id);
}
consumed.sort_unstable();
assert_eq!(consumed, [first, second]);
assert!(matches!(
wire(&mut host).await,
Message::Result { id: 1, .. }
));
shutdown(&mut host, task).await;
}
@@ -199,8 +199,9 @@ pub fn retry_after_seconds_from_value(value: &Value) -> Option<u64> {
.pointer("/response/error")
.or_else(|| value.get("error"))
.unwrap_or(value);
retry_after_seconds_header(error)
.or_else(|| retry_after_seconds_header(value))
retry_after_header(error)
.or_else(|| retry_after_header(value))
.map(|(_, seconds)| seconds)
.or_else(|| retry_after_seconds_field(error))
.or_else(|| {
error
@@ -225,22 +226,27 @@ fn retry_after_seconds_field(value: &Value) -> Option<u64> {
.filter(|seconds| *seconds > 0)
}
fn retry_after_seconds_header(value: &Value) -> Option<u64> {
/// 同时保留原始头值与解析秒数,供事件转换和重试观测共用官方头部规则
pub(super) fn retry_after_header(value: &Value) -> Option<(&Value, u64)> {
let headers = value.get("headers")?.as_object()?;
// 官方先按 HTTP 头值校验,再覆盖大小写不同的同名头;最后才解释重试时间
// 合法但无法解析为时间的末项也会覆盖前值,非法头值和数组不参与覆盖
let header = headers
let (value, header) = headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
.filter_map(|(_, value)| match value {
Value::String(value) => http::HeaderValue::from_str(value).ok(),
Value::Number(_) | Value::Bool(_) => {
http::HeaderValue::from_str(&value.to_string()).ok()
}
Value::Null | Value::Array(_) | Value::Object(_) => None,
.filter_map(|(_, value)| {
let header = match value {
Value::String(value) => http::HeaderValue::from_str(value).ok(),
Value::Number(_) | Value::Bool(_) => {
http::HeaderValue::from_str(&value.to_string()).ok()
}
Value::Null | Value::Array(_) | Value::Object(_) => None,
}?;
Some((value, header))
})
.next_back()?;
super::headers::parse_retry_after_seconds(header.to_str().ok()?)
let seconds = super::headers::parse_retry_after_seconds(header.to_str().ok()?)?;
Some((value, seconds))
}
/// 标准化的单个限流窗口

Some files were not shown because too many files have changed in this diff Show More