Skip to content

test(logservice): avoid HAKeeper double-driver race in task scheduler test - #28668

Merged
XuPeng-SH merged 3 commits into
mainfrom
codex/issue-28550-id-allocator-race-20260911
Sep 11, 2026
Merged

test(logservice): avoid HAKeeper double-driver race in task scheduler test#28668
XuPeng-SH merged 3 commits into
mainfrom
codex/issue-28550-id-allocator-race-20260911

Conversation

@XuPeng-SH

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • BUG
  • Improvement
  • Documentation
  • Feature
  • Code Refactoring
  • Test and CI

Which issue(s) this PR fixes:

Related to #28550

What this PR does / why we need it:

Root cause

TestTickerForTaskSchedule manually drives HAKeeper bootstrap through proceedHAKeeperToRunning, while its fixture also starts the production HAKeeper worker. The two drivers can concurrently advance the same bootstrap state and idAllocator, which caused the race reported by the race-enabled Ubuntu UT job.

The task-scheduling ticker under test is a separate owner and must remain enabled.

Changes

  • Run TestTickerForTaskSchedule with the HAKeeper worker disabled.
  • Keep the explicit task-scheduling ticker enabled, so the test still verifies real task assignment after HAKeeper reaches Running.
  • Join the explicit task-scheduling ticker during cleanup after cancellation, before the store and task service are closed.

This keeps one owner for manual bootstrap and one owner for the task-scheduling behavior being tested. It does not add synchronization or overhead to the production allocator, and it does not mask concurrent HAKeeper state-machine ownership with a lock.

Verification

  • .agents/skills/mo-dev/scripts/mo-cgo-test -count=1 -timeout=240s -run '^TestTickerForTaskSchedule$' ./pkg/logservice — PASS on macOS arm64.
  • .agents/skills/mo-dev/scripts/mo-cgo-test -race -count=14 -timeout=300s -run '^TestTickerForTaskSchedule$' ./pkg/logservice — PASS, 14 repetitions in one process.
  • .agents/skills/mo-dev/scripts/mo-cgo-test -race -count=1 -timeout=240s -run '^TestTaskSchedulerCanScheduleTasksToCNs$' ./pkg/logservice — PASS.
  • GOWORK=off go vet -mod=readonly ./pkg/logservice with the repository CGo include/library paths — PASS.
  • git diff --check — PASS.

Scope and known follow-up

This is a focused follow-up for the TestTickerForTaskSchedule occurrence in #28550. TestTaskSchedulerCanReScheduleExpiredTasks is intentionally not changed here because it relies on the background HAKeeper tick to advance expiration; disabling that worker without redesigning the test makes the test fail. The broader issue remains open for that separate single-driver redesign.

The CodeQL failure on PR #28459 is unrelated: it is the existing SHA-256/password alert at pkg/sql/compile/scheduler.go:553, outside this PR and this change.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed exact head 6e9203d against main cd8e8d9. No concrete merge-blocking issue found. COMMENT only for this self-authored PR.

The fixture flag actually prevents startHAKeeperReplica from starting the production HAKeeper ticker, whose startup would also create a second task-scheduling ticker. The manual bootstrap remains the sole bootstrap/ID-allocation driver, while the explicitly launched production tickerForTaskSchedule remains active with the real leader-state getter and taskSchedule implementation. The assignment assertion is unchanged: the newly created task must become assigned to the newly heartbeating CN. Thus disabling the duplicate fixture owner does not bypass the behavior this test is intended to observe.

Cleanup now cancels and joins the explicit ticker before the callback returns, so the outer fixture cannot close the store or task service underneath it. The done channel is closed by the goroutine's defer, with one sender/one waiter and no detached cleanup task. Normal return, FailNow on the test goroutine and panic unwinding all execute the cancellation/join defer. There are no production synchronization changes, skipped assertions, new ports/data directories or shared cross-test state.

The join intentionally waits for an in-progress checker/scheduler callback to return; cancellation does not interrupt that callback by itself. This is an honest lifecycle wait, not a new claim of an independent short shutdown deadline. Existing helper/query timing limits and package timeout still apply. The original test's one-second SQL context versus ten-second polling timer is unchanged and could be cleaned up separately if it proves flaky.

Scope is correctly narrow: this fixes the manually bootstrapped ticker test, not the separate expiration/rescheduling test that needs HAKeeper ticks. It is not a claim that all #28550 occurrences or real production leader transitions are solved.

Evidence: complete one-file diff, current PR discussion/review state, exact-head fixture flag/startup, manual bootstrap, production tickers, scheduler dispatch and outer teardown ordering inspected; head/base rechecked before submission. Author reports normal execution, 14 race repetitions and a related scheduling control on macOS arm64. I did not independently rerun native tests or Linux CI and did not wait for CI.

@XuPeng-SH
XuPeng-SH merged commit 8ab8b06 into main Sep 11, 2026
25 of 28 checks passed
@XuPeng-SH
XuPeng-SH deleted the codex/issue-28550-id-allocator-race-20260911 branch September 11, 2026 05:49
XuPeng-SH added a commit that referenced this pull request Sep 11, 2026
## What type of PR is this?

- [ ] API-change
- [x] BUG
- [ ] Improvement
- [ ] Documentation
- [ ] Feature
- [x] Test and CI
- [ ] Code Refactoring

## Which issue(s) this PR fixes:

Fixes #28550. Completes the expired-task test follow-up documented in
#28668.

## What this PR does / why we need it:

HAKeeper state transitions need one driver per store. Two paths violated
that ownership:

1. `TestTaskSchedulerCanReScheduleExpiredTasks` manually bootstrapped
and scheduled tasks while its fixture also ran the production HAKeeper
checker and task ticker. This caused the allocator race reported by [the
#28646 UT
job](https://github.com/matrixorigin/matrixone/actions/runs/34554939075/job/103145324437).
2. In production, stopping a local HAKeeper replica leaves the
store-owned ticker alive, but starting that replica again admitted
another ticker. A real NodeHost stop/resume probe against unchanged main
production code observed **two worker entries instead of one**, through
both voting and non-voting startup paths. Extra checkers share the
allocator/bootstrap state and also start extra task schedulers.

Both replica startup paths now share one store-lifetime startup guard.
Concurrent callers observe the same admission result; shutdown rejection
is terminal for that store. Replica stop/resume reuses the original
ticker, while a new store gets a fresh guard. This adds synchronization
only at worker startup, with no new locks, scans, or allocations in
tick, task scheduling, ID allocation, or query execution.

The manual task-service fixture now always disables background workers.
Its shared bootstrap helper enforces that precondition, eliminating
duplicated bootstrap code. The expired-task test advances the **real
replicated HAKeeper clock** explicitly instead of sleeping for
expiration. It retains the existing complete-and-truncate behavior for
expired tasks and adds live-runner and exact-boundary controls. The
explicit task-ticker test remains asynchronous and joins its worker
before teardown. FD checks now inspect the initialized fixture
filesystem.

### Test plan / QA reference

| Contract | Verification |
|---|---|
| Original flaky case | `TestTaskSchedulerCanReScheduleExpiredTasks`: no
eligible CN retains a Created task; exact expiry boundary retains the
running task; the next tick removes only the expired runner's task;
repeated scheduling preserves the live task; a returning CN accepts new
work without resurrecting the old task. |
| Other manual-bootstrap consumers |
`TestTaskSchedulerCanScheduleTasksToCNs`, `TestTickerForTaskSchedule`,
`TestAllocateIDByKeyWithRequestID`. All use the checked shared helper;
the ticker case still proves asynchronous assignment. |
| Production worker lifetime |
`TestHAKeeperTickerSurvivesReplicaRestart`: concurrent startup
admission; real voting and non-voting stop/resume; replicated tick
progress before and after voting-replica restart; exactly one worker
entry after joining all workers. |
| Shutdown / disabled-worker admission |
`TestHAKeeperTickerRejectsStartupAfterShutdown`: concurrent callers
receive `stopper.ErrUnavailable`; disabled-worker fixtures create no
worker. |
| Counterexample strength | Temporary Go overlays changing CN expiry
from `<` to `<=`, disabling expiry entirely, or re-enabling the manual
fixture's background workers each fail the corresponding assertion.
These deliberately incorrect variants are not committed. |

Validation on Linux/amd64, Go 1.26.4, using source-matched native
artifacts and `.agents/skills/mo-dev/scripts/mo-cgo-test`:

- The four manual-bootstrap consumers above each passed `-race
-count=100` in a single process. Their worker-disabled execution paths
are unchanged by the subsequent production startup guard.
- The restart test passed `-race -count=68`; shutdown admission passed
`-race -count=100`. Counts follow a 30-second per-test stress budget
(restart measured 0.44 seconds).
- Final `pkg/logservice` normal suite passed (110.622 seconds); the full
race suite passed (112.295 seconds), both with `-count=1 -timeout=600s`.
- Scoped `golangci-lint --new-from-rev=<base> ./pkg/logservice`, package
`molint`, and `git diff --check` passed.
- Expiration-test body time under race decreased from roughly 1.3
seconds to 0.2 seconds locally. The new two-mode restart test takes
roughly 0.44 seconds. No production throughput gain is claimed.

The SQL surface and wire/disk formats do not change. The affected
restart boundary is exercised with real in-memory NodeHosts; no full SQL
cluster, retained-data upgrade, or 55-machine test is claimed.

Rebased onto main `4a7bfac4f9` before delivery. The intervening commit
changes only `pkg/embed`, outside the verified `pkg/logservice` test
dependency graph; native inputs and all validated code/test paths are
unchanged, so the completed evidence is reused.

### Ownership / unhappy-path review

| Boundary | Closure |
|---|---|
| Startup | One `sync.Once` admission per store; both replica roles
share it; no RPC is held under the guard. |
| Replica stop/resume | The original ticker remains the owner and
observes the current replica identity; no additional checker/task ticker
accumulates. |
| Store shutdown | Existing stopper cancellation joins its workers
before NodeHost closure; manual ticker cleanup also joins before fixture
teardown. |
| Admission failure / fresh store | Shutdown rejection is published to
every caller; a fresh store has independent startup state. |
| Test failure | Defers release contexts, stores and task services;
leak/FD checks run after the resources close. |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Something isn't working kind/test-ci size/S Denotes a PR that changes [10,99] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants