修复 BgFilter 发布窗口配置漂移
发布窗口内 callBudget 漂移改为告警计数并按 Worker 当前预算执行 Provision 验活地址改为从已校验的自定义端口派生 同步 BgFilter 调度方案与后端数据契约文档
This commit is contained in:
@@ -152,7 +152,7 @@ Authorization: Bearer <internal-token>
|
||||
- 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。
|
||||
- `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。
|
||||
- `screenColor` 只对 flat 必填;complex 不得误接 flat 参数或熔断。
|
||||
- `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 必须等于 worker 本进程按 `N / est` 公式算出的值,不一致按 `invalid_request` 拒绝,用于快速暴露父子配置漂移。
|
||||
- `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 是父侧按 `N / est` 公式算出的“配置指纹”,仅作核对:worker 始终以自己按同一公式派生的值执行,不一致时不拒绝请求,而是记录 warn 日志并递增漂移指标。发布调优 N / est 时新旧进程共存的瞬态漂移因此不会误伤在途任务;持久性漂移的硬拦截由部署脚本的共享 env 对齐校验承担。
|
||||
- JSON body 设置很小的固定上限;源图字节不进入该 JSON。
|
||||
|
||||
签名 URL 必须在取得 provider permit 后、每次 attempt 前生成,避免排队期间过期。签名 URL 只存在于子 worker 内存和发往 BgFilter 的请求中。
|
||||
@@ -262,7 +262,7 @@ attempt 公式的依据:BgFilter 服务端高并发时单图处理约 `1-3s`
|
||||
|
||||
flat 调用还要由父侧从可分配预算中扣除 `39s`:阿里云 request timeout(默认 `30s`)与本地处理余量 `7s` 构成 `37s` fallback 窗口,另有 `2s` 内部响应传输窗,避免排队吃完全部预算后名义上有 fallback、实际上已无时间执行。
|
||||
|
||||
`N` 与 `est` 必须由父子进程使用同一份有效值:父侧要用它们算 `callBudgetMs` 与 client timeout,worker 要用它们算 attempt 与队列估时。两者都放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖;worker 侧还应校验请求携带的 `callBudgetMs` 与本进程公式值一致,配置漂移时返回 `invalid_request` 快速暴露。
|
||||
`N` 与 `est` 必须由父子进程使用同一份有效值:父侧要用它们算 `callBudgetMs` 与 client timeout,worker 要用它们算 attempt 与队列估时。两者都放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖。worker 侧把请求携带的 `callBudgetMs` 与本进程公式值比对,不一致只告警并计指标、仍以本进程值执行——运行时校验必须容忍发布重启窗口内的瞬态漂移,持久漂移由部署脚本对齐校验在启动前拦截。
|
||||
|
||||
inline / External v1 当前没有显式 `RequestContext` deadline 时,内部 RPC 仍必须有界:`callBudgetMs` 同公式,`maxQueueWaitMs` 默认取与 `callBudgetMs` 等长的额度,因此默认父 client timeout 为 `2 × callBudgetMs + 2s`;后续若同步请求显式注入 deadline,再按同一 `maxQueueWaitMs` 公式从剩余预算派生。
|
||||
|
||||
@@ -405,6 +405,7 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
|
||||
- `bgfilter_internal_waiting_requests`(即 admission 后等待 `N` permit 的队长)
|
||||
- `bgfilter_internal_queue_timeout_total{bound}`(排队超时按触发边界 `estimate | parent` 分维度)
|
||||
- `bgfilter_internal_call_budget_drift_total{mode}`(请求 `callBudgetMs` 与本进程公式值不一致;发布窗口内短暂非零正常,持续增长说明父子 N / est 真漂移)
|
||||
- `bgfilter_internal_in_flight`
|
||||
- `bgfilter_internal_request_seconds{mode,outcome}`
|
||||
- `bgfilter_provider_http_seconds{mode,attempt,outcome}`
|
||||
@@ -443,6 +444,8 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
4. 再重启使用内部 client 的 API / external-generation-worker / controller。
|
||||
5. 确认所有父进程只访问内部 endpoint,BgFilter provider 日志中不再出现父进程直连。
|
||||
|
||||
第 3-4 步之间存在“新 worker + 旧父进程”共存窗口,恰好覆盖旧 external-generation-worker 的优雅排空阶段。调优 N / est 的发布会让窗口内请求携带旧公式的 `callBudgetMs`:worker 按 §4.1 只告警不拒绝、以自身公式值执行,在途任务照常完成或走既有 fallback。发布期间 `bgfilter_internal_call_budget_drift_total` 短暂增长属预期,窗口结束后应停止增长;持续增长才需要排查 env 漂移。
|
||||
|
||||
内部 worker 不可用时禁止自动 direct fallback。flat 仍可走业务已有阿里云 / 本地 fallback;complex 明确失败。需要整体回滚时回滚父、子进程版本和配置,不在运行中混用两种 BgFilter 调度方式。
|
||||
|
||||
`bgfilter-worker` 收到停止信号后先停止接收新请求,并让仍在排队等待 `N` permit 的请求立即以类型化错误收口(父侧 flat 走 fallback),只等待已取得 permit 的调用和已启动 provider attempt 排空。排空上界由 `callBudgetMs`(`N = 16`、`est = 5s` 时约 `321s`)决定,因此 systemd unit 固定使用 `TimeoutStopSec=900` 仍有充分余量;部署脚本的同步 `systemctl stop` 必须允许该窗口完成,不能沿用 systemd 常见的约 `90s` 默认值强杀在途调用。排队请求不参与排空等待——它们尚未发出任何外部请求,快速失败是安全的。
|
||||
@@ -461,7 +464,7 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
- listener 在 body 解析前执行鉴权与 admission;保险丝 `Q`(默认 `2048`)触达后新请求立即返回 `overloaded`,内核 socket backlog 按独立固定值验证。
|
||||
- 排队时间只消耗 `min((队长+5)×est×2, maxQueueWaitMs)`,不侵蚀 `callBudgetMs`;排队超时返回 `deadline_exceeded (phase=queue)` 且带触发边界;`callBudgetMs` 自取得 `N` permit 起算,deadline 到达后不开始新的 provider attempt。
|
||||
- `maxQueueWaitMs <= 0` 时父侧不发送请求:flat 直接 fallback,complex 直接失败。
|
||||
- `callBudgetMs` 与 worker 本进程公式值不一致时按 `invalid_request` 拒绝;attempt、callBudget、client timeout 全部由 `N / est` 运行时派生,代码不存在硬编码结果值。
|
||||
- `callBudgetMs` 与 worker 本进程公式值不一致时不拒绝:worker 以自身公式值执行,记 warn 并递增漂移指标;发布调优 N / est 的新旧进程共存窗口内,在途 flat 任务仍能正常执行或走既有 fallback,不得因瞬态漂移触发 `invalid_request`(该码禁止 fallback)。attempt、callBudget、client timeout 全部由 `N / est` 运行时派生,代码不存在硬编码结果值。
|
||||
- parent client timeout 精确取 `maxQueueWaitMs + callBudgetMs + 2s`,helper 保持 infallible;父绝对预算通过 `maxQueueWaitMs` 的派生公式预先约束,结果校验等待也必须 deadline-aware,不能只在校验完成后事后判超时。
|
||||
- 第一次失败后预算不足时不开始第二次;父侧从不重试整次内部 RPC。
|
||||
- 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。
|
||||
@@ -482,7 +485,7 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
- 父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和部署 readiness URL 必须指向同一个 `127.0.0.1:<port>` endpoint;旧非空配置不能因为“无需补默认值”而绕过一致性检查。
|
||||
- `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖 `callBudgetMs` 排空上界(约 `321s`)与停止收口余量;停止时排队请求立即类型化失败,不参与排空。
|
||||
- 发布目录切换前拒绝缺失、空、符号链接或权限错误的内部 Token 文件;父、子有效 Token 文件路径必须相同。
|
||||
- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 `127.0.0.1:8083/readyz` 成功,不能只依赖 systemd 自动重启。
|
||||
- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 worker `readyz` 成功(默认 `127.0.0.1:8083`),不能只依赖 systemd 自动重启。provision / deploy 的发布验活 URL 必须从已验证的 env `HOST/PORT` 派生,不得硬编码默认端口;若运维自定义 `GENARRATIVE_BGFILTER_WORKER_PORT`,必须同步覆盖巡检的 `GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL`(巡检是独立进程,不读取 worker env,默认值不会自动跟随)。
|
||||
- `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。
|
||||
|
||||
本地全进程调度门禁使用已构建的 `api-server` binary 和 loopback mock provider,不读取真实 OSS / BgFilter 密钥,也不访问真实外部服务。`load-smoke` 固定验证 `R = 32 / 40 / 48、N = 16`(`Q` 用小值场景单独验证保险丝行为);`fault-smoke` 用独立 worker / mock 生命周期验证保险丝触达快速拒绝、queue timeout 双边界、`503 → 200` 顺序重试、两次 `503` 后 provider exhausted,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功。默认读取 `server-rs/target/debug/api-server(.exe)`;在 WSL 或自定义 target 目录运行时,通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定 binary:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -768,6 +768,9 @@ validate_bgfilter_loopback_endpoint_alignment() {
|
||||
echo "[server-provision] 父进程 GENARRATIVE_BGFILTER_WORKER_BASE_URL 必须与 BgFilter worker 有效监听地址一致: expected=${expected_base_url}, actual=${base_url:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 验活 URL 从这里已验证的 host/port 派生:校验允许 8083 以外的合法自定义端口,
|
||||
# 后续 readiness 必须访问同一 endpoint,不能硬编码默认端口造成假阴性。
|
||||
BGFILTER_WORKER_READYZ_URL="${expected_base_url}/readyz"
|
||||
}
|
||||
|
||||
ensure_worker_runtime_env_defaults() {
|
||||
@@ -1259,19 +1262,22 @@ render_bgfilter_worker_service() {
|
||||
}
|
||||
|
||||
wait_for_bgfilter_worker_service() {
|
||||
# 真实路径的 URL 由 validate_bgfilter_loopback_endpoint_alignment 从已验证 env 派生;
|
||||
# dry-run 分支该校验提前返回,此处仅回显默认值。
|
||||
local readyz_url="${BGFILTER_WORKER_READYZ_URL:-http://127.0.0.1:8083/readyz}"
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "+ curl -fsS --max-time 2 http://127.0.0.1:8083/readyz"
|
||||
echo "+ curl -fsS --max-time 2 ${readyz_url}"
|
||||
return
|
||||
fi
|
||||
echo "[server-provision] 等待 BgFilter worker readiness。"
|
||||
echo "[server-provision] 等待 BgFilter worker readiness: ${readyz_url}"
|
||||
for _ in {1..30}; do
|
||||
if systemctl is-active --quiet genarrative-bgfilter-worker.service && curl -fsS --max-time 2 http://127.0.0.1:8083/readyz >/dev/null; then
|
||||
if systemctl is-active --quiet genarrative-bgfilter-worker.service && curl -fsS --max-time 2 "${readyz_url}" >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
systemctl --no-pager --full status genarrative-bgfilter-worker.service || true
|
||||
echo "[server-provision] BgFilter worker 未在超时时间内通过 readiness。" >&2
|
||||
echo "[server-provision] BgFilter worker 未在超时时间内通过 readiness: ${readyz_url}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ struct BgfilterMetrics {
|
||||
_circuit_state: ObservableGauge<i64>,
|
||||
waiting_requests: UpDownCounter<i64>,
|
||||
queue_timeout_total: Counter<u64>,
|
||||
call_budget_drift_total: Counter<u64>,
|
||||
in_flight: UpDownCounter<i64>,
|
||||
internal_request_seconds: Histogram<f64>,
|
||||
provider_http_seconds: Histogram<f64>,
|
||||
@@ -93,6 +94,13 @@ fn bgfilter_metrics() -> &'static BgfilterMetrics {
|
||||
.with_unit("{request}")
|
||||
.with_description("Queue waits ended by timeout, labeled by which bound fired")
|
||||
.build(),
|
||||
call_budget_drift_total: meter
|
||||
.u64_counter("bgfilter_internal_call_budget_drift_total")
|
||||
.with_unit("{request}")
|
||||
.with_description(
|
||||
"Requests whose callBudgetMs fingerprint mismatched the worker formula; transient during deploys, sustained growth means real N/est drift",
|
||||
)
|
||||
.build(),
|
||||
in_flight: meter
|
||||
.i64_up_down_counter("bgfilter_internal_in_flight")
|
||||
.with_unit("{request}")
|
||||
@@ -561,7 +569,8 @@ struct BgfilterInternalRequest {
|
||||
/// 动态队列估时取 min。排队不消耗 callBudget。
|
||||
max_queue_wait_ms: u64,
|
||||
/// 调用预算:自取得 provider permit 起算,覆盖签名、两次 attempt、校验与响应构造。
|
||||
/// 必须等于 worker 本进程按 N/est 公式派生的值,用于快速暴露父子配置漂移。
|
||||
/// 该字段是父侧按 N/est 公式算出的配置指纹,仅作核对:worker 始终以自身公式值
|
||||
/// 执行,不一致只告警并计漂移指标(容忍发布重启窗口的瞬态漂移)。
|
||||
call_budget_ms: u64,
|
||||
#[serde(default)]
|
||||
audit_context: Option<BgfilterInternalAuditContext>,
|
||||
@@ -572,7 +581,7 @@ async fn remove_background(
|
||||
Extension(admission): Extension<Arc<AdmissionGuard>>,
|
||||
payload: Result<Json<BgfilterInternalRequest>, JsonRejection>,
|
||||
) -> Response {
|
||||
let request = match payload {
|
||||
let mut request = match payload {
|
||||
Ok(Json(request)) => request,
|
||||
Err(error) => {
|
||||
record_internal_outcome_metrics(
|
||||
@@ -592,11 +601,7 @@ async fn remove_background(
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(error) = validate_internal_request(
|
||||
&request,
|
||||
&admission,
|
||||
runtime.app_state.config.bgfilter_call_budget_ms(),
|
||||
) {
|
||||
if let Err(error) = validate_internal_request(&request, &admission) {
|
||||
record_internal_outcome_metrics(
|
||||
request.background_mode.as_str(),
|
||||
"invalid_request",
|
||||
@@ -610,6 +615,26 @@ async fn remove_background(
|
||||
);
|
||||
}
|
||||
|
||||
// 请求携带的 callBudgetMs 只是父侧配置指纹:worker 始终以自身公式值执行。
|
||||
// 不一致只告警并计漂移指标——发布调优 N/est 时新旧进程共存的瞬态漂移必然存在,
|
||||
// 拒绝会让窗口内 flat 任务绕过 fallback(invalid_request 禁止降级);
|
||||
// 持久漂移由部署脚本的共享 env 对齐校验在启动前拦截。
|
||||
let derived_call_budget_ms = runtime.app_state.config.bgfilter_call_budget_ms();
|
||||
if request.call_budget_ms != derived_call_budget_ms {
|
||||
tracing::warn!(
|
||||
request_id = %admission.request_id,
|
||||
background_mode = request.background_mode.as_str(),
|
||||
request_call_budget_ms = request.call_budget_ms,
|
||||
derived_call_budget_ms,
|
||||
"bgfilter_call_budget_drift"
|
||||
);
|
||||
bgfilter_metrics().call_budget_drift_total.add(
|
||||
1,
|
||||
&[KeyValue::new("mode", request.background_mode.as_str())],
|
||||
);
|
||||
request.call_budget_ms = derived_call_budget_ms;
|
||||
}
|
||||
|
||||
let Some(task_guard) = runtime.task_tracker.register() else {
|
||||
record_internal_outcome_metrics(
|
||||
request.background_mode.as_str(),
|
||||
@@ -712,7 +737,6 @@ fn status_for_json_rejection(error: &JsonRejection) -> StatusCode {
|
||||
fn validate_internal_request(
|
||||
request: &BgfilterInternalRequest,
|
||||
admission: &AdmissionGuard,
|
||||
expected_call_budget_ms: u64,
|
||||
) -> Result<(), WorkerFailure> {
|
||||
if request.request_id != admission.request_id {
|
||||
return Err(WorkerFailure::new(
|
||||
@@ -731,16 +755,6 @@ fn validate_internal_request(
|
||||
false,
|
||||
));
|
||||
}
|
||||
if request.call_budget_ms != expected_call_budget_ms {
|
||||
return Err(WorkerFailure::new(
|
||||
"invalid_request",
|
||||
format!(
|
||||
"callBudgetMs={} 与 worker 按 N×est 公式派生的 {} 不一致,父子 N/est 配置漂移",
|
||||
request.call_budget_ms, expected_call_budget_ms
|
||||
),
|
||||
false,
|
||||
));
|
||||
}
|
||||
let normalized_object_key = crate::editor_project::normalize_editor_reference_object_key(
|
||||
request.source_object_key.as_str(),
|
||||
)
|
||||
@@ -2506,7 +2520,6 @@ mod tests {
|
||||
admitted_at: Instant::now(),
|
||||
request_id: "request-1".to_string(),
|
||||
};
|
||||
let expected_call_budget_ms = 321_000;
|
||||
let mut request = BgfilterInternalRequest {
|
||||
request_id: "request-1".to_string(),
|
||||
source_object_key: "generated-character-drafts/editor/source.png".to_string(),
|
||||
@@ -2515,21 +2528,22 @@ mod tests {
|
||||
seg_model: "birefnet".to_string(),
|
||||
cross_check: true,
|
||||
max_queue_wait_ms: 1_000,
|
||||
call_budget_ms: expected_call_budget_ms,
|
||||
call_budget_ms: 321_000,
|
||||
audit_context: None,
|
||||
};
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
assert!(validate_internal_request(&request, &admission).is_err());
|
||||
request.screen_color = Some("#CFEFFF".to_string());
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_ok());
|
||||
// callBudget 与 worker 公式派生值不一致必须按 invalid_request 拒绝(父子配置漂移)。
|
||||
request.call_budget_ms = expected_call_budget_ms + 1;
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
request.call_budget_ms = expected_call_budget_ms;
|
||||
assert!(validate_internal_request(&request, &admission).is_ok());
|
||||
// callBudget 只是父侧配置指纹:与 worker 公式值不一致不得拒绝(发布重启窗口
|
||||
// 的瞬态漂移必然存在),由 handler 告警并以 worker 自身公式值执行。
|
||||
request.call_budget_ms = 1;
|
||||
assert!(validate_internal_request(&request, &admission).is_ok());
|
||||
request.call_budget_ms = 321_000;
|
||||
// 排队预算必须为正且不超过荒谬值防线。
|
||||
request.max_queue_wait_ms = 0;
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
assert!(validate_internal_request(&request, &admission).is_err());
|
||||
request.max_queue_wait_ms = BGFILTER_MAX_QUEUE_WAIT_MS + 1;
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
assert!(validate_internal_request(&request, &admission).is_err());
|
||||
request.max_queue_wait_ms = 1_000;
|
||||
for malicious in [
|
||||
"/generated-character-drafts/editor/source.png",
|
||||
@@ -2543,21 +2557,21 @@ mod tests {
|
||||
] {
|
||||
request.source_object_key = malicious.to_string();
|
||||
assert!(
|
||||
validate_internal_request(&request, &admission, expected_call_budget_ms).is_err(),
|
||||
validate_internal_request(&request, &admission).is_err(),
|
||||
"malicious object key should fail: {malicious}"
|
||||
);
|
||||
}
|
||||
request.source_object_key = "generated-character-drafts/editor/source.png".to_string();
|
||||
request.background_mode = BgfilterBackgroundMode::Complex;
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
assert!(validate_internal_request(&request, &admission).is_err());
|
||||
request.screen_color = None;
|
||||
request.cross_check = false;
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_ok());
|
||||
assert!(validate_internal_request(&request, &admission).is_ok());
|
||||
request.seg_model = "anime-seg".to_string();
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
assert!(validate_internal_request(&request, &admission).is_err());
|
||||
request.seg_model = "birefnet".to_string();
|
||||
request.cross_check = true;
|
||||
assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err());
|
||||
assert!(validate_internal_request(&request, &admission).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user