Skip to content

perf(plan): avoid lossy string aggregate range shuffle - #28646

Merged
XuPeng-SH merged 5 commits into
matrixorigin:mainfrom
XuPeng-SH:codex/q34-string-range-safety
Sep 11, 2026
Merged

perf(plan): avoid lossy string aggregate range shuffle#28646
XuPeng-SH merged 5 commits into
matrixorigin:mainfrom
XuPeng-SH:codex/q34-string-range-safety

Conversation

@XuPeng-SH

Copy link
Copy Markdown
Contributor

Problem

ClickBench Q34 on the 55 machine could select shuffle: range(hits.url) for a high-cardinality string GROUP BY. ShuffleRange.UpdateString and the executor encode only the first eight bytes, while grouping equality uses the complete URL. The full URL had 1,853,441 distinct values but only 51 distinct first-eight-byte prefixes; one prefix owned 1.69M rows. The resulting range plan took about 15.1s on the same NVMe data, with roughly 358MiB spill and 215s cumulative scan wait. The no-group-range plan was about 1.75s.

Change

  • Fail closed for CHAR/VARCHAR/TEXT aggregate keys when selecting a range strategy; the existing reset hash/no-shuffle cost decision remains in control.
  • Reject lossy string range inheritance from reusable aggregate children, while preserving reusable full-key hash strategies.
  • Keep numeric and join range behavior unchanged, including hybrid/remap paths.
  • Document the range-encoding contract and add a composition regression proving fresh string range metadata remains available to non-aggregate consumers.

This is a conservative, reusable guard for any string aggregate with long common prefixes. A future full-key quantile sketch can explicitly re-enable range ownership.

Validation

  • ./.agents/skills/mo-dev/scripts/mo-cgo-test -count=1 -timeout=600s ./pkg/sql/plan ./pkg/vm/engine/disttae
  • go vet -mod=readonly ./pkg/sql/plan ./pkg/vm/engine/disttae
  • go build -mod=readonly ./pkg/sql/plan ./pkg/vm/engine/disttae
  • git diff --check and gofmt -d
  • GPT-6 medium final review: approved after correcting an initial draft that would have removed fresh string ranges needed by non-aggregate consumers.

@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 current head 2a484f1 against main 44b7f90. No concrete merge-blocking issue found. COMMENT only because this is a self-authored PR.

The guard is a conservative planner-policy change: CHAR/VARCHAR/TEXT aggregate distribution keys retain the reset hash strategy instead of deriving range bounds from an eight-byte prefix. Range inheritance is rejected without retaining stale Reuse/range metadata; full-key hash reuse remains eligible. The existing aggregate cardinality/cost decision may still choose no shuffle. I traced selected GroupBy-column identity, direct-column versus expression eligibility, multi-key/grouping-set selection, DISTINCT topology exclusions, strategy reset/reuse, and the compiler's group-shuffle argument construction. Numeric and join range selection are not disabled, and fresh string range statistics remain available to non-aggregate consumers.

Importantly, prefix loss here demonstrates possible severe partition skew, not by itself a wrong-result counterexample: prefix collisions need not split equal complete keys. This change does not introduce a new string comparator, aggregate state format, persistent metadata, or wire representation. NULL/empty/long/multibyte keys continue through the existing hash/no-shuffle execution paths; aggregate expressions and their explicit ordering semantics are unchanged.

The versioned design update explains the restriction and the future full-key-sketch prerequisite. The focused planner tests exercise fresh string-range rejection and inherited-range rejection; the composition regression protects the non-aggregate metadata contract. Optional follow-up: table-drive CHAR/TEXT and a retained hash-reuse control alongside VARCHAR, and measure a well-distributed short-string workload to quantify the deliberate conservative tradeoff. These are not blockers.

Validation: inspected the complete four-file diff, PR discussion/review history, and relevant exact-head planner/compiler consumers; rechecked head/base before submission. The PR reports package tests, vet/build and a ClickBench comparison; I did not independently rerun native tests, distributed execution, or those performance measurements, and did not wait for CI.

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. |

@aptend aptend left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deep-reviewed the complete four-file diff and PR history at exact head 0ff2ff5f7ed7e8c653e73e727d8f9ba05db16deb (base/merge-base 7013fbbd6e152ba31da2d3dbba7012ac7f8b8462). No blocking issue found.

The planner now rejects both freshly selected and inherited lossy eight-byte string range distributions specifically for CHAR/VARCHAR/TEXT aggregate keys, while retaining reusable full-key hash distributions. I traced the selected group-key identity, reset/fallback path, aggregate-child and join-child reuse, multi-key/grouping-set eligibility, and all range-strategy writers; numeric aggregate ranges and join/scan range behavior remain outside the guard. The stats-composition test also preserves fresh string range metadata for non-aggregate consumers.

Validation at the exact head: git diff --check; CGo/third-party build; ./.agents/skills/mo-dev/scripts/mo-cgo-test ./pkg/sql/plan ./pkg/vm/engine/disttae -count=1; and focused -race runs for both new planner regressions. Required CI is green. The documented possibility that a conservative hash fallback may subsequently choose no shuffle for some well-distributed short-string workloads is a performance tradeoff to measure, not a correctness or merge blocker.

@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.

Re-reviewed current head 0ff2ff5 against main 7013fbb. No concrete blocker found. COMMENT only for this self-authored PR.

The complete four-file change remains identical to my prior reviewed head 2a484f1; intervening commits merge main. I re-read the full current diff, all discussion/reviews, and current planner/compiler consumers, including the newly merged standalone DISTINCT-aggregate rewrite interaction. Both fresh and inherited CHAR/VARCHAR/TEXT aggregate range choices are rejected, while reset clears range/reuse state before fallback and full-key hash reuse remains eligible. Selected group-key identity, grouping-set/DISTINCT topology, remap fallbacks, and compiler key/type construction remain consistent. Numeric aggregate ranges, join/scan range decisions and fresh string range metadata are not disabled.

This prevents known prefix-induced skew; eight-byte prefix collisions alone are not evidence of incorrect aggregate results. Equality, collation handling, NULL/empty/long-key representation, aggregate ordering/state, wire and persistent formats are unchanged. There is no added runtime resource owner or synchronization. The existing hash cost threshold can choose no shuffle, so some well-distributed short-string workloads can lose parallel range aggregation; that conservative performance tradeoff is documented and is not a new blocker. Prior suggestions for CHAR/TEXT/hash-reuse/numeric controls and a short-string benchmark remain optional, not mandatory evidence failures.

Evidence: full exact-head source/history inspection and prior applicable review reused. An external review records successful exact-head CGo package tests for plan/disttae and focused race runs of the two planner regressions; I reused that report rather than rerunning native tests. I did not independently execute SQL, UT/BVT, distributed performance or ClickBench measurements, and did not wait for CI. Head/base were rechecked immediately before submission.

@XuPeng-SH
XuPeng-SH merged commit d44f033 into matrixorigin:main Sep 11, 2026
22 checks passed
@XuPeng-SH
XuPeng-SH deleted the codex/q34-string-range-safety branch September 11, 2026 09:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Denotes a PR that changes [100,499] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants