feat: disk request body cache - #2780
Conversation
- 新增 common/body_storage.go 提供 HTTP 请求体存储抽象和文件缓存能力 - 增加 common/disk_cache_config.go 支持全局磁盘缓存配置 - main.go 挂载缓存初始化流程 - 新增和补充 controller/performance.go (及 unix/windows) 用于缓存性能监控接口 - middleware/body_cleanup.go 自动清理缓存文件 - router 挂载相关接口 - 前端 settings 页面新增性能监控设置 PerformanceSetting - 优化缓存开关状态和模块热插拔能力 - 其他相关文件同步适配缓存扩展
- 修复 diskStorage.Close() 竞态条件,先获取锁再执行 CAS - 为 memoryStorage 添加互斥锁和 closed 状态检查 - 修复 CreateBodyStorageFromReader 在磁盘存储失败时的回退逻辑 - 添加缓存命中统计调用 (IncrementDiskCacheHits/IncrementMemoryCacheHits) - 修复 gin.go 中 Seek 错误被忽略的问题 - 在 api-router 添加 BodyStorageCleanup 中间件 - 修复前端 formatBytes 对异常值的处理 Co-authored-by: Cursor <cursoragent@cursor.com>
WalkthroughThis pull request introduces a pluggable disk-based body storage system with in-memory fallback, disk caching configuration management, performance monitoring APIs, and a corresponding frontend settings interface. The implementation spans core storage abstractions, API endpoints, middleware integration, configuration management, and React UI components. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Middleware
participant Storage as BodyStorage Factory
participant Backend as Memory/Disk Storage
participant Context
participant Cleanup
Client->>Middleware: HTTP Request with Body
Middleware->>Storage: CreateBodyStorageFromReader()
alt Content Length Check
Storage->>Backend: Use Disk Storage (large)
else
Storage->>Backend: Use Memory Storage (small)
end
Backend-->>Storage: BodyStorage Instance
Storage-->>Middleware: BodyStorage
Middleware->>Context: Store BodyStorage in Context
Middleware->>Client: Forward to Handler
Client->>Middleware: Request Processing
Middleware->>Context: Retrieve BodyStorage
Context-->>Middleware: BodyStorage
Middleware->>Backend: Read/Seek Body Data
Backend-->>Middleware: Body Bytes
Middleware->>Cleanup: c.Next() Complete
Cleanup->>Backend: Close & Cleanup
Backend->>Cleanup: Release Resources
Cleanup-->>Client: Response
sequenceDiagram
participant Frontend as React UI
participant API as /api/performance
participant Config as DiskCacheConfig
participant Stats as DiskCacheStats
participant Disk as Disk Operations
Frontend->>API: GET /stats
API->>Config: GetDiskCacheConfig()
API->>Stats: GetDiskCacheStats()
API->>Disk: Gather Disk Space Info
Stats-->>API: Cache Stats
Config-->>API: Cache Config
Disk-->>API: Disk Space Metrics
API-->>Frontend: Performance Data JSON
Frontend->>Frontend: Render Stats & Charts
Frontend->>API: DELETE /disk_cache (clear)
API->>Disk: Remove Cache Directory
API->>Stats: ResetDiskCacheStats()
Disk-->>API: Success
Stats-->>API: Reset Complete
API-->>Frontend: Clear Confirmation
Frontend->>Frontend: Update UI
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@common/body_storage.go`:
- Around line 231-263: In diskStorage.Bytes() the allocation make([]byte,
d.size) fails because d.size is int64; check that d.size is non-negative and
does not exceed the platform max int (e.g., math.MaxInt) and return an error if
it does, then safely convert with int(size) and use that for the slice
allocation and reading; update references in the method (d.size, Bytes, and the
io.ReadFull call) to use the converted int variable for buffer creation and any
length checks.
In `@common/gin.go`:
- Around line 89-118: GetBodyStorage currently calls GetRequestBody which reads
and caches the full request body (KeyRequestBody) into memory; instead build and
cache a disk-backed BodyStorage directly from c.Request.Body (or its reader) and
store it under KeyBodyStorage without populating KeyRequestBody. Modify
GetBodyStorage to: check for existing KeyBodyStorage and seek to start if
present; if missing, create a BodyStorage from the request reader (wrapping in a
temp file/streaming buffer), store only that under KeyBodyStorage, and return
it; remove the GetRequestBody call so large payloads are not eagerly loaded into
memory. Ensure you still reset the request.Body or replace it with an
io.ReadCloser if handlers need to read it later.
In `@web/src/components/settings/PerformanceSetting.jsx`:
- Around line 38-47: The current success branch builds a newInputs object from
data and calls setInputs(newInputs), which replaces the entire state and drops
any default keys missing from the API response; instead merge the API-derived
values into the existing/default state (inputs) so missing keys are preserved —
build newInputs the same way (using toBoolean for boolean keys) but call
setInputs(prev => ({ ...prev, ...newInputs })) or merge with the initial
defaults so omitted keys are retained; update the code around setInputs,
newInputs, inputs, and toBoolean accordingly.
🧹 Nitpick comments (5)
controller/misc.go (1)
118-118: Consider using a more descriptive key name or adding a comment.The key
_qnis cryptic and its purpose isn't immediately clear. If this is intended as a product/API identifier (possibly "QuantumNous"), consider either:
- Using a more descriptive key like
"_api_identifier"or"_product"- Adding an inline comment explaining the purpose
Additionally, this change appears unrelated to the PR's stated objective of "storage request body cache" - is this intentional?
setting/performance_setting/config.go (1)
46-48: Consider returning a copy instead of a pointer to prevent unintended mutations.
GetPerformanceSetting()returns a pointer to the package-levelperformanceSettingvariable. Callers could mutate the struct directly, causing the common package configuration to become out of sync (sincesyncToCommon()wouldn't be called).If this is only used for read-only access in admin contexts, the current implementation is acceptable. Otherwise, consider returning a copy:
♻️ Suggested change to return a copy
// GetPerformanceSetting 获取性能设置 -func GetPerformanceSetting() *PerformanceSetting { - return &performanceSetting +func GetPerformanceSetting() PerformanceSetting { + return performanceSetting }web/src/components/settings/PerformanceSetting.jsx (1)
26-33: Preferconstfor state variables.Using
letfor useState variables is unconventional in React. Since the returned array is never reassigned,constis the idiomatic choice.♻️ Proposed fix
- let [inputs, setInputs] = useState({ + const [inputs, setInputs] = useState({ 'performance_setting.disk_cache_enabled': false, 'performance_setting.disk_cache_threshold_mb': 10, 'performance_setting.disk_cache_max_size_mb': 1024, 'performance_setting.disk_cache_path': '', }); - let [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false);web/src/pages/Setting/Performance/SettingsPerformance.jsx (2)
82-92: Simplify redundant conditional branches.Both branches of the if-else perform identical
String()conversion. The condition is unnecessary.♻️ Proposed fix
const requestQueue = updateArray.map((item) => { - let value = ''; - if (typeof inputs[item.key] === 'boolean') { - value = String(inputs[item.key]); - } else { - value = String(inputs[item.key]); - } + const value = String(inputs[item.key]); return API.put('/api/option/', { key: item.key, value, }); });
290-377: Add null-safe access for stats properties.The stats rendering accesses nested properties (e.g.,
stats.cache_stats.current_disk_usage_bytes) directly. Whilestatsis checked at line 290, malformed API responses could cause runtime errors if nested objects are missing.Consider optional chaining for defensive access:
🛡️ Example with optional chaining
- <Text type='tertiary'> - {formatBytes(stats.cache_stats.current_disk_usage_bytes)} / {formatBytes(stats.cache_stats.disk_cache_max_bytes)} - </Text> + <Text type='tertiary'> + {formatBytes(stats?.cache_stats?.current_disk_usage_bytes)} / {formatBytes(stats?.cache_stats?.disk_cache_max_bytes)} + </Text>Apply similar changes to other nested accesses like
stats.memory_stats.*,stats.disk_cache_info.*, etc.
| func (d *diskStorage) Bytes() ([]byte, error) { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
|
|
||
| if atomic.LoadInt32(&d.closed) == 1 { | ||
| return nil, ErrStorageClosed | ||
| } | ||
|
|
||
| // 保存当前位置 | ||
| currentPos, err := d.file.Seek(0, io.SeekCurrent) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // 移动到开头 | ||
| if _, err := d.file.Seek(0, io.SeekStart); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // 读取全部内容 | ||
| data := make([]byte, d.size) | ||
| _, err = io.ReadFull(d.file, data) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // 恢复位置 | ||
| if _, err := d.file.Seek(currentPos, io.SeekStart); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return data, nil | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
sed -n '1,150p' common/body_storage.go | cat -nRepository: QuantumNous/new-api
Length of output: 4084
🏁 Script executed:
sed -n '231,263p' common/body_storage.go | cat -nRepository: QuantumNous/new-api
Length of output: 873
Fix compile error: slice length must be int, not int64.
make([]byte, d.size) won't compile because d.size is int64. Convert safely and guard against overflow before allocation.
Suggested fix
- // 读取全部内容
- data := make([]byte, d.size)
+ // 读取全部内容
+ if d.size > int64(^uint(0)>>1) {
+ return nil, fmt.Errorf("body too large to allocate: %d bytes", d.size)
+ }
+ data := make([]byte, int(d.size))
_, err = io.ReadFull(d.file, data)
if err != nil {
return nil, err
}🤖 Prompt for AI Agents
In `@common/body_storage.go` around lines 231 - 263, In diskStorage.Bytes() the
allocation make([]byte, d.size) fails because d.size is int64; check that d.size
is non-negative and does not exceed the platform max int (e.g., math.MaxInt) and
return an error if it does, then safely convert with int(size) and use that for
the slice allocation and reading; update references in the method (d.size,
Bytes, and the io.ReadFull call) to use the converted int variable for buffer
creation and any length checks.
| // GetBodyStorage 获取请求体存储对象(用于需要多次读取的场景) | ||
| func GetBodyStorage(c *gin.Context) (BodyStorage, error) { | ||
| // 检查是否已有存储 | ||
| if storage, exists := c.Get(KeyBodyStorage); exists && storage != nil { | ||
| if bs, ok := storage.(BodyStorage); ok { | ||
| if _, err := bs.Seek(0, io.SeekStart); err != nil { | ||
| return nil, fmt.Errorf("failed to seek body storage: %w", err) | ||
| } | ||
| return bs, nil | ||
| } | ||
| } | ||
|
|
||
| // 如果没有,调用 GetRequestBody 创建存储 | ||
| _, err := GetRequestBody(c) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // 再次获取存储 | ||
| if storage, exists := c.Get(KeyBodyStorage); exists && storage != nil { | ||
| if bs, ok := storage.(BodyStorage); ok { | ||
| if _, err := bs.Seek(0, io.SeekStart); err != nil { | ||
| return nil, fmt.Errorf("failed to seek body storage: %w", err) | ||
| } | ||
| return bs, nil | ||
| } | ||
| } | ||
|
|
||
| return nil, errors.New("failed to get body storage") | ||
| } |
There was a problem hiding this comment.
Avoid loading the full body into memory in GetBodyStorage.
GetBodyStorage currently calls GetRequestBody, which reads the entire request body and caches KeyRequestBody, negating disk‑backed streaming and risking OOM on large payloads. Build the BodyStorage directly from the reader and cache only the storage.
💡 Suggested fix
func GetBodyStorage(c *gin.Context) (BodyStorage, error) {
// 检查是否已有存储
if storage, exists := c.Get(KeyBodyStorage); exists && storage != nil {
if bs, ok := storage.(BodyStorage); ok {
if _, err := bs.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to seek body storage: %w", err)
}
return bs, nil
}
}
- // 如果没有,调用 GetRequestBody 创建存储
- _, err := GetRequestBody(c)
- if err != nil {
- return nil, err
- }
-
- // 再次获取存储
- if storage, exists := c.Get(KeyBodyStorage); exists && storage != nil {
- if bs, ok := storage.(BodyStorage); ok {
- if _, err := bs.Seek(0, io.SeekStart); err != nil {
- return nil, fmt.Errorf("failed to seek body storage: %w", err)
- }
- return bs, nil
- }
- }
-
- return nil, errors.New("failed to get body storage")
+ maxMB := constant.MaxRequestBodyMB
+ if maxMB <= 0 {
+ maxMB = 128 // 默认 128MB
+ }
+ maxBytes := int64(maxMB) << 20
+ contentLength := c.Request.ContentLength
+
+ storage, err := CreateBodyStorageFromReader(c.Request.Body, contentLength, maxBytes)
+ _ = c.Request.Body.Close()
+ if err != nil {
+ if IsRequestBodyTooLargeError(err) {
+ return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
+ }
+ return nil, err
+ }
+
+ c.Set(KeyBodyStorage, storage)
+ return storage, nil
}🤖 Prompt for AI Agents
In `@common/gin.go` around lines 89 - 118, GetBodyStorage currently calls
GetRequestBody which reads and caches the full request body (KeyRequestBody)
into memory; instead build and cache a disk-backed BodyStorage directly from
c.Request.Body (or its reader) and store it under KeyBodyStorage without
populating KeyRequestBody. Modify GetBodyStorage to: check for existing
KeyBodyStorage and seek to start if present; if missing, create a BodyStorage
from the request reader (wrapping in a temp file/streaming buffer), store only
that under KeyBodyStorage, and return it; remove the GetRequestBody call so
large payloads are not eagerly loaded into memory. Ensure you still reset the
request.Body or replace it with an io.ReadCloser if handlers need to read it
later.
| if (success) { | ||
| let newInputs = {}; | ||
| data.forEach((item) => { | ||
| if (typeof inputs[item.key] === 'boolean') { | ||
| newInputs[item.key] = toBoolean(item.value); | ||
| } else { | ||
| newInputs[item.key] = item.value; | ||
| } | ||
| }); | ||
| setInputs(newInputs); |
There was a problem hiding this comment.
State may lose default values if API doesn't return all keys.
The newInputs object only includes keys present in the API response. If the API omits a key, setInputs(newInputs) replaces the entire state, discarding default values for missing keys. Consider merging with the initial defaults.
🛠️ Proposed fix
if (success) {
- let newInputs = {};
+ let newInputs = { ...inputs }; // preserve defaults
data.forEach((item) => {
- if (typeof inputs[item.key] === 'boolean') {
+ if (item.key in inputs) {
+ if (typeof inputs[item.key] === 'boolean') {
newInputs[item.key] = toBoolean(item.value);
- } else {
+ } else {
newInputs[item.key] = item.value;
+ }
}
});
setInputs(newInputs);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (success) { | |
| let newInputs = {}; | |
| data.forEach((item) => { | |
| if (typeof inputs[item.key] === 'boolean') { | |
| newInputs[item.key] = toBoolean(item.value); | |
| } else { | |
| newInputs[item.key] = item.value; | |
| } | |
| }); | |
| setInputs(newInputs); | |
| if (success) { | |
| let newInputs = { ...inputs }; // preserve defaults | |
| data.forEach((item) => { | |
| if (item.key in inputs) { | |
| if (typeof inputs[item.key] === 'boolean') { | |
| newInputs[item.key] = toBoolean(item.value); | |
| } else { | |
| newInputs[item.key] = item.value; | |
| } | |
| } | |
| }); | |
| setInputs(newInputs); |
🤖 Prompt for AI Agents
In `@web/src/components/settings/PerformanceSetting.jsx` around lines 38 - 47, The
current success branch builds a newInputs object from data and calls
setInputs(newInputs), which replaces the entire state and drops any default keys
missing from the API response; instead merge the API-derived values into the
existing/default state (inputs) so missing keys are preserved — build newInputs
the same way (using toBoolean for boolean keys) but call setInputs(prev => ({
...prev, ...newInputs })) or merge with the initial defaults so omitted keys are
retained; update the code around setInputs, newInputs, inputs, and toBoolean
accordingly.
主要更新: - feat: 磁盘请求体缓存 (QuantumNous#2780) - feat: OpenAI Response API /v1/response/compact (QuantumNous#2644) - feat: 渠道亲和性 (Channel Affinity) (QuantumNous#2669) - feat: Codex渠道支持 (QuantumNous#2652) - feat: Claude/Grok refusal reason显示 - feat: 性能监控和GC控制API - fix: 用户配额获取逻辑 (QuantumNous#2749) - fix: Gemini多工具调用索引问题 - fix: 错误时仍本地计费 冲突解决: - README.md: 保留中文版本 - go.mod: 采用上游较新版本 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: 引入通用 HTTP BodyStorage/DiskCache 缓存配置与管理 - 新增 common/body_storage.go 提供 HTTP 请求体存储抽象和文件缓存能力 - 增加 common/disk_cache_config.go 支持全局磁盘缓存配置 - main.go 挂载缓存初始化流程 - 新增和补充 controller/performance.go (及 unix/windows) 用于缓存性能监控接口 - middleware/body_cleanup.go 自动清理缓存文件 - router 挂载相关接口 - 前端 settings 页面新增性能监控设置 PerformanceSetting - 优化缓存开关状态和模块热插拔能力 - 其他相关文件同步适配缓存扩展 * fix: 修复 BodyStorage 并发安全和错误处理问题 - 修复 diskStorage.Close() 竞态条件,先获取锁再执行 CAS - 为 memoryStorage 添加互斥锁和 closed 状态检查 - 修复 CreateBodyStorageFromReader 在磁盘存储失败时的回退逻辑 - 添加缓存命中统计调用 (IncrementDiskCacheHits/IncrementMemoryCacheHits) - 修复 gin.go 中 Seek 错误被忽略的问题 - 在 api-router 添加 BodyStorageCleanup 中间件 - 修复前端 formatBytes 对异常值的处理 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.