fix(jobs): make deploy coalescing deterministic via inflight counter
CI / changes (push) Successful in 12s
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m27s

TestParallelModuleRefresh_CoalescesDeployApply падал на CI под -race
(want exactly one deploy_apply job, got 2). Локально тест проходил
стабильно (100/500 итераций с -cpu), но узкая гонка проявлялась при
замедлении под race-детектором.

Корень: коалесцирование решало «делать ли deploy» через
CountOtherActiveRefresh, который опрашивал статусы job-ов (queued/
running). Статусы меняются асинхронно относительно tenantRefreshMu,
поэтому в редких таймингах оба параллельных refresh могли решить,
что другой уже не активен, и каждый породил свой deploy_apply.

Решение — детерминированный inflight-счётчик refresh-kind job-ов в
Registry (inflightRefresh map[string]int), управляемый под r.mu:
- инкремент в Enqueue при создании нового refresh-kind job-а;
- декремент + проверка «последний ли я» в finishModuleRefreshSuccess
  через новый метод finalizeRefreshCoalesce (под tenantRefreshMu).

Последний refresh (счётчик <= 1) делает render + deploy_apply; все
остальные defer-ят. Решение больше не зависит от опроса статусов и
таймингов ingest.

Чтобы счётчик не утёк на error/cancel путях (где refresh не доходит
до finishModuleRefreshSuccess), обработка module_refresh и
tenant_refresh вынесена в runModuleRefresh / runTenantRefresh с
defer-обёрткой, которая гарантированно освобождает слот, если
finishModuleRefreshSuccess не отработал.

CountOtherActiveRefresh / CountOtherActiveModuleRefresh оставлены
как публичные методы (могут использоваться в мониторинге); из
продакшн-логики коалесцирования убраны.

Проверки: go build, go vet, go test ./internal/... -count=1 — exit 0.
Стресс-тест коалесцирования: 200 итераций с -cpu=4 — стабильно.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-03 01:31:32 +07:00
co-authored by Cursor
parent 0ea5b3b738
commit 8c97445f7e
2 changed files with 100 additions and 27 deletions
+60 -23
View File
@@ -119,26 +119,7 @@ func (w *Worker) Process(j *Job) {
switch j.Kind {
case KindModuleRefresh:
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
w.finishModuleRefreshSuccess(j, mid)
w.runModuleRefresh(j)
case KindTenantRefresh:
w.runTenantRefresh(j)
case KindPeerReconcile:
@@ -295,7 +276,55 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
// creates one aggregate revision and enqueues a single deploy_apply.
// runModuleRefresh handles a single module_refresh job and guarantees the per-tenant inflight
// slot is released exactly once — even on failure/cancellation before finishModuleRefreshSuccess.
func (w *Worker) runModuleRefresh(j *Job) {
coalesceFinalized := false
defer func() {
if !coalesceFinalized && w != nil && w.Registry != nil {
// Refresh failed/was cancelled before reaching finishModuleRefreshSuccess.
// Decrement the counter under the tenant mutex so the "last one" logic stays sound.
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
w.Registry.finalizeRefreshCoalesce(j.TenantID)
mu.Unlock()
}
}()
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
w.finishModuleRefreshSuccess(j, mid)
coalesceFinalized = true
}
func (w *Worker) runTenantRefresh(j *Job) {
coalesceFinalized := false
defer func() {
if !coalesceFinalized && w != nil && w.Registry != nil {
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
w.Registry.finalizeRefreshCoalesce(j.TenantID)
mu.Unlock()
}
}()
moduleIDs := moduleIDsFromJobMeta(j.Meta)
if len(moduleIDs) == 0 {
j.Fail("missing module_ids in job meta")
@@ -318,6 +347,7 @@ func (w *Worker) runTenantRefresh(j *Job) {
}
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
w.finishModuleRefreshSuccess(j, trigger)
coalesceFinalized = true
}
func moduleIDsFromJobMeta(meta map[string]any) []string {
@@ -346,6 +376,9 @@ func moduleIDsFromJobMeta(meta map[string]any) []string {
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
if w == nil || w.Store == nil {
if w != nil && w.Registry != nil {
w.Registry.finalizeRefreshCoalesce(j.TenantID)
}
j.Succeed()
return
}
@@ -354,11 +387,15 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
defer mu.Unlock()
ctx, cancel := j.workContext()
defer cancel()
deferDeploy := false
// Determine whether this is the last outstanding refresh for the tenant. The counter is
// incremented in Enqueue (under r.mu) and decremented here, so the "last one" decision is
// deterministic regardless of ingest timing — unlike the previous status-polling approach
// (CountOtherActiveRefresh) which could race under -race.
isLastRefresh := true
if w.Registry != nil {
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
isLastRefresh = w.Registry.finalizeRefreshCoalesce(j.TenantID)
}
if deferDeploy {
if !isLastRefresh {
j.mergeMeta(map[string]any{
"deploy_apply_deferred": true,
"deploy_apply_defer_reason": "parallel_module_refresh",