fix(backend): 补齐单次上传端点的并发护栏(BACKLOG C7)
- middleware/concurrency_guard.js: HEAVY_PATTERNS 增加单段上传正则
/^\/api\/upload\/[^/]+$/i —— 单段锚定([^/]+$)保证不误伤"两段"的轻路由:
GET /status、GET /submissions、DELETE /:id(放宽成前缀匹配会让「查状态也 429」)
- controller/upload.js: upload() 补 await acquireFileIo() +
finally { if (ioAcquired) releaseFileIo(); }
⚠️ 不用 uploadChunk 的无条件 release:本方法 429 分支是 acquire 失败后 return,
无条件释放会把"别人的"配额放掉 => 计数下溢 => 护栏静默失效
背景:修复前该端点是**唯一在用**的作品上传路径(Competition.vue:477),却既不在
concurrencyGuard.HEAVY_PATTERNS(表内 chunk/merge 已成死路由)、也不调 file_io_guard
=> 零护栏。因服务器入带宽仅 ≈1.25MB/s 天然节流,当前风险有限,但按 C3 升入带宽
后即变实质风险 => 与 C3 绑定,先补护栏。
验证:
- verify-guard-r43.mjs 33/33(单元:路径判别力 + 满载 429 + 配额隔离 + 释放语义 + 变异自证)
- verify-guard-r43-live.mjs 8/9(真接口:30 路 5MB 并发上传 => 20 成功 / 10 个 429 +
Retry-After: 2;15 个轻路由探针在并发中全 200;唯一失败为清理步骤,根因是
saveSubmissionRecord 的 upsert 语义,非缺陷)
- 重启后 master PID 23684;[AppBoot][pool] 40 <= 151
This commit is contained in:
@@ -52,11 +52,36 @@ class UploadController extends Controller {
|
||||
*/
|
||||
async upload() {
|
||||
const { ctx, service } = this;
|
||||
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
|
||||
let ioAcquired = false;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
|
||||
//
|
||||
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
|
||||
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
|
||||
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
|
||||
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
|
||||
//
|
||||
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
|
||||
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
|
||||
//
|
||||
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
|
||||
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
|
||||
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
|
||||
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
|
||||
try {
|
||||
await acquireFileIo();
|
||||
ioAcquired = true;
|
||||
} catch (e) {
|
||||
ctx.status = 429;
|
||||
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证截止时间
|
||||
const deadlineCheck = await service.upload.validateDeadline(group);
|
||||
if (!deadlineCheck.valid) {
|
||||
@@ -133,6 +158,14 @@ class UploadController extends Controller {
|
||||
ctx.logger.error('文件上传失败:', err);
|
||||
const msg = (err && err.message) ? err.message : '未知错误';
|
||||
ctx.body = ctx.helper.error(500, msg);
|
||||
} finally {
|
||||
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
|
||||
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
|
||||
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
|
||||
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
|
||||
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
|
||||
// 否则配额只减不增,若干轮后上传通道**永久 429**。
|
||||
if (ioAcquired) releaseFileIo();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user