Skip to content

TSI-3766: fix(scheduler): starvation sweep WORKER_CRASH 判定时刻跨 run 抖动,(120s,150s] 窗口边界敏感 - #128

Open
multica-tsic404[bot] wants to merge 1 commit into
mainfrom
multica/TSI-3766
Open

multica-tsic404[bot] wants to merge 1 commit into
mainfrom
multica/TSI-3766

Conversation

@multica-tsic404

@multica-tsic404 multica-tsic404 Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Closes TSI-3766

Issue: https://multica.ai/issues/01a133b5-b046-7e37-999e-559094cb13fd

改动

WORKER_CRASH 的 Server 侧分支由 starvation sweep 判定,而 sweep 此前只挂在巡检 tick 上:判定点 = 「pending 时钟到期」之后的第一个 tick,落在 (120s, 150s] 里的哪一点完全由 tick 相位决定。生产默认配置下 tick 周期 30s 整除 no-worker 超时 120s,到期时刻正好压在 tick 边界上,亚毫秒级相位差就决定判定落在 +120s 还是 +150s —— 这正是 TSI-3765 首跑(CLI 240s 包裹内只看到 cancelled)与重跑(232s 才拿到 [WORKER_CRASH])两种结果的来源,也是「CLI 到点先放弃」错序的来源。

  • pkg/server/db/db.go:新增 EarliestPendingCreatedAt() —— 最早的「pending 且未认领」作业的 created_at,即 starvation sweep 的 pending 时钟锚点(迁移、超时重排、重启恢复都会刷新该字段)。过滤条件与 GetStarvedPendingJobs 对齐:已被认领(worker_id IS NOT NULL)的作业永不被 sweep,不得把锚点提前。
  • pkg/server/scheduler/scheduler.go:新增一次性到期唤醒 starvationWake。循环启动时与每次巡检 tick 后按锚点重新瞄准,唤醒点 = 锚点 + --no-worker-job-timeout + starvationExpiryMargin(1s,用于越过 sweep 的严格比较——pending 时长必须严格大于阈值);唤醒后照常执行同一个 checkNoWorkerStarvation()(幂等,保留「存在存活可调度 worker 则短路不判」的语义),再重新瞄准下一个锚点。判定点因此收敛到 (120s, 121s],与 tick 相位无关;巡检 tick 仍是兜底路径(锚点已过期、或唤醒来不及瞄准时由 tick 判定,例如作业一直卡在有存活 worker 的守卫后面)。

契约语义未变:03-contracts.md §3.1 的分类口径、§5 的巡检 tick 配置项、05-modules/server.md 的 sweep 伪代码(cutoff 比较、短路条件、终态写入 + 限流释放 + 广播)全部保持原样,改的只是判定时刻不再被 tick 相位量化;NoWorkerDeadline(created_at + timeout + interval,客户端放弃点上界)不动,保证 CLI 永远不会先于服务端判定放弃。

验证

  • 新增 TestStarvationWakeJudgesJustPastDeadline:tick 设为 1h(唯一可能的触发就是到期唤醒),锚点使到期点在 1s 后;断言判定严格晚于到期点、且距到期点不超过 margin+4s,分类为 NO_WORKER_AVAILABLE。修复前该测试失败(无唤醒 → 30s 内无任何判定)。
  • 新增 TestStarvationWakeFallsBackToTickForAnExpiredDeadline:锚点已过期时没有未来唤醒点,tick(500ms)必须照常判定 —— 唤醒只是加速器,不是唯一路径。
  • 新增 TestEarliestPendingCreatedAt:空表返回「无锚点」;更早但已被认领(queued)的作业不得把锚点提前。
  • 真实时序端到端(默认 90s 心跳超时 + 30s 巡检,提交后 kill -9 worker),判定点相对迁移锚点:
    • 默认 tick(30s)两次运行:+120.0011s / +120.0005s(tick 边界命中,毫秒级)
    • --timeout-check-interval 45s(tick 不再整除 120s 超时,使相位效应可见):修复后 +121.0013s,修复前 +150.0011s(落在下一个 tick 上,晚了 30s)
    • 四种运行均为 status=failed、failure_type=WORKER_CRASH、stderr 精确 Job failed: [WORKER_CRASH] worker crashed (heartbeat timeout) and no surviving worker to take over the job、CLI rc=1,均未出现 cancelled 错序。
  • gofmt、go vet 干净;go test ./...:本次改动的包(pkg/server/db、pkg/server/scheduler、pkg/server/handlers、pkg/server/workerhealth)全绿。pkg/worker 的 16 个用例在 clean HEAD 26e0159 上以完全相同的集合失败(本沙箱 ffmpeg fixture 不兼容;pkg/worker/cmd/worker 不依赖本次改动的任何包),与本 PR 无关。

Summary by Sourcery

Stabilize starvation-sweep timing so worker-crash verdicts are produced just after the no-worker deadline regardless of inspection tick phase.

Bug Fixes:

  • Make starvation-sweep worker-crash verdicts occur immediately after the no-worker deadline instead of varying with the inspection tick phase, preventing the CLI from cancelling jobs before the server reports the failure.

Enhancements:

  • Track the earliest eligible pending job as the starvation deadline anchor while preserving existing sweep classification and live-worker guard semantics.
  • Retain periodic inspection as a fallback for already-expired deadlines and add coverage for deadline wakeups, fallback behavior, and anchor selection.

Tests:

  • Add scheduler and database tests covering precise starvation timing, expired-deadline fallback, and exclusion of assigned jobs from the starvation anchor.

@sourcery-ai

sourcery-ai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR introduces a one-shot scheduler wake based on the oldest unassigned pending job, causing starvation judgments to occur just after the no-worker deadline instead of being quantized by inspection-tick phase, while retaining tick fallback and existing sweep semantics.

Sequence diagram for starvation judgment wake-up

sequenceDiagram
    participant Scheduler
    participant Database
    participant WakeTimer
    participant StarvationSweep

    Scheduler->>Database: EarliestPendingCreatedAt()
    Database-->>Scheduler: anchor
    Scheduler->>WakeTimer: Reset(anchor + NoWorkerJobTimeout + starvationExpiryMargin)
    WakeTimer-->>Scheduler: expiry wake
    Scheduler->>StarvationSweep: checkNoWorkerStarvation()
    StarvationSweep-->>Scheduler: classify and persist verdict
    Scheduler->>Database: EarliestPendingCreatedAt()
    Scheduler->>WakeTimer: Reset(next anchor deadline)
Loading

Flow diagram for starvation wake and tick fallback

flowchart TD
    A[Scheduler loop starts] --> B[Read oldest pending unassigned created_at]
    B --> C{Future deadline exists?}
    C -- Yes --> D[Arm one-shot wake]
    C -- No --> E[Wait for timeout tick]
    D --> F{Wake or timeout tick?}
    E --> F
    F --> G["checkNoWorkerStarvation()"]
    G --> H{Live schedulable worker exists?}
    H -- Yes --> I[No starvation verdict]
    H -- No --> J[Write terminal failure and release capacity]
    I --> B
    J --> B
Loading

File-Level Changes

Change Details Files
Added a database query for the starvation sweep’s true pending-job time anchor.
  • Selects the oldest pending job with no assigned worker.
  • Returns an explicit no-anchor result when no eligible job exists.
  • Keeps eligibility and ordering aligned with the starvation sweep.
pkg/server/db/db.go
pkg/server/db/db_test.go
Added deadline-triggered starvation sweeping to remove timeout-tick phase dependence.
  • Arms a one-shot timer for the oldest pending anchor plus the no-worker timeout and a one-second strict-comparison margin.
  • Rearms the timer at scheduler startup, after timeout ticks, and after wake-triggered sweeps.
  • Retains timeout ticks as a fallback for already-expired anchors and preserves the existing idempotence and live-worker short-circuit behavior.
  • Logs anchor lookup failures and disables the timer when no eligible pending job exists.
pkg/server/scheduler/scheduler.go
Added scheduler and database tests covering precise wake timing and fallback behavior.
  • Verifies verdicts occur strictly after the deadline and within the configured margin when ticks cannot trigger the sweep.
  • Verifies an expired deadline is still handled by the regular timeout tick.
  • Verifies claimed jobs do not advance the pending anchor.
pkg/server/scheduler/scheduler_starvation_wake_test.go
pkg/server/db/db_test.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pkg/server/scheduler/scheduler.go" line_range="192" />
<code_context>
 		case <-timeoutTicker.C:
 			s.checkTimeouts()
 			s.checkNoWorkerStarvation()
+			s.armStarvationWake(starvationWake)
+		case <-starvationWake.C:
+			// A pending job's no-worker clock just expired: judge it now rather
</code_context>
<issue_to_address>
**Starvation verdict arrives late**

When a job becomes pending after the initial arm found no pending jobs, and its deadline passes before the next timeout tick, `run` schedules or reschedules the job without re-arming `starvationWake`, so `checkNoWorkerStarvation` does not judge it until the next timeout tick. The verdict is delayed past the no-worker deadline.

Re-arm `starvationWake` when scheduling or rescheduling makes a job pending, not only at startup, timeout ticks, or wakes.

Also at `pkg/server/scheduler/scheduler.go:177`, `pkg/server/scheduler/scheduler.go:213`.
</issue_to_address>

### Comment 2
<location path="pkg/server/scheduler/scheduler.go" line_range="31" />
<code_context>
+// three orders of magnitude below the default 30s check interval, so the
+// verdict stays far inside the (120s, 150s] acceptance window (AC-17) and well
+// before the CLI's own no-worker give-up at deadline+check interval.
+const starvationExpiryMargin = time.Second
+
 type Config struct {
</code_context>
<issue_to_address>
**CLI deadline precedes server verdict**

When `TimeoutCheckInterval` is less than one second and a timeout tick checks the job at the strict cutoff, the one-second margin places the expiry wake after the deadline advertised to the CLI when `TimeoutCheckInterval` is shorter than one second. If a tick lands exactly on the strict cutoff, it skips the job and the CLI can give up before the next server verdict.

Keep the wake margin below the configured client slack (or reject configurations where that cannot be guaranteed) while still ensuring the sweep runs strictly after its cutoff.
</issue_to_address>

### Comment 3
<location path="pkg/server/db/db.go" line_range="1920-1925" />
<code_context>
+// not pull the anchor earlier.
+func (d *Database) EarliestPendingCreatedAt() (time.Time, bool, error) {
+	var anchor time.Time
+	err := d.db.QueryRow(`
+		SELECT created_at FROM jobs
+		WHERE status = ? AND worker_id IS NULL
+		ORDER BY created_at ASC
+		LIMIT 1
+	`, protocol.JobStatusPending).Scan(&anchor)
+	if errors.Is(err, sql.ErrNoRows) {
+		return time.Time{}, false, nil
</code_context>
<issue_to_address>
**Anchor lookup scans pending backlog**

When the database has a large pending backlog and the scheduler repeatedly re-arms the wake, `EarliestPendingCreatedAt` filters by `status` and `worker_id` while ordering by `created_at`, but the jobs table has no index covering that combination. With a large pending backlog, each startup, timeout tick, or wake can scan and sort many pending rows, adding recurring database work to the scheduler.

Add an index that supports the pending, unassigned `created_at` lookup, and verify the query plan uses it.
</issue_to_address>

### Comment 4
<location path="pkg/server/scheduler/scheduler_starvation_wake_test.go" line_range="86-87" />
<code_context>
+
+	// Anchor the pending clock so the deadline is a second away, and pass the
+	// deadline before the verdict can be observed.
+	deadline := time.Now().Add(time.Second)
+	backdatePendingAnchor(t, database, job.ID, deadline.Add(-noWorkerTimeout))
+
+	notifyCh, cancel := database.JobNotifier().Subscribe(job.ID)
</code_context>
<issue_to_address>
**Wake test can time out**

When test setup or scheduling takes more than one second before the scheduler arms the wake, `armStarvationWake` sees the test deadline has passed and leaves judgment to the one-hour timeout tick, so the test's 30-second wait expires despite the fallback being intentional.

Set the test deadline relative to a synchronized scheduler start, or otherwise ensure the scheduler arms the wake before the deadline passes.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 4 findings to address first, and the expiry wake can mark an unassigned pending job failed earlier than the next timeout tick; if that verdict is wrong, the failed status remains after a revert, but the job can be retried or resubmitted.

Blocking findings: pkg/server/scheduler/scheduler.go:192, pkg/server/scheduler/scheduler.go:31, pkg/server/db/db.go:1925, pkg/server/scheduler/scheduler_starvation_wake_test.go:87


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

case <-timeoutTicker.C:
s.checkTimeouts()
s.checkNoWorkerStarvation()
s.armStarvationWake(starvationWake)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Starvation verdict arrives late

When a job becomes pending after the initial arm found no pending jobs, and its deadline passes before the next timeout tick, run schedules or reschedules the job without re-arming starvationWake, so checkNoWorkerStarvation does not judge it until the next timeout tick. The verdict is delayed past the no-worker deadline.

Re-arm starvationWake when scheduling or rescheduling makes a job pending, not only at startup, timeout ticks, or wakes.

Also at pkg/server/scheduler/scheduler.go:177, pkg/server/scheduler/scheduler.go:213.

Prompt for AI agents
In `pkg/server/scheduler/scheduler.go` at line 192:

**Starvation verdict arrives late**

When a job becomes pending after the initial arm found no pending jobs, and its deadline passes before the next timeout tick, `run` schedules or reschedules the job without re-arming `starvationWake`, so `checkNoWorkerStarvation` does not judge it until the next timeout tick. The verdict is delayed past the no-worker deadline.

Re-arm `starvationWake` when scheduling or rescheduling makes a job pending, not only at startup, timeout ticks, or wakes.

Also at `pkg/server/scheduler/scheduler.go:177`, `pkg/server/scheduler/scheduler.go:213`.

// three orders of magnitude below the default 30s check interval, so the
// verdict stays far inside the (120s, 150s] acceptance window (AC-17) and well
// before the CLI's own no-worker give-up at deadline+check interval.
const starvationExpiryMargin = time.Second

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · CLI deadline precedes server verdict

When TimeoutCheckInterval is less than one second and a timeout tick checks the job at the strict cutoff, the one-second margin places the expiry wake after the deadline advertised to the CLI when TimeoutCheckInterval is shorter than one second. If a tick lands exactly on the strict cutoff, it skips the job and the CLI can give up before the next server verdict.

Keep the wake margin below the configured client slack (or reject configurations where that cannot be guaranteed) while still ensuring the sweep runs strictly after its cutoff.

Prompt for AI agents
In `pkg/server/scheduler/scheduler.go` at line 31:

**CLI deadline precedes server verdict**

When `TimeoutCheckInterval` is less than one second and a timeout tick checks the job at the strict cutoff, the one-second margin places the expiry wake after the deadline advertised to the CLI when `TimeoutCheckInterval` is shorter than one second. If a tick lands exactly on the strict cutoff, it skips the job and the CLI can give up before the next server verdict.

Keep the wake margin below the configured client slack (or reject configurations where that cannot be guaranteed) while still ensuring the sweep runs strictly after its cutoff.

Comment thread pkg/server/db/db.go
Comment on lines +1920 to +1925
err := d.db.QueryRow(`
SELECT created_at FROM jobs
WHERE status = ? AND worker_id IS NULL
ORDER BY created_at ASC
LIMIT 1
`, protocol.JobStatusPending).Scan(&anchor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Anchor lookup scans pending backlog

When the database has a large pending backlog and the scheduler repeatedly re-arms the wake, EarliestPendingCreatedAt filters by status and worker_id while ordering by created_at, but the jobs table has no index covering that combination. With a large pending backlog, each startup, timeout tick, or wake can scan and sort many pending rows, adding recurring database work to the scheduler.

Add an index that supports the pending, unassigned created_at lookup, and verify the query plan uses it.

Prompt for AI agents
In `pkg/server/db/db.go` at lines 1920-1925:

**Anchor lookup scans pending backlog**

When the database has a large pending backlog and the scheduler repeatedly re-arms the wake, `EarliestPendingCreatedAt` filters by `status` and `worker_id` while ordering by `created_at`, but the jobs table has no index covering that combination. With a large pending backlog, each startup, timeout tick, or wake can scan and sort many pending rows, adding recurring database work to the scheduler.

Add an index that supports the pending, unassigned `created_at` lookup, and verify the query plan uses it.

Comment on lines +86 to +87
deadline := time.Now().Add(time.Second)
backdatePendingAnchor(t, database, job.ID, deadline.Add(-noWorkerTimeout))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Wake test can time out

When test setup or scheduling takes more than one second before the scheduler arms the wake, armStarvationWake sees the test deadline has passed and leaves judgment to the one-hour timeout tick, so the test's 30-second wait expires despite the fallback being intentional.

Set the test deadline relative to a synchronized scheduler start, or otherwise ensure the scheduler arms the wake before the deadline passes.

Prompt for AI agents
In `pkg/server/scheduler/scheduler_starvation_wake_test.go` at lines 86-87:

**Wake test can time out**

When test setup or scheduling takes more than one second before the scheduler arms the wake, `armStarvationWake` sees the test deadline has passed and leaves judgment to the one-hour timeout tick, so the test's 30-second wait expires despite the fallback being intentional.

Set the test deadline relative to a synchronized scheduler start, or otherwise ensure the scheduler arms the wake before the deadline passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant