chore: update gateway pressure observability

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent 974def5fef
commit f179ee72f9
69 changed files with 14843 additions and 1609 deletions
-116
View File
@@ -1,116 +0,0 @@
# Aether Data Schema Inventory
This inventory is the maintenance map for the three SQL drivers. The executable
`sqlx` migrations remain under `crates/aether-data/migrations/{postgres,mysql,sqlite}`.
Do not split already-shipped migration files without also deciding how to handle
existing `_sqlx_migrations` rows.
The maintainable schema source is under `crates/aether-data/schema`. Manifests
there compose back into the executable SQL files and are checked by tests, so
the split source is not just documentation.
The schema directory separates human-maintained sources from generated/runtime
outputs:
| Layer | Purpose |
|---|---|
| `schema/logical/*.toml` | Human-maintained long-term table-structure source. |
| `schema/drivers/{postgres,mysql,sqlite}/**` | Human-maintained executable-SQL fragments while generation is being promoted incrementally. |
| `schema/bootstrap/postgres/**` | Human-maintained source fragments for the Postgres empty-database bootstrap snapshot. |
| `schema/generated/**` | Machine-written SQL from logical schema; checked in for audit and drift detection only. |
| `migrations/**` | Runtime SQL artifacts composed from manifests. |
Generated SQL is not hand-maintained and runtime code does not load it. It
exists to prove that the logical schema can emit driver SQL and to provide the
candidate replacement for handwritten fragments.
## Logical Type Map
| Logical type | Postgres | MySQL | SQLite | Notes |
|---|---|---|---|---|
| `id` | `varchar/text` | `varchar` | `text` | Repository DTOs treat ids as strings. |
| `bool` | `boolean` | `tinyint(1)/boolean` | `integer` | Repositories normalize to Rust `bool`. |
| `time_unix` | `bigint` or legacy `timestamptz` | `bigint` | `integer` | New cross-driver paths prefer unix seconds/ms. |
| `json` | `json/jsonb` | `text/json-compatible` | `text` | Application parses through `serde_json::Value`. |
| `decimal_money` | `numeric` or `double precision` legacy | `double` | `real` | Wallet precision should be reviewed before new money tables. |
| `blob` | `bytea` | `longblob/blob` | `blob` | Used for compressed body payloads. |
| `enum` | `enum` or `varchar` legacy | `varchar` | `text` | Repository contracts own allowed values. |
## Baseline Source Plan
The current executable SQL files are intentionally kept stable for runtime
compatibility. Their maintainable sources are:
| Driver | Executable SQL | Source manifest |
|---|---|---|
| Postgres baseline | `migrations/postgres/20260403000000_baseline.sql` | `schema/drivers/postgres/baseline/manifest.txt` |
| Postgres empty-database snapshot | `aether-data` build output (`OUT_DIR/empty_database_snapshot.sql`) | `schema/bootstrap/postgres/manifest.txt` |
| MySQL baseline | `migrations/mysql/20260403000000_baseline.sql` | `schema/drivers/mysql/baseline/manifest.txt` |
| SQLite baseline | `migrations/sqlite/20260403000000_baseline.sql` | `schema/drivers/sqlite/baseline/manifest.txt` |
All driver manifests are kept as a small set of numbered SQL fragments. Postgres
uses execution-phase fragments (`001_types_and_tables.sql`,
`002_defaults.sql`, `003_constraints.sql`, `004_indexes.sql`,
`005_foreign_keys.sql`, `006_footer.sql`) so pg_dump ordering remains stable
when composed. MySQL and SQLite use similarly numbered domain fragments. After
editing fragments, run:
```bash
bash crates/aether-data/schema/compose_schema.sh compose
bash crates/aether-data/schema/compose_schema.sh check
```
Use `schema/logical/*.toml` for new table structure first; handwritten driver
fragments remain for executable migration compatibility and generator gaps.
`crates/aether-data/schema/logical/*.toml` is the single-maintenance source for
table structure. `aether-data-schema` renders it to
`schema/generated/{postgres,mysql,sqlite}/baseline`, and
`compose_schema.sh check` verifies that the generated SQL is current and that
required executable SQL tables are represented in logical schema. The generated
directory carries its own machine-generated README and per-file `Do not edit`
headers; changes there should come only from `compose_schema.sh generate`.
## Table Inventory
| Area | Tables | Owner | Generation target |
|---|---|---|---|
| Identity/auth | `users`, `api_keys`, `management_tokens`, `user_preferences`, `user_sessions`, `user_oauth_links` | `repository/users`, `repository/auth`, `repository/management_tokens`, auth modules | Good first candidate for schema manifest/query helper generation. |
| Provider catalog | `providers`, `provider_api_keys`, `provider_endpoints`, `models`, `global_models`, `api_key_provider_mappings`, `provider_usage_tracking` | `repository/provider_catalog`, `repository/global_models`, scheduler read paths | Keep complex selection SQL handwritten; generate basic CRUD only. |
| Auth config | `auth_modules`, `oauth_providers`, `ldap_configs` | `repository/auth_modules`, `repository/oauth_providers`, `repository/users` | Good candidate for generated CRUD. |
| Proxy nodes | `proxy_nodes`, `proxy_node_events` | `repository/proxy_nodes` | Good candidate for generated CRUD plus handwritten heartbeat update. |
| Wallet/billing | `wallets`, `wallet_transactions`, `wallet_daily_usage_ledgers`, `payment_orders`, `payment_callbacks`, `refund_requests`, `redeem_code_batches`, `redeem_codes`, `billing_rules`, `dimension_collectors` | `repository/wallet`, `repository/billing`, `repository/settlement` | Keep settlement/ledger math explicit; generate table definitions and simple reads. |
| Usage/audit | `usage`, `usage_counter_deltas`, `usage_body_blobs`, `usage_http_audits`, `usage_routing_snapshots`, `usage_settlement_snapshots`, `request_candidates`, `audit_logs` | `repository/usage`, `repository/candidates`, `repository/audit` | Keep core write/audit queries handwritten. |
| Runtime tasks | `video_tasks`, `gemini_file_mappings`, `announcements`, `announcement_reads` | `repository/video_tasks`, `repository/gemini_file_mappings`, `repository/announcements` | Good candidate for generated CRUD except polling claim logic. |
| Stats | `stats_*`, `schema_backfills` | backend aggregation modules | Keep aggregation SQL per-driver; generate table/index definitions only. |
| System | `system_configs` | `repository/system` through backend dispatch | Good candidate for generated CRUD. |
## Logical Schema Coverage
Logical schema currently covers the clean baseline table set plus portable
MySQL/SQLite table-creation migrations. Postgres-only historical follow-up
migrations remain driver-specific until their schema is normalized or promoted
as explicit generated/override fragments.
## Maintenance Rules
1. Keep driver-specific SQL inside driver-specific migration/repository files.
2. Use logical type names in docs and future schema manifests, not raw database
type names.
3. Keep `jsonb` only in Postgres migrations/repositories/tests.
4. Prefer generated helpers for simple CRUD first; do not rewrite complex usage,
billing, stats, or candidate-selection queries until contract tests cover the
behavior.
5. When adding a new table, update this inventory and add it to the export domain
plan if it must move across databases.
6. If a baseline fragment changes, run `compose_schema.sh compose` before tests
so the executable SQL artifact is regenerated from the source manifest.
## Performance Notes
- Shared hot counters must use `bigint` and be updated by durable outbox flush
workers, not request transactions.
- `usage_counter_deltas` is append-only until processed; processed rows are
retained briefly for audit/replay and then batch-deleted by maintenance.
- Candidate selection joins stay handwritten but are protected in the gateway by
a short TTL, single-flight cache invalidated by provider/routing writes.
@@ -1,411 +0,0 @@
# Gemini API Endpoint Routing Design
**状态:** implementation design
**最后更新:** 2026-05-18
**目标:** 把 Gemini Developer API 和 Vertex AI 的端点语义在 Aether 内部做成明确、可测试、可审计的一等路由语义,根治 `generativelanguage.googleapis.com``aiplatform.googleapis.com` 混用、批量 embedding 伪成功、provider 能力声明不完整等问题。
---
## 速查结论
Aether 里同一个 `api_format` 只描述请求/响应数据形态,不等于实际 Google 后端产品面。
| Aether 语义 | 默认后端产品面 | 官方 host | 主要认证形态 | 说明 |
| --- | --- | --- | --- | --- |
| Google / Gemini Developer API | Gemini Developer API, 也就是 AI Studio 这条 Gemini API | `generativelanguage.googleapis.com` | API key | 默认 Gemini provider 应走这里 |
| Vertex AI | Vertex AI Gemini API | `aiplatform.googleapis.com``{region}-aiplatform.googleapis.com` | service account / Vertex API key | `provider_type = vertex_ai` 应走这里 |
Aether 还必须区分 Google 官方的 OpenAI-compatible 表面。它们使用 OpenAI request/response schema,但不等于 native `generateContent` / `embedContent` endpoint
| OpenAI-compatible 表面 | 后端产品面 | 官方 API root | 主要认证形态 | Aether 处理原则 |
| --- | --- | --- | --- | --- |
| Gemini Developer API OpenAI compatibility | Gemini Developer API / AI Studio | `https://generativelanguage.googleapis.com/v1beta/openai` | Gemini API key as Bearer | 只在 provider format 是 `openai:*` 且显式配置该 root 时使用 |
| Vertex AI OpenAI compatibility | Vertex AI / Google Cloud | `https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi` | Google Cloud access token / service account | 只在显式 OpenAI-compatible endpoint 上使用;不得替代 native Vertex provider 主链 |
端点动作必须按后端产品面区分:
| 能力 | Gemini Developer API | Vertex AI | Aether 处理原则 |
| --- | --- | --- | --- |
| Generate Content | `models/{model}:generateContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent` | 两边都支持,但 URL 构造不同 |
| Stream Generate Content | `models/{model}:streamGenerateContent?alt=sse` | `projects/{project}/locations/{location}/publishers/google/models/{model}:streamGenerateContent?alt=sse` | 两边都支持,但 URL 构造不同 |
| Single Embedding | `models/{model}:embedContent` | `projects/{project}/locations/{location}/publishers/google/models/{model}:predict` | Vertex 文本 embedding 使用 Predict contract`instances[]` + `parameters` |
| Batch Embedding | `models/{model}:batchEmbedContents` | 同一个 `:predict`,由 `instances[]` 表达多输入 | Aether 不切到 Developer API;模型自身的批量限制由 Vertex 明确返回 |
工程不变量:
1. 默认 Gemini provider 只能生成 Gemini Developer API URL,不得因为模型名是 Gemini 就走 Vertex。
2. `provider_type = vertex_ai` 或明确的 Vertex auth/host 只能生成 Vertex URL,不得回退到 Gemini Developer API URL。
3. Vertex embedding 请求必须使用 Vertex Predict contract,不得把 Developer API 的 `model/content/requests` body 原样发给 `:predict`
4. 任何“不支持”的情况必须在调度/URL 构造阶段显式暴露为不可用,不能伪成功。
5. Provider 模板、runtime policy、URL builder、conversion policy、测试连接、live DB reconciliation 必须消费同一个语义模型。
6. Google 官方 OpenAI-compatible root 已经包含 API rootAether 不得额外拼接 `/v1`,否则会生成 `.../openai/v1/...``.../endpoints/openapi/v1/...` 这类错误 URL。
7. Native Gemini endpoint 与 Google OpenAI-compatible endpoint 不是互相 fallback 的关系。显式配置 `openai:*` 才能走 OpenAI-compatible;显式配置 `gemini:*` 才能走 native Gemini REST。
---
## 官方资料依据
本节只记录影响工程设计的官方事实。实现前必须以这些来源为真源,而不是以旧代码行为为真源。
### Gemini Developer API / AI Studio
官方 Gemini API 文档把 Developer API 作为可直接用 API key 调用的产品面。其 REST API host 是 `generativelanguage.googleapis.com`,常见路径是 `/v1beta/models/{model}:...`
关键资料:
- Gemini API reference: <https://ai.google.dev/api>
- Gemini API Generate Content: <https://ai.google.dev/api/generate-content>
- Gemini API Embeddings guide: <https://ai.google.dev/gemini-api/docs/embeddings>
- Gemini API embeddings reference: <https://ai.google.dev/api/embeddings>
- Gemini API OpenAI compatibility: <https://ai.google.dev/gemini-api/docs/openai>
- Gemini API migrate to cloud / Vertex AI: <https://ai.google.dev/gemini-api/docs/migrate-to-cloud>
工程含义:
- `generateContent``streamGenerateContent` 可以走 Developer API host。
- `embedContent` 是单条 embedding。
- `batchEmbedContents` 是 Developer API 的批量 embedding 方法;批量 body 形态是顶层 `requests[]`,每项包含 `model``content`
- Developer API key 不应被拼进 pathAether URL builder 应继续过滤或独立处理 `key` query,避免 query 重复或泄露。
- Developer API 的 OpenAI-compatible root 是 `/v1beta/openai`,其 chat / embedding path 是 `/chat/completions``/embeddings`,不是 `/v1/chat/completions``/v1/embeddings`
### Vertex AI Gemini API
Vertex AI 的 Gemini API REST reference 使用 `aiplatform.googleapis.com` 或 region host,路径包含 GCP project 与 location。
关键资料:
- Vertex AI Generate Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/generateContent>
- Vertex AI Stream Generate Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/streamGenerateContent>
- Vertex AI Embed Content REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/embedContent>
- Vertex AI Predict REST: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models/predict>
- Vertex AI REST resources: <https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.publishers.models>
- Vertex AI Model Garden publisher model list: <https://docs.cloud.google.com/vertex-ai/docs/reference/rest/v1beta1/publishers.models/list>
- Vertex AI text embeddings API: <https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api>
- Vertex AI OpenAI compatibility: <https://cloud.google.com/vertex-ai/generative-ai/docs/start/openai>
工程含义:
- Vertex service account 路径必须包含 project 和 location
- `https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:{action}`
-`global` location,可使用 `https://aiplatform.googleapis.com/v1/projects/{project}/locations/global/...`
- Vertex API key 路径可走:
- `https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key=...`
- Vertex 模型目录拉取不是推理请求,必须走 Model Garden publisher list
- `https://aiplatform.googleapis.com/v1beta1/publishers/{publisher}/models`
- 不得使用 `projects/{project}/locations/{location}/publishers/{publisher}/models``projects.locations.publishers.models` 资源没有 list 方法,只有 generate / stream / predict / embed 等动作。
- Vertex 文本 embedding API 文档使用 `:predict`,请求体是 `instances[]`,可选参数在 `parameters` 下;响应是 `predictions[].embeddings.values`
- Vertex REST reference 也列出 `embedContent`,但 Aether 当前 text embedding 主链使用 text embeddings guide 和 Predict API 的 contract。
- Vertex `instances[]` 是在线 Predict 请求体,不等同于异步 batch prediction job。模型级输入数量限制由 Vertex 返回;Aether 不把超出限制的请求静默改走其他产品面。
- Vertex OpenAI-compatible root 是 `/v1/projects/{project}/locations/{location}/endpoints/openapi`,其 OpenAI path 直接挂在这个 root 之后。
- 自定义 Vertex OpenAI-compatible endpoint 可以使用 service account token 刷新,但只有 base URL 明确落在 `/endpoints/openapi` 时才能启用该 Vertex auth 语义。普通 `aiplatform.googleapis.com` + `openai:*` 不能被误判成 Vertex OpenAI compatibility。
### Google Gen AI SDK 的后端切换语义
官方 SDK 同时支持 Gemini Developer API 与 Vertex AI,但二者需要显式选择后端。SDK 层面的 `vertexai=true` / `GOOGLE_GENAI_USE_VERTEXAI=true` 说明:这不是“同一 URL 自动兼容”的关系,而是同一 SDK 下的两个后端产品面。
关键资料:
- Google Gen AI SDK docs: <https://googleapis.github.io/python-genai/>
- Vertex AI SDK overview: <https://cloud.google.com/vertex-ai/generative-ai/docs/sdks/overview>
- Gemini API migrate to cloud / Vertex AI: <https://ai.google.dev/gemini-api/docs/migrate-to-cloud>
工程含义:
- Aether 也应把“选择 Gemini Developer API 还是 Vertex AI”作为显式路由语义,而不是让 URL builder 通过零散 host 字符串猜测。
- `api_format = gemini:generate_content``api_format = gemini:embedding` 只是数据格式。真正的后端产品面由 provider family / auth / endpoint host 决定。
---
## Aether 当前相关链路
这一节说明在 Aether 内部,哪些对象共同决定一次 Gemini 请求实际打到哪里。
| 层 | 代表文件 | 当前职责 | 设计要求 |
| --- | --- | --- | --- |
| 请求格式转换 | `crates/aether-ai-formats/src/formats/...` | OpenAI / Gemini / Claude 等格式互转 | 只负责 body 形态,不决定 Google 后端产品面 |
| Provider 类型模板 | `crates/aether-provider-transport/src/provider_types.rs` | 固定 provider 默认 endpoint、runtime policy | Vertex 模板必须声明 generate + embedding 能力 |
| Runtime policy | `crates/aether-provider-transport/src/provider_types.rs` 和 provider policy | 判断 provider 是否本地可消费 | Vertex embedding 必须进入支持矩阵 |
| URL builder | `crates/aether-provider-transport/src/request_url/mod.rs` | 把 transport + mapped_model + api_format 转成 upstream URL | 必须按后端产品面构造 URL |
| Vertex helpers | `crates/aether-provider-transport/src/vertex/url.rs` | 构造 Vertex 特有 URL | 必须覆盖 generate / stream / embedding |
| Conversion policy | `crates/aether-provider-transport/src/conversion.rs` | 判定跨格式请求能否走某个 transport | OpenAI embedding -> Gemini embedding 在 Vertex 上必须可判定、可认证、可 URL |
| Gateway 测试连接 | `apps/aether-gateway/src/handlers/public/support/test_connection/route.rs` | 测试 provider endpoint 是否可用 | 不得用过低 token 或伪成功规则误判 Gemini 3 |
| Live provider reconciliation | gateway admin/provider 初始化与 DB | 把固定模板同步到 live DB | 新 endpoint 不应只存在源码里,必须进入 live provider/endpoints |
---
## 目标语义模型
新增或显式固化一个内部概念:`GeminiEndpointFamily`
```rust
enum GeminiEndpointFamily {
DeveloperApi,
VertexAi,
}
```
该概念不一定必须以公开 enum 落地,但所有相关函数必须在行为上遵守同一判定:
| 判定输入 | 结果 | 备注 |
| --- | --- | --- |
| `provider_type == "vertex_ai"` | `VertexAi` | 固定 provider 主判据 |
| endpoint host 看起来是 `aiplatform.googleapis.com``{region}-aiplatform.googleapis.com` | `VertexAi` | 支持自定义 Vertex provider,但不可反客为主覆盖固定 provider |
| Vertex service account auth 可解析 | `VertexAi` | service account 是 Vertex 强语义 |
| Vertex API key query auth 可解析 | `VertexAi` | Vertex API key 仍是 Vertex 后端 |
| 普通 Google/Gemini provider + `generativelanguage.googleapis.com` | `DeveloperApi` | 默认 Gemini API |
禁止规则:
- 不得因为 `api_format``gemini:*` 就默认走 Vertex。
- 不得因为 Vertex 缺少某个 endpoint 就回退到 Developer API。
- 不得在 URL builder 里用“host 像谁就算谁”覆盖固定 provider 的 provider_type。
- 不得在 body converter 里偷偷决定 endpoint familybody converter 只能做数据形态转换。
---
## URL 构造矩阵
### Developer API URL
| Aether api_format | stream | batch | URL 形态 |
| --- | --- | --- | --- |
| `gemini:generate_content` | false | 不适用 | `/v1beta/models/{model}:generateContent` |
| `gemini:generate_content` | true | 不适用 | `/v1beta/models/{model}:streamGenerateContent?alt=sse` |
| `gemini:embedding` | false | false | `/v1beta/models/{model}:embedContent` |
| `gemini:embedding` | false | true | `/v1beta/models/{model}:batchEmbedContents` |
Developer API 的批量 embedding 支持顶层 `requests[]`。Aether 可以继续用 body 检测来决定单条还是批量 URL,但该检测只允许影响 Developer API URL。
### Vertex AI URL
| Aether api_format | stream | batch | URL 形态 |
| --- | --- | --- | --- |
| `gemini:generate_content` | false | 不适用 | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent` |
| `gemini:generate_content` | true | 不适用 | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:streamGenerateContent?alt=sse` |
| `gemini:embedding` | false | false | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:predict` |
| `gemini:embedding` | false | true | `/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:predict` |
Vertex text embedding 的模型由 URL path 承载,body 不得重复携带顶层 `model` 字段,否则会触发 Vertex `oneof field '_model' is already set` 一类错误。Aether 在 Vertex transport context 下必须把 Gemini Developer API embedding body 转成 Predict body
```json
{
"instances": [
{ "content": "text", "task_type": "RETRIEVAL_QUERY", "title": "optional" }
],
"parameters": {
"outputDimensionality": 768,
"autoTruncate": true
}
}
```
如果输入已经是 Predict bodyAether 只移除重复的顶层 `model`。如果输入仍是 OpenAI body 或无法确定可转换,调度阶段必须显式失败,不得把未转换 body 发到 Vertex native endpoint。
### Google OpenAI-Compatible URL
OpenAI-compatible URL 属于显式 passthrough root,不参与 native Gemini URL builder。
| Aether api_format | Gemini Developer API OpenAI compatibility | Vertex AI OpenAI compatibility | Aether 处理原则 |
| --- | --- | --- | --- |
| `openai:chat` | `/v1beta/openai/chat/completions` | `/v1/projects/{project}/locations/{location}/endpoints/openapi/chat/completions` | root 已含 API 版本,不再补 `/v1` |
| `openai:embedding` | `/v1beta/openai/embeddings` | `/v1/projects/{project}/locations/{location}/endpoints/openapi/embeddings` | root 已含 API 版本,不再补 `/v1` |
这条链路的关键边界:
1. `openai:*` provider format 走 OpenAI-compatible schema,不做 OpenAI -> Gemini native body 转换。
2. `gemini:*` provider format 走 native Gemini schema,不因目标是 Google provider 就切到 OpenAI-compatible endpoint。
3. 如果用户请求 `openai:*`、provider endpoint 是 `gemini:*`Aether 走格式转换后打 native Gemini endpoint。
4. 如果用户请求 `openai:*`、provider endpoint 也是 `openai:*`Aether 保持 OpenAI schema 并打显式 OpenAI-compatible root。
5. 以上两条都是正式主链,不能互相静默顶替。
---
## 请求体转换边界
`aether-ai-formats` 中的 Gemini embedding converter 当前负责:
- 单条 input -> `embedContent` body
- 多条 input -> Developer API `batchEmbedContents` body
- `dimensions` -> `outputDimensionality`
- embedding task -> Gemini `taskType`
设计要求:
1. 该 converter 可以继续生成 Gemini Developer API 的 `embedContent` / `batchEmbedContents` body。
2. Transport 层必须在 Vertex context 下把该 body 转成 Predict body,并在无法转换时 fail closed。
3. 所有 taskType / outputDimensionality 必须保持显式传递;不得默认注入会改变语义的 task 或维度。
4. Vertex Predict 的 `instances[]` 只能表示在线 Predict 请求的一次调用;它不是异步 batch prediction job,也不是 Developer API `batchEmbedContents` 的静默替身。
5. Developer API 单条 embedding body 可以保留 `model`Vertex 单条 embedding 在 gateway transport 语义层必须删除顶层 `model`,因为 Vertex 模型已在 path 中指定。
### 格式转换矩阵
端点族和格式转换是两层语义:
- 端点族决定请求发往 `generativelanguage.googleapis.com` 还是 `aiplatform.googleapis.com`
- 格式转换决定客户端传入的 body 如何变成 provider 所需 body,以及 provider response 如何变回客户端期望 body。
`gemini:generate_content` 在 Developer API 与 Vertex AI 上使用同一 Gemini generate-content body 形态,因此格式转换器不应区分这两个产品面。产品面差异只留给 URL/auth 层处理。
`gemini:embedding` 在格式层仍先表达为 Gemini Developer API 的 embedding body。进入 Vertex transport context 时,transport 层再把它收敛到 Vertex Predict body。这样格式转换器不需要知道认证方式,URL/body transport 也不会把 Developer API body 原样发给 Vertex。
| 客户端格式 | Provider 格式 | Developer API | Vertex AI | 处理要求 |
| --- | --- | --- | --- | --- |
| `openai:chat` | `gemini:generate_content` | 支持 | 支持 | OpenAI chat -> Gemini contents / generationConfig |
| `gemini:generate_content` | `openai:chat` | 支持 | 支持 | Gemini contents -> OpenAI messages |
| `openai:embedding` | `gemini:embedding` 单条 | 支持 | 支持 | OpenAI input string 或单项数组 -> Gemini `embedContent` bodyVertex transport 再转 `instances[]` |
| `openai:embedding` | `gemini:embedding` 多条 | 支持 | 支持于 transport 层 | Developer API -> `batchEmbedContents`Vertex transport -> Predict `instances[]`,模型限制由 Vertex 返回 |
| `gemini:embedding` 单条 | `openai:embedding` | 支持 | 支持 | Gemini `content.parts[].text` -> OpenAI `input` string |
| `gemini:embedding` 批量 | `openai:embedding` | 支持 | 支持 | Gemini `requests[]` -> OpenAI `input[]`Vertex Predict response 同样可转 |
| `gemini:embedding` response | `openai:embedding` response | 支持 | 支持 | Gemini `embedding.values` / `embeddings[].values` / Vertex `predictions[].embeddings.values` -> OpenAI `data[].embedding` |
| `openai:embedding` response | `gemini:embedding` response | 支持 | 支持于格式层 | OpenAI `data[]` -> Gemini single `embedding` 或 batch `embeddings[]` |
| `openai:chat` | `openai:chat` on Google OpenAI-compatible root | 支持 | 支持 | passthrough OpenAI schema,不做 native Gemini 转换 |
| `openai:embedding` | `openai:embedding` on Google OpenAI-compatible root | 支持 | 支持 | passthrough OpenAI schema,不做 native Gemini 转换 |
这张矩阵的关键点:
1. 格式层必须能双向理解 Gemini native embedding request/response 与 OpenAI embedding request/response。
2. Vertex text embedding 使用 Predict contract;多输入由 `instances[]` 表达,不构造不存在的 `:batchEmbedContents`
3. 一旦 provider family 是 Vertex,任何 embedding 请求都不能借格式转换之名回退到 Developer API。
4. 对 OpenAI embedding 单项数组,转换器必须生成 Gemini 单条 body,避免把“单条业务请求”误判成 Vertex batch。
5. Google OpenAI-compatible passthrough 与 OpenAI -> Gemini native conversion 是两条显式路径。管理员通过 provider endpoint format 选择路径,Aether 不得自动“择优”改路。
---
## Provider 能力声明与调度
Vertex provider 的固定模板必须包含:
- `gemini:generate_content`
- `gemini:embedding`
- `claude:messages`,如果当前上游 Vertex Claude 支持仍保留
Runtime policy 必须表达:
- Vertex 能本地消费 Gemini generate content。
- Vertex 能本地消费 Gemini embedding,并在 transport 层生成 Predict URL/body。
- Vertex text embedding 的模型级输入数量限制由 Vertex 返回;Aether 不静默拆分、不静默降级到 Developer API。
- 全局模型名与 Vertex 实际 provider 模型名必须可以分离。例如客户端继续请求全局 `gemini-embedding-2-preview` 时,Vertex provider model 可以映射到官方可用的 `gemini-embedding-2`;调度、key allowed_models、URL builder 必须消费映射后的 provider model,不得拿全局 preview 名直打 Vertex。
调度与 conversion policy 必须表达:
- `openai:embedding -> gemini:embedding` 可以被 Vertex provider 接收,transport 层负责把 Gemini Developer API body 转成 Vertex Predict body。
- 对批量 input,不能生成 Vertex `:batchEmbedContents`,也不能回退到 `generativelanguage.googleapis.com`
- `request_pair_direct_auth` 对 Vertex API key 必须返回 `key` query authservice account auth 由 OAuth refresh path 处理,不能伪造成普通 bearer key。
---
## 测试设计
必须覆盖这些测试面:
1. Developer API generate URL:
- non-stream -> `generativelanguage.googleapis.com/...:generateContent`
- stream -> `...:streamGenerateContent?alt=sse`
2. Developer API embedding URL:
- 单条 body -> `...:embedContent`
- 多条 body -> `...:batchEmbedContents`
3. Vertex generate URL:
- API key auth -> `aiplatform.googleapis.com/v1/publishers/google/models/...`
- service account -> project/location path
4. Vertex embedding URL:
- API key auth -> `...:predict?key=...`
- service account -> project/location `...:predict`
5. Vertex embedding body:
- 单条 `model/content` body -> `instances[]`,且移除顶层 `model`
- body 含顶层 `requests[]` 时 -> `instances[]`
- body 已经是 `instances[]` 时只清理重复 `model`
- 无法映射的 body 在调度/模型测试阶段显式失败
- 不得生成 `generativelanguage.googleapis.com`
- 不得生成 `aiplatform.googleapis.com/...:batchEmbedContents`
6. Provider template:
- Vertex fixed template 包含 `gemini:embedding`
- provider embedding support 矩阵包含 Vertex -> Gemini embedding
7. Conversion:
- OpenAI embedding 可以被转换到 Gemini embedding provider format
- Vertex embedding transport 可通过支持检查
- Vertex embedding execution plan 的 URL 使用 mapped provider modelbody 不含顶层 `model`
- Vertex embedding execution plan 的 body 使用 `instances[]` / `parameters`
8. Gateway test connection:
- Gemini generate content 测试不能强制 `maxOutputTokens = 5`
- Google OpenAI-compatible `openai:chat` 测试不能强制 `max_tokens = 5`,否则 Gemini thinking 模型仍可能只返回 thought token / 空 visible content
- Gemini 3 / thinking 模型返回 HTTP 200 但无 visible content 时必须判失败,不能写成成功
9. Google OpenAI-compatible roots:
- Developer API OpenAI root `.../v1beta/openai``openai:chat` 时生成 `.../chat/completions`,不得生成 `.../openai/v1/chat/completions`
- Developer API OpenAI root `.../v1beta/openai``openai:embedding` 时生成 `.../embeddings`,不得生成 `.../openai/v1/embeddings`
- Vertex OpenAI root `.../endpoints/openapi``openai:chat` / `openai:embedding` 时直接挂对应 OpenAI path
- 自定义 Vertex OpenAI-compatible service account endpoint 必须进入 Vertex auth refresh 上下文;普通 `aiplatform.googleapis.com` + `openai:*` 不得被误认成 Vertex OpenAI-compatible
10. Admin write validation:
- Vertex API key key formats 允许 `gemini:generate_content``gemini:embedding`
- Vertex service account key formats 允许 `claude:messages``gemini:generate_content``gemini:embedding`
测试断言必须检查具体 URL、具体 action、具体 body contract 和具体失败原因,不能只检查 `Some(url)` 或状态码。
---
## Live 迁移与验证
上线后必须做四类验证:
1. 源码测试:
- `cargo test -p aether-provider-transport --lib`
- 必要时补 `cargo test -p aether-ai-formats --lib`
- 必要时补 gateway 相关 test
2. Live DB reconciliation
- `vertex_ai` provider 的 endpoints 中必须出现 `gemini:embedding`
- Google/Gemini provider 的 embedding endpoint 仍指向 Developer API,不被 Vertex 改写
3. Live HTTP smoke
- Developer API embedding 单条可用
- Developer API embedding 批量可用
- Vertex generate content 返回 visible content 才算成功
- Vertex embedding 单输入可用
- Vertex embedding 多输入如果模型拒绝,必须暴露 Vertex 原始失败,不能降级成 Developer API 成功
4. 接入方地址核验:
- astrbot plugin ltm
- codex cli config
- 其它容器中引用 Aether 的配置
接入方默认应使用容器网络内稳定地址:
```text
http://aether-app:8084/v1
```
只有在调用方不在 `edge-stack-aether-internal` 这类 Docker 内网、或需要从宿主机/外网访问时,才使用宿主机映射地址或域名。
---
## 明确不做的事
1. 不把 Vertex embedding 多输入写成隐藏循环。隐藏 fan-out 会改变成本、延迟、断路器行为和重试语义,必须另开设计。
2. 不为了测试通过把 Vertex 请求降级到 Developer API。
3. 不为了让 HTTP 200 看起来成功而接受空 candidate / MAX_TOKENS 无 visible content。
4. 不改前端视觉定制、字体、品牌名、landing page 设计。
5. 不用旧 provider endpoint 继续承担新主链。
6. 不把 Google OpenAI-compatible endpoint 当成 native Gemini endpoint 的隐藏 fallback;它只能通过显式 `openai:*` endpoint 进入。
---
## 施工顺序
1. 固化 endpoint family 判定与 URL helper。
2. 为 Vertex `gemini:embedding` 补齐 provider template、runtime policy、conversion policy。
3. 让 request URL builder 对 Vertex embedding 走 Vertex Predict helper。
4. 让 transport body semantics 对 Vertex embedding 生成 `instances[]` / `parameters`,无法转换时 fail closed。
5. 移除测试连接中对 Gemini generate content 的过低 `maxOutputTokens` 硬编码,防止 Gemini 3 thinking 被预算挤空。
6. 移除公开 test-connection 对 OpenAI-compatible chat 的过低 `max_tokens` 硬编码,防止 Google OpenAI-compatible root 复发同类空输出。
7. 跑 red/green 测试。
8. 部署 live。
9. 校验 live DB provider endpoints 与外部接入方地址。
---
## 后续可选增强
如果 7 天 embedding 重算必须在 Vertex 上高吞吐完成,建议后续单独实现 `VertexEmbeddingFanoutExecutor`
- 输入 OpenAI embedding 数组。
- 按配置分片,每片发单条或有限并发 Vertex `predict`
- 合并为 OpenAI embedding response。
- 将每个子请求的失败、重试、成本、断路器状态独立记录。
这项增强不能混入本次 endpoint 语义修复,否则会扩大风险面。
-279
View File
@@ -1,279 +0,0 @@
# Usage Counter Root Fix
This worktree implements the first production cut of the root fix for
usage-related lock contention and hot-row pressure.
## Problem
The current Postgres write path mixes:
- request facts (`usage`, audit snapshots, settlement state)
- shared counters (`api_keys`, `provider_api_keys`, `global_models`)
- provider quota window JSON updates
- wallet settlement writes
That means a single request can hold a transaction while touching multiple shared rows, and high-frequency traffic can serialize on the same `api_key_id` / `provider_api_key_id` / wallet rows.
Relevant code paths:
- `crates/aether-data/src/repository/usage/postgres/mod.rs`
- `crates/aether-data/src/repository/settlement/postgres.rs`
- `crates/aether-usage-runtime/src/runtime.rs`
## Goal
Remove shared-hot-row updates from the request path without losing correctness.
The request path must become:
1. write immutable request facts
2. write durable delta records
3. commit
All shared counters must be derived later by a worker.
## Target Architecture
### 1. Facts first
Keep `usage` as the source of truth for per-request facts:
- request identity
- status transitions
- billing state
- token / cost / latency payloads
- audit snapshots
### 2. Durable counter outbox
Add a new append-only delta table, for example:
`usage_counter_deltas`
Each row should represent one logical contribution:
- `kind` (`api_key`, `provider_api_key`, `model`, `provider_monthly`,
`proxy_node`, `management_token`, `api_key_last_used`, `window`)
- `target_id`
- delta fields
- request id / revision
- `processed_at`
This table is the bridge between request facts and derived counters.
### 3. Flush worker
Add a background worker that:
1. reads unprocessed deltas with `FOR UPDATE SKIP LOCKED`
2. aggregates them in memory by `(kind, target_id)`
3. applies one UPDATE per target
4. marks the source delta rows processed in the same transaction
This keeps memory useful as a buffer, but never as the only source of truth.
### 4. Read models
Move high-read counters to separate tables or compact materialized read models:
- `api_key_usage_counters`
- `provider_api_key_usage_counters`
- `model_usage_counters`
- `provider_monthly_usage_counters`
- `provider_api_key_window_usage_counters`
Keep the original business tables (`api_keys`, `provider_api_keys`, `global_models`) for configuration and compatibility only.
## Implementation Boundary
This branch should land the fix in phases, but the direction must not change:
1. Postgres gets the durable outbox and counter flush path first, because the
reported production lock wait is on Postgres row locks.
2. MySQL and SQLite keep their current usage write behavior until their smaller
deployment paths are moved to the same contract.
3. Request-path Postgres writes may still write `usage`, audit blobs, routing
snapshots, and settlement pricing snapshots. They must not directly update
shared aggregate rows.
4. Compatibility mirror columns may be updated by the flush worker only, never
by the request transaction.
5. Dashboard/statistics read paths should be migrated after the write pressure
is removed, otherwise we risk mixing a large read refactor with the lock fix.
The first executable migration creates the outbox. The first code patch makes
`upsert_usage_record` enqueue deltas in the same transaction as the usage fact
write, then a background worker batches those deltas with
`FOR UPDATE SKIP LOCKED`. Dedicated counter read-model tables remain a follow-up
after the request-path lock pressure is removed.
## Locking Model
### Keep
- advisory lock per `request_id` for idempotent request transitions
- wallet row lock only inside settlement, where correctness depends on it
### Remove from request path
- direct `UPDATE api_keys`
- direct `UPDATE provider_api_keys`
- direct `UPDATE global_models`
- direct `UPDATE providers.monthly_used_usd`
- direct `FOR UPDATE` on provider quota JSON for request-level usage windows
## Memory Cache Rules
Allowed:
- short TTL snapshot cache for read-only admin UI data
- short TTL + single-flight cache for provider/model candidate selection rows
- worker-side delta aggregation buffer
- per-key last-used-at max tracking before flush
Not allowed:
- using memory as the only accounting source
- using memory as the only settlement source
- depending on a clipped Redis stream as the only record of usage
## Rollout Plan
1. Stop counting `pending` / `streaming` as shared counter contributions.
2. Introduce the delta outbox and worker.
3. Redirect request path to facts + outbox only.
4. Migrate reads to the new counter tables.
5. Decommission synchronous hot-row updates.
6. Move provider quota windows out of request transactions.
## Implemented In This Branch
- `usage_counter_deltas` durable outbox for api key, provider api key, model,
provider monthly, proxy node, management token, and api key last-used counters.
- Postgres usage upsert writes request facts plus outbox rows, not shared counter
rows.
- Postgres settlement enqueues provider monthly usage deltas instead of updating
`providers.monthly_used_usd` in the request transaction.
- Gateway request-adjacent proxy node, management token, and api key last-used
writes enqueue durable deltas and fall back to direct writes only when the
usage writer is unavailable.
- Gateway provider/model candidate selection reads use a 5 second in-memory TTL
cache with per-key single-flight. Provider/routing catalog writes invalidate
this cache alongside provider transport and scheduler affinity caches.
- Gateway maintenance worker flushes deltas in batches and aggregates in memory
inside the worker before applying compatibility counter updates.
- Daily quota lookup index on `(user_entitlement_id, usage_date)` removes the
avoidable aggregate scan in quota settlement checks.
- Hot counter columns are widened to `bigint` where old bootstrap schemas still
used `integer`, preventing long-running counter overflow.
## Integration Pressure Tests
The hotspot benchmarks start a managed local Postgres instance, run the
migrations, seed a single hot target, then monitor `pg_stat_activity` while the
load is running. Use a separate target directory when the main worktree target
lock is not writable.
Usage write path, one hot `api_key` / `provider_api_key` / `global_model`:
```sh
CARGO_TARGET_DIR=/tmp/aether-rootfix-target \
cargo run -p aether-testkit --bin usage_counter_hotspot_baseline -- \
--requests 5000 \
--concurrency 200 \
--flush-interval-ms 50 \
--monitor-interval-ms 20 \
--output /tmp/usage_counter_hotspot_after_5000.json
```
Settlement path, one hot provider monthly counter:
```sh
CARGO_TARGET_DIR=/tmp/aether-rootfix-target \
cargo run -p aether-testkit --bin usage_settlement_hotspot_baseline -- \
--requests 5000 \
--concurrency 200 \
--flush-interval-ms 50 \
--monitor-interval-ms 20 \
--output /tmp/usage_settlement_hotspot_after_5000.json
```
Auxiliary hot counters, one hot proxy node / management token / api key
last-used target:
```sh
CARGO_TARGET_DIR=/tmp/aether-rootfix-target \
cargo run -p aether-testkit --bin usage_aux_counter_hotspot_baseline -- \
--requests 5000 \
--concurrency 200 \
--flush-interval-ms 50 \
--monitor-interval-ms 20 \
--output /tmp/usage_aux_counter_hotspot_after_5000.json
```
Latest local run on this worktree:
- usage hotspot: 5000 requests, 200 concurrency, p95 173 ms, 0 failures,
15000 outbox rows processed, 0 pending rows, 0 `api_keys` /
`provider_api_keys` / `global_models` update waiters.
- settlement hotspot: 5000 requests, 200 concurrency, p95 39 ms, 0 failures,
5000 provider monthly deltas processed, `providers.monthly_used_usd = 5.0`,
0 provider update waiters.
Run the auxiliary counter hotspot after changing outbox schemas or gateway
fallback routing; it should drain all pending outbox rows and report zero
request-path waiters for `proxy_nodes`, `management_tokens`, and `api_keys`.
## Runtime Observability
The same outbox health signals used by the pressure tools are exposed through
admin runtime endpoints:
- `GET /api/admin/system/stats`
- `GET /api/admin/monitoring/system-status`
- `GET /api/admin/stats/performance/providers`
These responses include `usage_counter`:
- `status`: `idle`, `catching_up`, or `backlogged`
- `outbox_pending_rows`
- `outbox_processed_rows`
- `oldest_pending_created_at_unix_secs`
- `oldest_pending_age_secs`
- `latest_processed_at_unix_secs`
- `pending_by_kind`
Operational alerting should page when `status = backlogged`, when pending rows
continue growing across several flush intervals, or when the oldest pending age
stays above one minute. A transient non-zero backlog is acceptable during catch
up bursts.
## Remaining Correctness Locks
Wallet debit settlement and daily quota consumption still use database locks
because they protect money/quota correctness, not derived counters. Removing
those locks safely requires a separate wallet debit ledger/reservation worker:
1. request settlement writes an immutable debit intent keyed by `request_id`
2. a per-wallet worker claims intents with `FOR UPDATE SKIP LOCKED`
3. the worker applies balance changes and writes final settlement snapshots
4. request-facing APIs read `pending/settled/insufficient_quota` from the
settlement snapshot
Do not replace this with memory-only balance caches. A cache may accelerate
read-side availability estimates, but the durable ledger must remain the source
of truth.
## Acceptance Criteria
- request transactions no longer update shared counter rows
- counter updates become batchable and replayable
- lock wait time on `api_keys` / `provider_api_keys` drops sharply under concurrency
- wallet settlement remains correct and isolated
- dashboard/statistics reads stay fast from dedicated read models
## Open Decisions
- table names for counter read models
- whether provider window counters live in Postgres only or are dual-written into Redis for display latency
- flush cadence and batch size defaults
- whether to keep compatibility snapshot columns as low-frequency mirrors
-271
View File
@@ -1,271 +0,0 @@
# Aether Gateway DB pressure testing
目标:验证 6k+ 并发长连接/流式请求时,gateway 不把请求并发线性放大成 DB 连接并发,并确认 DB pool、usage queue、后台维护任务不会成为瓶颈。
## 1. 预设环境
生产/压测环境建议显式设置:
```bash
export AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
export AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
export AETHER_GATEWAY_MAINTENANCE_POOL_IDLE_RESERVE=8
export AETHER_GATEWAY_USAGE_QUEUE_TERMINAL_EVENTS=true
export AETHER_GATEWAY_USAGE_QUEUE_LIFECYCLE_EVENTS=true
export AETHER_GATEWAY_USAGE_QUEUE_STREAM_MAXLEN=200000
export AETHER_GATEWAY_USAGE_QUEUE_BATCH_SIZE=500
export AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_COUNT=500
# Pool score DB feedback is rate-limited per provider key to avoid one
# synchronous score UPDATE per successful request.
export AETHER_GATEWAY_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS=5
export AETHER_GATEWAY_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS=1
# Invalid API keys are cached briefly so repeated bad credentials cannot
# linearly amplify into DB lookups. Set 0 to disable during auth debugging.
export AETHER_GATEWAY_AUTH_CONTEXT_NEGATIVE_CACHE_TTL_SECS=10
```
SQLite 不适合 6k 并发压测;请使用 Postgres/MySQL 和 Redis runtime backend。
## 2. Gateway HTTP 压测
准备请求体:
```bash
cat >/tmp/aether-pressure-request.json <<'JSON'
{"model":"gpt-5-mini","messages":[{"role":"user","content":"ping"}],"stream":true}
JSON
```
运行 6k 并发(高并发结论必须使用 release;debug 构建会把 CPU/调试开销误判成 planning timeout):
```bash
TARGET_URL=http://127.0.0.1:18080/v1/chat/completions \
METRICS_URL=http://127.0.0.1:18080/_gateway/metrics \
PRESSURE_METHOD=POST \
PRESSURE_REQUESTS=60000 \
PRESSURE_CONCURRENCY=6000 \
PRESSURE_TIMEOUT_MS=120000 \
PRESSURE_BODY_FILE=/tmp/aether-pressure-request.json \
AUTH_HEADER='Authorization: Bearer <api-key>' \
EXTRA_HEADERS='Content-Type: application/json' \
PRESSURE_RESPONSE_MODE=full \
PRESSURE_CARGO_PROFILE=release \
OUTPUT=/tmp/aether_gateway_pressure_6k.json \
tools/pressure/run_gateway_6k_pressure.sh
```
如果压测流式长连接,建议让 probe 读完整响应体,否则客户端拿到 headers 后会立刻断开:
```bash
PRESSURE_RESPONSE_MODE=full
```
## 3. 本地 mock upstream
没有可承载 6k 并发的真实上游 key 时,先用 testkit 启一个 OpenAI-compatible mock upstream
```bash
cargo run --release -p aether-testkit --bin mock_openai_upstream -- \
--bind 127.0.0.1:18181 \
--chunks 8 \
--first-byte-delay-ms 0 \
--chunk-delay-ms 20 \
--payload-bytes 32
```
可直接压 mock 网络栈:
```bash
cat >/tmp/aether-mock-request.json <<'JSON'
{"model":"mock-model","messages":[{"role":"user","content":"ping"}],"stream":true}
JSON
cargo run --release -p aether-testkit --bin http_load_probe -- \
--url http://127.0.0.1:18181/v1/chat/completions \
--method POST \
--requests 60000 \
--concurrency 6000 \
--timeout-ms 120000 \
--header 'Content-Type: application/json' \
--body-file /tmp/aether-mock-request.json \
--response-mode full
```
要做 gateway 端到端压测,把一个本地压测 provider/endpoint 指到
`http://127.0.0.1:18181/v1`provider key 用 dummy 值即可;gateway 侧仍需一个
本地 Aether API key,但不再消耗真实上游额度。
报告重点字段:
```json
{
"load": {
"throughput_rps": 0,
"failed_requests": 0,
"error_counts": {},
"p95_ms": 0,
"p99_ms": 0
},
"metrics": {
"db_pool_max_checked_out": 0,
"db_pool_min_idle": 0,
"db_pool_max_usage_basis_points": 0,
"db_pool_pressure_samples": 0,
"gateway_requests_max_rejected_total": 0
}
}
```
### 本地全链路 release 基线(mock upstream
本地全链路压测固定为:release gateway + release mock upstream + 本地 Postgres/Redis + 临时 Aether API key/provider/model。
不要把 6k 长连接理解为 6k DB 连接;目标是 6k 前端连接下 DB pool 维持在几十级。
最近可复现基线:
| 场景 | 结果 | throughput | p50 | p95 | p99 | DB pool | mock in-flight |
| --- | --- | ---: | ---: | ---: | ---: | --- | ---: |
| 1000 req / 100 conc | 1000x 200 / 0 fail | 211 rps | 209ms | 729ms | 1171ms | max checked out 33/48 | - |
| 6000 req / 6000 conc, sync terminal candidate | 6000x 200 / 0 fail, `error_counts={}` | 262 rps | 20198ms | 22496ms | 22748ms | max checked out 48/48, pressure samples 7 | 396 |
| 6000 req / 6000 conc, async candidate queue | 6000x 200 / 0 fail, `error_counts={}` | 367 rps | 13678ms | 16060ms | 16245ms | max checked out 48/48, pressure samples 3 | - |
| 6000 req / 6000 conc, async queue + slot compaction | 6000x 200 / 0 fail, `error_counts={}` | 344 rps | 14613ms | 17143ms | 17315ms | max checked out 48/48, pressure samples 3 | - |
6000/6000 下实际打开约 6k FD,说明客户端长连接链路有效。terminal 模式下同一请求可能产生 2 条 candidate 状态写入;
async queue 会把这部分从前台请求路径移出,slot compaction 会把同 slot/同状态的重复记录合并后再落库。
如需测纯转发极限,可临时把 `AETHER_GATEWAY_REQUEST_CANDIDATE_PERSISTENCE=none` 与 terminal 模式对照。
常用本地命令(不要打印 API key):
```bash
source /tmp/aether_local_env.sh
KEY=$(cat /tmp/aether_fullchain_api_key)
CARGO_TARGET_DIR=/tmp/aether-release-pressure \
TARGET_URL=http://127.0.0.1:8088/v1/chat/completions \
METRICS_URL=http://127.0.0.1:8088/_gateway/metrics \
PRESSURE_METHOD=POST \
PRESSURE_REQUESTS=6000 \
PRESSURE_CONCURRENCY=6000 \
PRESSURE_TIMEOUT_MS=120000 \
PRESSURE_SAMPLE_INTERVAL_MS=250 \
PRESSURE_BODY_FILE=/tmp/aether-mock-request.json \
PRESSURE_RESPONSE_MODE=full \
PRESSURE_CARGO_PROFILE=release \
AUTH_HEADER="Authorization: Bearer ${KEY}" \
EXTRA_HEADERS='Content-Type: application/json' \
OUTPUT=/tmp/aether_gateway_release_6000_6000_full.json \
tools/pressure/run_gateway_6k_pressure.sh
```
```bash
docker compose exec -T -e PGPASSWORD="$DB_PASSWORD" postgres psql -U postgres -d aether -P pager=off -c "
SELECT calls, round(total_exec_time::numeric,1) total_ms,
round(mean_exec_time::numeric,3) mean_ms, rows,
left(regexp_replace(query, '\s+', ' ', 'g'), 280) query
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 60;"
```
### Request candidate async persistence
`request_candidates` 是当前 6k 全链路下最明显的同步 DB 写入点。生产/压测可以先保留
`AETHER_GATEWAY_REQUEST_CANDIDATE_PERSISTENCE=terminal`,再把 terminal 写入从前台 await 改为异步队列:
```bash
export AETHER_GATEWAY_REQUEST_CANDIDATE_PERSISTENCE=terminal
export AETHER_GATEWAY_REQUEST_CANDIDATE_WRITE_MODE=async
export AETHER_GATEWAY_REQUEST_CANDIDATE_QUEUE_CAPACITY=65536
export AETHER_GATEWAY_REQUEST_CANDIDATE_QUEUE_BATCH_SIZE=512
export AETHER_GATEWAY_REQUEST_CANDIDATE_QUEUE_FLUSH_INTERVAL_MS=50
export AETHER_GATEWAY_REQUEST_CANDIDATE_QUEUE_WORKERS=2
# 队列满时默认 drop trace,保护前台请求;需要强一致审计时可设 sync。
export AETHER_GATEWAY_REQUEST_CANDIDATE_QUEUE_FULL=drop
```
新增 metrics
- `request_candidate_queue_depth`
- `request_candidate_queue_pending_depth`
- `request_candidate_queue_capacity`
- `request_candidate_queue_enqueued_total`
- `request_candidate_queue_dropped_total`
- `request_candidate_queue_flushed_total`
- `request_candidate_queue_flush_failed_total`
- `request_candidate_queue_flush_batches_total`
- `request_candidate_queue_flush_sql_ops_total`
- `request_candidate_queue_compacted_total`
- `request_candidate_queue_sync_fallback_total`
判定:6k/6k 下 `dropped_total=0``flush_failed_total=0`,压测结束后
`queue_depth``pending_depth` 应回到 0。
`flush_sql_ops_total` 应低于 `flushed_total`;差值体现在 `compacted_total`
用于确认 terminal candidate 的重复状态写入已在队列侧合并。
当前本地 6000/6000 合并版观测:
- `enqueued_total=12000`
- `flushed_total=12000`
- `flush_sql_ops_total=6406`
- `compacted_total=5594`
- `dropped_total=0`
- `flush_failed_total=0`
- `request_candidates` 最终 `6000 rows / 6000 request_id`
## 4. 判定标准
优先看这些信号:
- `failed_requests == 0` 或仅包含预期的上游错误。
- `db_pool_max_checked_out` 不应接近 `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`
- `db_pool_min_idle` 在大部分采样中应高于 idle reserve。
- `db_pool_max_usage_basis_points < 8000` 比较健康;持续 `>9000` 表示 DB pool 或 SQL 写入已接近瓶颈。
- `db_pool_pressure_samples` 可以短暂出现,但不应贯穿压测全程。
- `gateway_requests_max_rejected_total` 不应增长,除非有意测试 admission limit。
## 5. DB 热点写入专项压测
这些 testkit 场景会启动临时 Postgres,适合回归验证 counter/settlement 热点锁竞争:
```bash
cargo run -p aether-testkit --bin usage_counter_hotspot_baseline -- \
--requests 20000 --concurrency 1000 \
--flush-interval-ms 50 --monitor-interval-ms 20 \
--output /tmp/aether_usage_counter_20000_1000.json
cargo run -p aether-testkit --bin usage_settlement_hotspot_baseline -- \
--requests 20000 --concurrency 1000 \
--flush-interval-ms 50 --monitor-interval-ms 20 \
--output /tmp/aether_usage_settlement_20000_1000.json
cargo run -p aether-testkit --bin usage_aux_counter_hotspot_baseline -- \
--requests 20000 --concurrency 1000 \
--flush-interval-ms 50 --monitor-interval-ms 20 \
--output /tmp/aether_usage_aux_counter_20000_1000.json
```
关注:
- `failed_requests`
- `throughput_rps`
- `p95_ms`
- `lock_monitor.max_*_update_waiters`
- `lock_monitor.max_oldest_lock_wait_ms`
定向 update waiter 持续大于 0,说明某类 counter/settlement 仍有热点行锁竞争,需要继续分桶或延迟聚合。
## 6. 本地回归基线
最近一次本地临时 Postgres 回归(`requests=60000, concurrency=6000, max_connections=64`):
| suite | throughput | failed | p95 | 定向 update waiters |
| --- | ---: | ---: | ---: | ---: |
| `usage_settlement_hotspot_baseline` | 5452 rps | 0 | 740ms | usage 10 / wallet 0 / provider 0 |
| `usage_counter_hotspot_baseline` | 1358 rps | 0 | 1865ms | api_key 0 / provider_key 0 / model 0 / provider 0 |
| `usage_aux_counter_hotspot_baseline` | 1899 rps | 0 | 785ms | proxy 0 / management_token 0 / api_key 0 |
这些是 DB 热点写入专项结果,不等价于完整 gateway 端到端 6k 流式压测;完整压测仍需使用第 2 节的 gateway URL、真实 API key、Redis runtime backend 与目标模型请求体。