enhance: rewrite storage.MergeSort as a proper k-way merge - #51998
Conversation
|
[ci-v2-notice] To rerun ci-v2 checks, comment with:
If you have any questions or requests, please contact @zhikunyao. |
✅ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 16.0min | - |
| ✅ Code-Check | SUCCESS | 8.9min | - |
| ✅ UT-Integration | SUCCESS | 25.9min | - |
| ✅ UT-GO | SUCCESS | 23.4min | - |
| ✅ UT-CPP-Cov | SUCCESS | 59.2min | 8499 total, 8499 passed, 0 failed |
Total: 83min | Pipeline | Artifacts
Overall Coverage: 73.6%
Diff Coverage: Go 92.1% (140 hit, 12 miss, 152 measurable lines, 77 unmeasured)
Diff Coverage HTML: view changed lines
Total Patch Coverage: 92.1% (140/152 measurable lines, 77 unmeasured)
|
Adversarial review found no blocking issues; the remaining items are four low-severity and two nitpick-level points on Low
Nitpick
|
29cf9ba to
1cd871b
Compare
|
Thanks — all six verified against the head you reviewed ( Low — init loop drops a record returned alongside
|
SummaryAdversarial verification confirmed six issues in this PR — one medium-severity behavior regression where the new hard error rejects input the old code merged correctly with no fallback in the compaction path, plus five low-severity robustness, error-classification, and test-coverage gaps. Medium
The old Suggestion: return a distinguishable sentinel (see the Low
Suggestion: optional hardening — record the first reader's
The old init loop ran Suggestion: one line —
The Suggestion: switch the wrapper, and replace the substring assertion at
The Suggestion: add a reader that poisons or releases the previous record's buffers on
Suggestion: add a case pinning the current decision (single reader, record |
✅ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 11.8min | - |
| ✅ Code-Check | SUCCESS | 8.1min | - |
| ✅ UT-Integration | SUCCESS | 24.9min | - |
| ✅ UT-GO | SUCCESS | 22.2min | - |
| ✅ UT-CPP-Cov | SUCCESS | 39.8min | 8499 total, 8499 passed, 0 failed |
Total: 80min | Pipeline | Artifacts
Overall Coverage: 73.6%
Diff Coverage: Go 92.8% (141 hit, 11 miss, 152 measurable lines, 83 unmeasured)
Diff Coverage HTML: view changed lines
Total Patch Coverage: 92.8% (141/152 measurable lines, 83 unmeasured)
|
I think one production-safety issue should be addressed before this merges. The new k-way segment.GetIsSorted() || segment.GetIsSortedByNamespace()The actual merge key is With this PR, such input reaches the monotonicity check and returns an error. Suggested minimal fix before merge:
This keeps the optimization unchanged for valid sorted inputs while preventing a metadata/order mismatch from making a segment uncompactionable. The remaining lower-severity robustness and test-coverage items can be handled as follow-ups. |
Re-review of
|
1cd871b to
983118d
Compare
|
Thanks — taking the Taken: One caveat worth recording: Not taken: degrading to First, this is not a regression against the prior behavior. The old path panicked on general disorder — Second, the trigger is a segment whose metadata says sorted and whose bytes are not — a should-not-happen class (a sorter bug, storage corruption, memory corruption). Degrading silently to Third, the degradation has a wrinkle the suggestion doesn't cover. Instead, I took the other half. The gate now checks the flag matching the plan's merge key rather than either flag, which closes the metadata-expressible mismatch at zero cost and before any rows are read. And the error is now actionable: One correction. Your Medium argued the path is "reachable more broadly than the PR comment claims", citing the single-segment gate. That part is right and I fixed the stale comment. But neither of us established why a sorted-flagged segment would carry unsorted data in the first place — and the PR body's answer to that (the namespace toggle) turns out not to hold: |
|
@xiaofan-luan thanks — implemented, with one correction to the mechanism. Head is now Implemented. Your single-segment observation was right, and it exposed a comment/code disagreement: The trigger you described is not reachable, though. You wrote that an That does not make the change pointless — the gate was testing the wrong proposition, and it was safe by coincidence rather than by construction — but it does mean this is defense-in-depth rather than a fix for something users are hitting. The PR body claimed otherwise and I have corrected it. For the same reason I did not open a Also worth flagging: the PR body attributed #48322 to this gate. That was wrong. #48322 was an arrow-go use-after-free on STRUCT array deallocation, fixed by milvus-io/arrow#3 and #48775, verified on On the deeper failure mode — a segment whose flag matches but whose bytes are not actually ordered — the gate cannot see that, and I deliberately did not add a |
Adversarial review found no issues requiring changes. Verified:
|
❌ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 14.8min | - |
| ✅ Code-Check | SUCCESS | 9.2min | - |
| ✅ UT-Integration | SUCCESS | 26.4min | - |
| ❌ UT-GO | FAILURE | 23.9min | - |
| ✅ UT-CPP-Cov | SUCCESS | 56.3min | 8629 total, 8629 passed, 0 failed |
Total: 79min | Pipeline | Artifacts
Failed Test Logs:
- UT-GO: view log
|
/ci-rerun-build-ut-cov |
❌ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 17.8min | - |
| ✅ Code-Check | SUCCESS | 9.9min | - |
| ✅ UT-Integration | SUCCESS | 25.7min | - |
| ❌ UT-GO | FAILURE | 23.6min | - |
| ✅ UT-CPP-Cov | SUCCESS | 63.0min | 8629 total, 8629 passed, 0 failed |
Total: 86min | Pipeline | Artifacts
Failed Test Logs:
- UT-GO: view log
TestMergeSort only asserted Value(0) of each output record, and the 64MB batchSize produced a single batch, so the ordering assertion was effectively inert. Check every row with a small batchSize instead. MergeSort had no varchar-key and no multi-field-key coverage, and nothing pinned how many times predicate is called per row -- its production implementation carries a side effect (segmentTotalRows in merge_sort.go). Add all three, plus BenchmarkMergeSort and BenchmarkMergeSortVarcharKey. These all pass on the current implementation; they exist to pin behavior across the rewrite that follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
Keep one heap entry per reader instead of every in-flight row, resolve the merge key columns once per record instead of once per comparison, and store rowIndex by value in a small typed heap to drop the per-row allocation. storage.PriorityQueue had no other users and is removed along with the endPositions bookkeeping. Add an explicit check that the emitted key never decreases, so input that is not sorted by the merge key fails loudly instead of silently emitting rows out of order. The previous key is held in reusable buffers rather than cloned per row, which would otherwise reintroduce the allocation this removes. BenchmarkMergeSort, 200k rows over a 20-field schema with 5 vector types: 523ms -> 326ms (1.61x), 302617 -> 102599 allocs/op. The gain depends on how much rb.Append dominates: BenchmarkMergeSortVarcharKey, 65k rows over a single varchar field, goes 60.8ms -> 9.2ms (6.63x) and 65642 -> 91 allocs/op. issue: milvus-io#51981 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
…-break Adversarial review follow-up on the k-way merge, two non-blocking items: - The keys[ri] lifetime comment claimed a reader's keys are only read while that reader has a heap entry. The main loop pops the reader's only entry at sort.go:535 and then reads keys[x.ri] in compareWithLast (:537) and saveLast (:540). The code is safe for a different reason -- keys[ri] survives until seedNext(ri) at :557 -- so the comment now states that, plus the consequence: moving either call after seedNext would be a use-after-advance. - The heap comparator's x.i < y.i fallback is unreachable. seedNext pushes a single row and is called again only after that reader's entry is popped, so x.ri != y.ri always holds. Dropped, with the invariant stated in its place. Tie-breaking on ri alone preserves the previous output order, since a reader's equal-key rows are re-seeded in increasing pos. TestRowHeap and TestRowHeapSingleElement supply their own comparators and are unaffected. Comment-only apart from the removed branch; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
The gate was a local in Compact(), so covering it meant running a full compaction with storage mocks. Extracting it leaves behavior unchanged and makes it unit-testable. The replaced comment claimed a single-segment exclusion the code never implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
Add two subtests that pin the boundary and full-segment-scan behavior the extraction rewrote: an exact-max-segments case (distinguishes <= from <) and a later-unsorted-segment case (distinguishes a full scan from checking only the first segment). Also correct the doc comment, which implied the sort flag is checked against this plan's specific merge key when it is not yet, and soften an inferred-motive comment about the segment cap to what the code and param doc actually support. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
MergeSort merges by [pk] or [partitionKey, pk] depending on the schema's namespace setting, but the gate accepted a segment carrying either sorted flag, so it checked whether a segment was sorted rather than whether it was sorted by the key this plan merges on. Require the matching flag; a mismatch falls through to the existing mergeSplit path before any data is read. The NamespaceCompactor test fixture needed a correction to match: its schema never set EnableNamespace while its merge key ([partitionKey, pk]) and segment flags (IsSortedByNamespace) both assumed it was on. That state cannot occur in production -- NewNamespaceCompactor is constructed only inside the namespaceEnabled branch of datanode/services.go, which derives both the merge key and the flag from the same setting. The assertions are unchanged; only the fixture's schema was made faithful. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
Correct the in-function comment: EnableNamespace does not universally imply a [partitionKey, pk] merge key, because namespace.mode=partition adds no partition key field. The gate stays correct because datanode/services.go rejects such plans before their segments are ever flagged, so say that instead of asserting the invariant. Also credit merge sort and the bump-schema-version compactor as writers of the sorted flags, not sort compaction alone. Rename TestCanMergeSort's emptySchema to namespaceDisabledSchema and note that EnableNamespace=false is load-bearing there, so a later schema swap cannot silently drop that half of the truth table. In the NamespaceCompactor fixture, name the namespaceEnabled branch of services.go rather than a line number that will rot, and mark field 101 as the partition key so the schema matches the merge key it declares. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
…ation The out-of-order check used ErrStorage, whose own doc says to prefer ErrDataIntegrity when it fits. It fits: a binlog that claims to be sorted while it is not is bytes on disk disagreeing with what the metadata says about them. Both codes are non-retriable SystemError, so the swap changes only the code and message, not retry semantics or classification. The message was a bare sentence, so an operator could not tell which input was at fault. It now carries the reader index, the row index and the merge key field IDs; a later commit logs the reader-index to segment-ID mapping at the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
The row index the previous commit added is relative to the current record, not to the reader's stream: advanceRecord resets pos[ri] -- and so idx.i -- to zero on every new record. A reader many batches in would report a small row number bearing no relation to where the bad row sits, which defeats the point of naming a location at all. Add recNo[ri], incremented in advanceRecord next to the existing pos[ri] reset, and report a (record, row) coordinate. seedNext is the only path that advances recNo for a reader, and in the main loop it runs after the out-of-order check, so recNo[idx.ri] still names the record idx came from at check time. Add TestMergeSortUnsortedInputReportsLaterRecord. The existing case's disorder falls in the first record, so a reported record number of 0 would also pass if recNo were hardcoded; this variant keeps the first record in order and puts the offending row in the second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
The unsorted-input error names a reader index, but an operator seeing "reader 0" has no way to reach a segment from it, and the compactor logged nothing identifying the plan. Since this failure is deliberately loud and expects an operator to act on it, that gap made it unactionable. Log the reader-index to segment-ID mapping at the point of failure -- segmentReaders[i] is built from binlogs[i], so the index carries over -- along with the merge key fields, and attach planID and collectionID at the compactor boundary. Observability only; control flow is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
Both existing unsorted-input tests drive a single reader, so idx.ri is always 0 in the error -- a hardcoded 0 would pass them, and the reader index is what the compactor log maps to a segment ID, so it is the load-bearing link in the whole diagnosability chain. Add a case with two readers where reader 0 stays in order and reader 1's second record carries the offending row. Two breakage experiments, each reverted, confirm it discriminates: hardcoding idx.ri to 0 fails only the new test, and replacing per-reader recNo with a counter shared across readers also fails only the new test, reporting record 3 instead of 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
The two lines differed by one letter ("failed"/"fail"), so grep and any
log-based alert could not tell them apart, and each was missing what the
other had: segment IDs only on the inner line, collectionID only on the
outer. For a failure that deliberately stays loud and expects an operator
to act, that is the wrong shape.
Several error paths inside mergeSortMultipleSegments -- writer and reader
construction, deltalog composition -- return without logging, and the
executor's catch-all one level up is generic across every compaction
kind, so the boundary log is still their only record. Keep it, but say
plainly that it is the boundary catch-all, and add collectionID to the
inner line so the integrity case is diagnosable from one line. Rename
segmentIDs to segmentIDsByReaderIndex so the correspondence with the
error's reader index does not have to be guessed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
983118d to
ae61298
Compare
✅ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 11.9min | - |
| ✅ Code-Check | SUCCESS | 8.8min | - |
| ✅ UT-Integration | SUCCESS | 26.0min | - |
| ✅ UT-GO | SUCCESS | 22.5min | - |
| ✅ UT-CPP-Cov | SUCCESS | 44.4min | 8629 total, 8629 passed, 0 failed |
Total: 79min | Pipeline | Artifacts
Overall Coverage: 74.0%
Diff Coverage: Go 84.8% (167 hit, 30 miss, 197 measurable lines, 100 unmeasured)
Diff Coverage HTML: view changed lines
Total Patch Coverage: 84.8% (167/197 measurable lines, 100 unmeasured)
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: bigsheeper, czs007 The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
Cherry-pick from master PR milvus-io#51998 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: bigsheeper <yihao.dai@zilliz.com>
…2429) Cherry-pick from master pr: #51998 issue: #51981 ## Summary Cherry-picked from master PR #51998 (merged) ## Verification - [x] File count matches original PR - [x] Code changes verified against master PR diff (line-level comparison) - [x] No conflict markers - [ ] make static-check (skipped by cherry-pick workflow) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: bigsheeper <yihao.dai@zilliz.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#51998) issue: milvus-io#51981 ## What `storage.MergeSort` is the default mix-compaction path (`dataNode.compaction.useMergeSort` defaults to `true`), reached via `mix_compactor.go` -> `merge_sort.go:135`. Three changes: 1. **Heap holds k entries, not every in-flight row.** `enqueueAll` pushed every qualifying row of the current record into the queue, so the heap held the in-flight row count rather than the reader count. Each input record is already sorted by the merge key, so the standard form applies: one entry per reader, pop one and push one. At 30 readers x 4096 rows/record the heap goes from ~246k entries to 30. 2. **Merge keys resolved once per record.** The comparator did `recs[x.ri].Column(fid).(*array.Int64).Value(x.i)` per side per comparison -- a map lookup plus a type assert whose result is invariant for a record. They are now extracted in `advanceRecord` (`Int64Values()` for int64, the `*array.String` pointer for varchar; both O(1) and copy-free). 3. **No per-row allocation.** `pq.Enqueue(&index{...})` escaped once per row, and `container/heap`'s `Push(x any)` boxes regardless. Replaced by a small typed value heap over the existing `rowIndex{ri, i int32}` (added in milvus-io#50817). `storage.PriorityQueue` had no other users repo-wide and is removed, along with the `endPositions` bookkeeping. The sibling `Sort` in the same file already received (2) and (3) in milvus-io#50817. ## Numbers `BenchmarkMergeSort` (added here), 200k rows over `generateTestSchema()` -- 20 fields including 5 vector types. Median of 3, `-benchtime 10x -cpu 1`: | | before | after | |---|---|---| | time | 523.50 ms | **325.96 ms (1.61x)** | | allocs | 302,617 | **102,599 (-66%)** | The gain depends on how much `rb.Append` dominates. `BenchmarkMergeSortVarcharKey`, 65k rows over a single varchar field: | | before | after | |---|---|---| | time | 60.83 ms | **9.18 ms (6.63x)** | | allocs | 65,642 | **91** | | B/op | 6.11 MB | 4.84 MB (-21%) | The varchar allocation count is the cleanest evidence: 65,642 is approximately the 65,536 rows, i.e. one allocation per row; after is 91 in total, identical across all three runs. ## One deliberate behavior change A k-way merge relies on each input record being sorted by the merge key. That reliance is not new, but the old path did not fail uniformly on disorder, so this is a narrowing and worth stating precisely. `endPositions` already depended on sortedness for the general case: an unsorted record could dequeue its smallest key first and trigger `advanceRecord` while stale entries from the now-invalid record remained queued (`RecordReader.Next` is borrow-scoped, `record_reader.go:20-21`), which panics with `index out of range` -- the `[3,1,2]` shape. But it did tolerate a narrower class. Because the old loop enqueued every eligible row of a record, disorder whose maximum key still lands on the last row (a single reader with key order `[2,1,3]`, so `endPositions[ri]` is the final index) never triggered the early `advanceRecord`: it was emitted correctly as 1,2,3 and `MergeSort` returned success. Those inputs now return an error instead. The narrowing is intended -- a bare k-way merge would emit them silently out of order, and trading a loud failure for silent corruption is the one outcome worth avoiding -- and `mix_compactor.go:473-484` gates merge-sort to already-sorted segments, so the affected class is vanishingly rare on real data. To avoid trading a loud failure for silent corruption, the merge now checks that the emitted key never decreases and returns a merr error otherwise. The previous key is held in reusable buffers rather than cloned per row -- cloning would reintroduce exactly the per-row allocation this PR removes. **On milvus-io#48322 / milvus-io#48449.** An earlier revision of this description named the gate as their root cause and called for a reachability analysis. Both parts have since been resolved, and neither the way that paragraph assumed. milvus-io#48322 was an arrow-go use-after-free on STRUCT array deallocation, fixed by milvus-io/arrow#3 and milvus-io#48775 and verified on `master-20260429`. milvus-io#48449 carries the same title; its closure trail is inconclusive and does not point at the gate either. Neither issue supports the attribution. The reachability analysis has been done, and the scenario it described -- an `IsSorted=true` segment passing the gate "after `namespaceEnabled` is turned on" -- is **not reachable**. `EnableNamespace` is written only at collection creation (`ddl_callbacks_create_collection.go:217`); every schema-altering path rebuilds the snapshot through `coll.ToCollectionSchemaPB()`, which copies the stored value (`collection.go:163`), so the assignment at `collection.go:216` is an identity. And `sort_compaction.go:373-374` derives both sorted flags from that same immutable setting (`isSorted := !isNamespaceSorted`), so a segment's flag and the plan's merge key cannot disagree today. The gate is nonetheless loose in *structure* -- it tests whether a segment was sorted, not by which key -- so it is safe by coincidence rather than by construction. This PR now tightens it. ## Gate tightened `canMergeSort` (extracted from `Compact()` so it is unit-testable) now requires the sorted flag that corresponds to *this plan's* merge key, rather than either flag. A mismatch falls through to the existing `mergeSplit` path before any segment rows are read; no new fallback code. Two reviewers asked for this. It is defense-in-depth, not a fix for an observed production case -- as established above, the metadata-level mismatch is not reachable today. The failure that *is* left loud -- a segment whose flag matches but whose bytes do not -- is now diagnosable. `MergeSort` reports `ErrDataIntegrity` (`ErrStorage`'s own doc directs callers here; both are non-retriable `SystemError`, so retry semantics and classification are unchanged) with a `reader N record M row K` coordinate, and `mergeSortMultipleSegments` logs the reader-index to segment-ID mapping alongside it, so the reader index resolves to a segment. **Not done:** degrading to `mergeSplit` when that error fires. Rationale in the review thread -- briefly, the prior behavior for general disorder was a panic, so a deterministic error is not a regression against it; and a metadata/data inconsistency is a should-not-happen class where silent degradation would hide the problem. ## Test coverage `MergeSort` had no varchar-key test and no multi-field-key test, and `TestMergeSort`'s ordering assertion only checked `Value(0)` of each output record while the 64MB `batchSize` yielded a single batch. The first commit adds those baselines and shows them green on the pre-rewrite implementation; the second keeps them green. - `TestMergeSort` -- every row checked, small batchSize to force multiple batches - `TestMergeSortVarcharKey`, `TestMergeSortByMoreThanOneField` -- previously uncovered paths - `TestMergeSortPredicateCalledOncePerRow` -- pins predicate arity, whose production implementation carries a side effect (`segmentTotalRows`) - `TestMergeSortUnsortedInputReturnsError` -- the ordering check - `TestMergeSortUnsortedInputReportsLaterRecord`, `TestMergeSortUnsortedInputReportsOffendingReader` -- pin the reported coordinate: the first that the record number is computed rather than always 0, the second that the reader index is too - `TestCanMergeSort`, `TestCanMergeSortMatchesMergeKey` -- the gate, both halves of the merge-key truth table plus the segment-count boundary - `TestRowHeap`, `TestRowHeapSingleElement` ## Verification - `./internal/storage/ -run 'TestMergeSort|TestRowHeap|TestSort|TestRadixSort'` -- pass - `./internal/datanode/compactor/...` -- pass - `pkg/util/merr` guard tests -- pass (an error code changed) - `go build -tags dynamic` on both packages -- pass - `make static-check` -- **not verified locally**; the local golangci-lint binary is incompatible with the current Go toolchain (`unsupported version: 2`). `go vet` on both packages reports only pre-existing findings in files this PR does not touch. Relying on CI for this one. Two pre-existing failures in the full `./internal/storage/` package (`TestAzureObjectStorage` timeout, `TestReadFile` nil deref) reproduce on unmodified master and are unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: bigsheeper <yihao.dai@zilliz.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2496) Cherry-pick from master pr: #51998 issue: #51981 ## Summary Cherry-picked from master PR #51998 (merged): `storage.MergeSort` is rewritten as a proper k-way merge — the heap holds one entry per reader instead of every in-flight row, merge keys are resolved once per record instead of per comparison, and the per-row `pq.Enqueue(&index{...})` allocation is gone. ## Backport adaptations (2.6) **1. Carries one type definition from #50817, which was never backported.** `internal/storage/sort.go` gains these 7 lines: ```go // rowIndex addresses a single row as (record index, row-in-record index). It is // stored by value to avoid a per-row heap allocation. type rowIndex struct { ri int32 i int32 } ``` `rowIndex` was introduced by #50817; the `rowHeap` that #51998 adds is typed over it (`items []rowIndex`, `push(v rowIndex)`, `pop() rowIndex`), so it cannot compile without this declaration. **Nothing else from #50817 is included** — its three `storage.Sort` optimizations (value slice instead of `[]*index`, pre-extracted sort keys, radix sort for a single int64 key) are NOT in this PR, and `Sort` is left exactly as it is on this branch. This PR is deliberately not tagged `pr: #50817`, because #50817 remains un-backported and should stay visible as such. **2. `canMergeSort` drops the namespace branch.** 2.6 has no `GetIsSortedByNamespace`, so the master form ```go namespaceEnabled := plan.GetSchema().GetEnableNamespace() sortedByMergeKey := segment.GetIsSorted() if namespaceEnabled { sortedByMergeKey = segment.GetIsSortedByNamespace() } ``` reduces to checking `GetIsSorted()` only. This is condition-for-condition equivalent to the inline `sortMergeAppicable` logic it replaces on this branch: same `UseMergeSort` gate, same per-segment sorted check, same `<= MaxSegmentMergeSort` bound. **3. `TestCanMergeSortMatchesMergeKey` is dropped** — it exists solely to pin which sorted flag applies when namespace is enabled, which has no meaning on 2.6. `TestCanMergeSort` is kept and covers the rest. **4. Logging uses this branch's `log` + `zap`** instead of master's `mlog` (not present on 2.6), in `mix_compactor.go` and `merge_sort.go`. **5. `storage.PriorityQueue` is removed** along with the old merge loop, as on master — it had no other users in this package. ## Verification - [x] File count matches original PR (6) - [x] Conflicts resolved per-block by enclosing function: `Sort`-internal blocks kept at 2.6 side, `MergeSort` blocks taken from master - [x] No conflict markers; no `pkg/v3` / `go-api/v3` paths; no unused imports - [x] No dead code carried in from #50817 (`radixSortByInt64` and its test, `oneShotRecordReader` — all unused here — removed) - [ ] make static-check (skipped by cherry-pick workflow) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: bigsheeper <yihao.dai@zilliz.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
issue: #51981
What
storage.MergeSortis the default mix-compaction path (dataNode.compaction.useMergeSortdefaults totrue), reached viamix_compactor.go->merge_sort.go:135. Three changes:Heap holds k entries, not every in-flight row.
enqueueAllpushed every qualifying row of the current record into the queue, so the heap held the in-flight row count rather than the reader count. Each input record is already sorted by the merge key, so the standard form applies: one entry per reader, pop one and push one. At 30 readers x 4096 rows/record the heap goes from ~246k entries to 30.Merge keys resolved once per record. The comparator did
recs[x.ri].Column(fid).(*array.Int64).Value(x.i)per side per comparison -- a map lookup plus a type assert whose result is invariant for a record. They are now extracted inadvanceRecord(Int64Values()for int64, the*array.Stringpointer for varchar; both O(1) and copy-free).No per-row allocation.
pq.Enqueue(&index{...})escaped once per row, andcontainer/heap'sPush(x any)boxes regardless. Replaced by a small typed value heap over the existingrowIndex{ri, i int32}(added in enhance: speed up sort compaction in storage.Sort #50817).storage.PriorityQueuehad no other users repo-wide and is removed, along with theendPositionsbookkeeping.The sibling
Sortin the same file already received (2) and (3) in #50817.Numbers
BenchmarkMergeSort(added here), 200k rows overgenerateTestSchema()-- 20 fields including 5 vector types. Median of 3,-benchtime 10x -cpu 1:The gain depends on how much
rb.Appenddominates.BenchmarkMergeSortVarcharKey, 65k rows over a single varchar field:The varchar allocation count is the cleanest evidence: 65,642 is approximately the 65,536 rows, i.e. one allocation per row; after is 91 in total, identical across all three runs.
One deliberate behavior change
A k-way merge relies on each input record being sorted by the merge key. That reliance is not new, but the old path did not fail uniformly on disorder, so this is a narrowing and worth stating precisely.
endPositionsalready depended on sortedness for the general case: an unsorted record could dequeue its smallest key first and triggeradvanceRecordwhile stale entries from the now-invalid record remained queued (RecordReader.Nextis borrow-scoped,record_reader.go:20-21), which panics withindex out of range-- the[3,1,2]shape.But it did tolerate a narrower class. Because the old loop enqueued every eligible row of a record, disorder whose maximum key still lands on the last row (a single reader with key order
[2,1,3], soendPositions[ri]is the final index) never triggered the earlyadvanceRecord: it was emitted correctly as 1,2,3 andMergeSortreturned success. Those inputs now return an error instead. The narrowing is intended -- a bare k-way merge would emit them silently out of order, and trading a loud failure for silent corruption is the one outcome worth avoiding -- andmix_compactor.go:473-484gates merge-sort to already-sorted segments, so the affected class is vanishingly rare on real data.To avoid trading a loud failure for silent corruption, the merge now checks that the emitted key never decreases and returns a merr error otherwise. The previous key is held in reusable buffers rather than cloned per row -- cloning would reintroduce exactly the per-row allocation this PR removes.
On #48322 / #48449. An earlier revision of this description named the gate as their root cause and called for a reachability analysis. Both parts have since been resolved, and neither the way that paragraph assumed.
#48322 was an arrow-go use-after-free on STRUCT array deallocation, fixed by milvus-io/arrow#3 and #48775 and verified on
master-20260429. #48449 carries the same title; its closure trail is inconclusive and does not point at the gate either. Neither issue supports the attribution.The reachability analysis has been done, and the scenario it described -- an
IsSorted=truesegment passing the gate "afternamespaceEnabledis turned on" -- is not reachable.EnableNamespaceis written only at collection creation (ddl_callbacks_create_collection.go:217); every schema-altering path rebuilds the snapshot throughcoll.ToCollectionSchemaPB(), which copies the stored value (collection.go:163), so the assignment atcollection.go:216is an identity. Andsort_compaction.go:373-374derives both sorted flags from that same immutable setting (isSorted := !isNamespaceSorted), so a segment's flag and the plan's merge key cannot disagree today.The gate is nonetheless loose in structure -- it tests whether a segment was sorted, not by which key -- so it is safe by coincidence rather than by construction. This PR now tightens it.
Gate tightened
canMergeSort(extracted fromCompact()so it is unit-testable) now requires the sorted flag that corresponds to this plan's merge key, rather than either flag. A mismatch falls through to the existingmergeSplitpath before any segment rows are read; no new fallback code.Two reviewers asked for this. It is defense-in-depth, not a fix for an observed production case -- as established above, the metadata-level mismatch is not reachable today.
The failure that is left loud -- a segment whose flag matches but whose bytes do not -- is now diagnosable.
MergeSortreportsErrDataIntegrity(ErrStorage's own doc directs callers here; both are non-retriableSystemError, so retry semantics and classification are unchanged) with areader N record M row Kcoordinate, andmergeSortMultipleSegmentslogs the reader-index to segment-ID mapping alongside it, so the reader index resolves to a segment.Not done: degrading to
mergeSplitwhen that error fires. Rationale in the review thread -- briefly, the prior behavior for general disorder was a panic, so a deterministic error is not a regression against it; and a metadata/data inconsistency is a should-not-happen class where silent degradation would hide the problem.Test coverage
MergeSorthad no varchar-key test and no multi-field-key test, andTestMergeSort's ordering assertion only checkedValue(0)of each output record while the 64MBbatchSizeyielded a single batch. The first commit adds those baselines and shows them green on the pre-rewrite implementation; the second keeps them green.TestMergeSort-- every row checked, small batchSize to force multiple batchesTestMergeSortVarcharKey,TestMergeSortByMoreThanOneField-- previously uncovered pathsTestMergeSortPredicateCalledOncePerRow-- pins predicate arity, whose production implementation carries a side effect (segmentTotalRows)TestMergeSortUnsortedInputReturnsError-- the ordering checkTestMergeSortUnsortedInputReportsLaterRecord,TestMergeSortUnsortedInputReportsOffendingReader-- pin the reported coordinate: the first that the record number is computed rather than always 0, the second that the reader index is tooTestCanMergeSort,TestCanMergeSortMatchesMergeKey-- the gate, both halves of the merge-key truth table plus the segment-count boundaryTestRowHeap,TestRowHeapSingleElementVerification
./internal/storage/ -run 'TestMergeSort|TestRowHeap|TestSort|TestRadixSort'-- pass./internal/datanode/compactor/...-- passpkg/util/merrguard tests -- pass (an error code changed)go build -tags dynamicon both packages -- passmake static-check-- not verified locally; the local golangci-lint binary is incompatible with the current Go toolchain (unsupported version: 2).go veton both packages reports only pre-existing findings in files this PR does not touch. Relying on CI for this one.Two pre-existing failures in the full
./internal/storage/package (TestAzureObjectStoragetimeout,TestReadFilenil deref) reproduce on unmodified master and are unrelated to this change.🤖 Generated with Claude Code