Files
Jeng.Gwan 82a1dd7714 feat: implement Import V3 reshard (#53334)
issue: #53333

## Summary

Implements the Import V3 reshard path: source files are parsed and
sorted once at first read, fragments are planned into final segments
across files and tasks, and the Import V3 merge produces segments that
already satisfy the sort-compaction ordering contract, so the
post-import SortCompaction pass is no longer run for jobs on this path
(#50593).

Rollout gate: `dataCoord.import.enableImportV3` (default `false`). The
execution version is selected once at job create, persisted in the
import WAL (`msgpb.ImportMsg.version`) and the job catalog, and never
re-selected on retry, restart or config change. L0 imports and
in-flight/legacy ImportTaskV2 jobs keep the old path.

## Mechanism

- **DataCoord**
- Import V3 job state machine: `Pending -> [PreImporting ->
AssigningIDRange] -> Resharding -> Planning -> Importing ->
IndexBuilding -> Uncommitted -> Committing -> Completed` (backup/binlog
imports skip the count-only preimport and ID-range stages).
- Planning aggregates reshard fragments by `(vchannel, partition)`
bucket across all reshard tasks, packs them into segment plans in
canonical order (sequential next-fit against the expected segment size)
and preallocates the formal segment IDs. Reshard file grouping is a
deterministic best-fit-decreasing packing capped by
`dataCoord.import.maxSizeInMBPerImportTask`.
- Per-file literal `IDRange` allocation reuses the two-phase mechanism
added in #53544; files that already carry a range (replicated/forwarded
WAL bodies) are authoritative and are not re-allocated.
- Result acceptance applies worker results to the preallocated segments
with storage-version self-healing on retry (a retry converges onto the
current storage version instead of failing on a stale version fence).
- Orphan reconciliation in the garbage collector is extended to the
Import V3 intermediate paths, plus terminal-job GC derived from durable
facts.
- **DataNode**
- `ReshardTask` parses each source file exactly once: field
normalization, PK/RowID materialization, bucket routing and per-batch
sort under the shared sort order (`importutilv2.SortFieldIDs`), written
as temporary fragments under `{root}/import_v3/{job}/reshard/{task}/`.
- `ImportTaskV3` merges fragments into final segments with a one-head
k-way merge (direct fan-in `min(dataCoord.import.fragmentMergeFanIn,
len(fragments))`, clamped to `[2,1024]`, default **128**; hierarchical
merge when the plan has more inputs), bounded by a working-set slot
estimate and DataNode slot admission.
- Count-only preimport reads parquet/numpy row counts from file metadata
(bounded by `dataCoord.import.parquetFooterMaxSize`) instead of
scanning.
- Merge intermediates go to node-local Arrow IPC / local disk, so the
object store sees each fragment exactly once.
- Transient IO and local-storage errors stay retryable via the shared
checker denylist; non-transient run errors fail the task.

## Behavior boundaries

- No user-facing API change: job creation, CommitImport/RollbackImport,
auto-commit and visibility behavior are preserved; only the internal
execution pipeline is split into `ReshardTask`/`ImportTaskV3` with
per-run `run_id` isolation for late results.
- L0 imports, V2 backup/binlog restore jobs and legacy in-flight jobs
are unchanged.
- **Changes observable even with the gate off** (reviewer awareness):
- `dataCoord.import.maxSizeInMBPerImportTask` default 16384 -> 8192. It
is consumed by the legacy V2 file regrouping (`RegroupImportFiles`), so
V2 imports on collections where `segmentMaxSize x P x V` lands in (8
GiB, 16 GiB] now produce roughly twice as many, half-sized file
groups/tasks. Set the knob back explicitly to keep the old grouping.
- CSV import no longer silently truncates on a mid-file read/parse
error: the reader used to drop every non-EOF error (`csv/reader.go`); it
now fails the job.
- CSV header / parquet file-reader failures move from
`ErrImportSysFailed` (2101) to `ErrImportFailed` (2100, Input) or the
preserved IO code; failure reason strings change accordingly. Retry
classification is unchanged.
- Existing datacoord metrics gained labels:
`ImportJobLatency`/`ImportJobs` (`job_version`), `ImportTaskLatency`
(`task_type`).
- The V2 collection-drop check no longer tries to fail `Committing`
jobs. The job state outcome is unchanged (`UnfailableJobStates` already
refuses `Committing -> Failed`); this only removes one futile catalog
write plus a warning log per GC tick.

## CMEK / TEXT columns

Column groups are encrypted with the target collection's CMEK zone;
fragment readers use the read/source plugin context. **TEXT columns are
deliberately written plaintext.** The TEXT read chain has no key
retriever wiring yet: segcore reads V3 column groups through
milvus_storage's `api::Reader` without a key retriever, and the Go
TEXT-decode segment reader has no FFI entry point for one, so an
encrypted TEXT segment would be written but never read back (both query
and the compaction TEXT rewrite). The plugin context is still threaded
all the way down into the TEXT-aware writer; only its two call sites
(`internal/flushcommon/syncmgr/pack_writer_v3.go`,
`internal/storage/binlog_record_writer.go`) intentionally pass `nil`,
with a `TODO:` to re-enable encryption once the read chain supports
decryption. This matches the design doc's Stage 3 note, and only matters
when `common.storage.useLoonFFI=true` and the collection has an
encryption zone.

## Design

design doc:
`docs/design-docs/design_docs/20260921-import-v3-reshard-design.md`

## Validation

- [x] Compiles with `-tags dynamic,test` (`internal/datacoord`,
`internal/datanode/importv3`, `internal/storage`,
`internal/flushcommon/syncmgr`, `internal/util/initcore`).
- [x] `golangci-lint --build-tags dynamic,test` clean on the touched
root packages and on `pkg/util/paramtable`.
- [x] Unit tests pass: `internal/datanode/importv3`,
`pkg/util/paramtable`, `cmd/tools/config -run TestYamlFile`.
- [x] Integration test included:
`tests/integration/import/import_v3_test.go`.
- [ ] E2E: to be run on CI.

---

# Import V3 (Reshard) vs Import V2 — Core Performance Metrics

> The only difference between V3 and V2 is `enableImportV3`; everything
else is identical (both use milvus-storage V3).

## TL;DR

On the same dataset of 111 GB / 37.92M rows: **end-to-end time is
14%–79% faster** (the more shards/partitions, the bigger the gain),
**the write chain is 41%–89% faster**, and **S3 request counts drop by
70%–99%**; moreover, **only V3 completes at 128 / 1024 partitions** (V2
fails because of the etcd 2 MB metadata limit).

## Test setup

| Item | Value |
|---|---|
| Environment | 10 × 8C 32G datanodes |
| Data | 11 jobs / **1100 parquet files / 111.31 GB / 37,924,512 rows**
|
| Schema | 13 fields; `dense_embedding` = **FLOAT16_VECTOR dim=512**,
FLAT+IP index; **partition key = `pk_id`** (99.65% unique) |
| Tiers | 10 tiers: 1x1 / 1x2 / 1x4 / 2x4 / 2x8 / 2x16 / 2x32 / 2x64 /
2x128 / 2x1024 |
| Tier naming | **NxM = num_shards × number of partitions** (e.g. `2x64`
= 2 vchannels / 64 partitions) |

| Parameter | Value |
| --- | --- |
| `dataCoord.import.fragmentSizeInMB` | 128 |
| `dataCoord.import.maxSizeInMBPerImportTask` | 8192 |
| `dataCoord.import.reshardResidentBucketCap` | 16 |
| `dataCoord.import.reshardFlushConcurrency` | 2 |
| `dataCoord.import.reshardMemoryExpansionFactor` | 1.5 |
| `dataCoord.import.fragmentMergeFanIn` | 128 |
| `dataCoord.import.enableImportV3` | true |
| `common.storage.useLoonFFI` | true |
| `dataNode.import.readBufferSizeInMB` | 16 |
| `dataNode.import.reshardSpillMaxStreams` | 128 |

## Core metrics

### ① Elapsed time

![Elapsed
time](https://github.com/user-attachments/assets/0084e9dd-caaf-469f-8eb4-8987dae38fa2)

**Per-stage elapsed time (seconds; V3 and V2 have different stage sets;
"–" = not applicable)**

| Tier | Version | create | preimport | reshard | planning | importV3\*
| importV2 | sort\* | index\* | wall |
|---|---|---|---|---|---|---|---|---|---|---|
| 1x1 | V2 | 1 | 50 | – | – | – | 112 | 127 | 480 | 835 |
| 1x1 | V3 | 4 | 9 | 75 | ~0 | 43 | – | – | 497 | 643 |
| 1x2 | V2 | 1 | 50 | – | – | – | 135 | 132 | 499 | 829 |
| 1x2 | V3 | 4 | 9 | 73 | ~0 | 45 | – | – | 485 | 629 |
| 1x4 | V2 | 1 | 46 | – | – | – | 100 | 139 | 488 | 780 |
| 1x4 | V3 | 3 | 10 | 108 | ~0 | 50 | – | – | 489 | 669 |
| 2x4 | V2 | 1 | 54 | – | – | – | 134 | 228 | 419 | 848 |
| 2x4 | V3 | 4 | 9 | 132 | ~0 | 49 | – | – | 491 | 689 |
| 2x8 | V2 | 1 | 52 | – | – | – | 308 | 341 | 336 | 1043 |
| 2x8 | V3 | 3 | 10 | 233 | ~0 | 45 | – | – | 472 | 769 |
| 2x16 | V2 | 1 | 50 | – | – | – | 602 | 294 | 277 | 1238 |
| 2x16 | V3 | 4 | 10 | 446 | ~0 | 50 | – | – | 480 | 1004 |
| 2x32 | V2 | 1 | 50 | – | – | – | 958 | 337 | 322 | 1681 |
| 2x32 | V3 | 2 | 11 | 486 | ~0 | 64 | – | – | 464 | 1032 |
| 2x64 | V2 | 1 | 48 | – | – | – | 4302 | 667 | 26 | 5058 |
| 2x64 | V3 | 3 | 10 | 510 | ~0 | 53 | – | – | 483 | 1069 |
| 2x128 | V2 | 1 | 50 | – | – | – | ≈7000 | ≈1330 | ≈25 | ≈8400 |
| 2x128 | V3 | 2 | 11 | 542 | ~0 | 61 | – | – | 450 | 1075 |
| 2x1024 | V2 | 1 | 50 | – | – | – | ≈105000 | ≈10700 | ≈25 | ≈116000 |
| 2x1024 | V3 | 2 | 8 | 625 | ~0 | 115 | – | – | 275 | 1036 |

V3 and V2 have different stage sets: V3 = preimport → reshard →
importV3; V2 = preimport → importV2 → sort.

Because `index` is dispatched as soon as its segments are ready:

- V3's `index` overlaps `import`
- V2's `index` overlaps `sort`

Therefore: (1) the stages **cannot simply be summed** — the sum does not
equal `wall` either, the gap being mainly index building; (2) the
starred columns are stretched by the index tail (which is why V3's
`import` is inflated).

The last two V2 tiers actually **Failed** (etcd 2 MB metadata limit);
their importV2 / sort / wall are **estimates derived from the source
code** (segment count = T·V·P, see ③), not measurements.

### ② S3 access

![S3 read
requests](https://github.com/user-attachments/assets/b97f3a89-802c-4a8b-82e1-709dde404b4a)

![S3 write
requests](https://github.com/user-attachments/assets/02a21d4e-990b-4845-9ea3-4820c8bbbb50)

| Tier | V2 read (ops/GB) | V3 read (ops/GB) | read drop | V2 write
(ops/GB) | V3 write (ops/GB) | write drop |
|---|---|---|---|---|---|---|
| 1x1 | 115,195 / 129.3 | 17,758 / 130.8 | ↓84.6% | 76,535 / 215.6 |
22,686 / 218.5 | ↓70.4% |
| 1x4 | 137,522 / 129.4 | 17,167 / 130.8 | ↓87.5% | 91,785 / 214.2 |
22,293 / 218.5 | ↓75.7% |
| 2x8 | 255,808 / 130.7 | 17,263 / 130.7 | ↓93.3% | 170,905 / 214.8 |
21,912 / 214.7 | ↓87.2% |
| 2x16 | 331,359 / 131.6 | 17,342 / 130.6 | ↓94.8% | 224,947 / 216.5 |
22,213 / 216.9 | ↓90.1% |
| 2x32 | 654,440 / 131.2 | 18,695 / 130.5 | ↓97.1% | 440,787 / 219.8 |
22,087 / 215.5 | ↓95.0% |
| 2x64 | 1.30M / 132.9 | 20,919 / 130.7 | ↓98.4% | 870,798 / 222.9 |
22,437 / 214.8 | ↓97.4% |
| 2x128 | ≈2.61M / — | 28,786 / 130.7 | ↓98.9% | ≈1.75M / — | 22,708 /
215.4 | ↓98.7% |
| 2x1024 | ≈20.86M / — | 104,040 / 130.0 | ↓99.5% | ≈14.02M / — | 59,070
/ 216.2 | ↓99.6% |

The **bytes** transferred are nearly identical on both sides and
**barely change across tiers** (read ≈130 GB, write ≈215 GB, both ≈ the
dataset size); **the entire difference is in the number of requests** —
V2's sort compaction re-reads and re-writes the full dataset, so its
request count is 1–2 orders of magnitude higher, whereas V3 has no sort
and keeps request counts low and flat.

### ③ Segment shape

![Final segment
count](https://github.com/user-attachments/assets/3ed52c70-4ae3-4999-9fd3-38c55cb7b161)

![Segment size
(p50)](https://github.com/user-attachments/assets/ecf2db6d-6fbf-4894-a8a7-1e1609adf29a)

| Tier | V2 final segments | V3 final segments | V2 segment size p50 |
V3 segment size p50 | V2 sort compactions |
|---|---|---|---|---|---|
| 1x1 | 197 | 172 | 846 MB | 965 MB | 197 |
| 1x4 | 172 | 172 | 988 MB | 978 MB | 172 |
| 2x8 | 176 | 176 | 1022 MB | 978 MB | 176 |
| 2x16 | 352 | 192 | 511 MB | 976 MB | 352 |
| 2x32 | 1344 | 192 | 128 MB | 985 MB | 1344 |
| 2x64 | 2000 | 256 | 64 MB | 656 MB | 2000 |
| 2x128 | ≈5,376 | 256 | ≈32 MB | 658 MB | ≈5,376 |
| 2x1024 | ≈43,008 | 2048 | ≈4 MB | 82 MB | ≈43,008 |

**V3 runs 0 compactions** and directly produces well-formed, large
segments; V2 runs one sort compaction per segment after import (count =
number of segments), so as partitions grow the data shatters into a
large number of tiny segments (e.g. 2x64: 2000 segments × 64 MB). Fewer,
larger segments are friendlier to subsequent query/index.

### ④ Memory (per datanode)

![Per-datanode memory
peak](https://github.com/user-attachments/assets/2764cfed-61fc-4585-804f-96f31f30b722)

| Tier | V2 per-datanode peak | V3 per-datanode peak |
|---|---|---|
| 1x1 | 19.1 GB | 29.8 GB |
| 1x2 | 22.9 GB | 28.5 GB |
| 1x4 | 21.5 GB | 29.4 GB |
| 2x4 | 26.2 GB | 29.9 GB |
| 2x8 | 18.1 GB | 29.6 GB |
| 2x16 | 15.1 GB | 28.6 GB |
| 2x32 | 10.6 GB | 26.1 GB |
| 2x64 | 11.6 GB | 25.8 GB |
| 2x128 | — | 25.8 GB |
| 2x1024 | — | 27.5 GB |

![Per-datanode memory over
time](https://github.com/user-attachments/assets/2b7fbaf5-8097-4602-ad89-973c0bc15ec3)

**Notes:**
- **V3 peaks at 26–30 GB per datanode, slightly above V2 (10–26 GB)** —
this is the cost of reshard holding bucket data resident in memory; in
exchange there is no sort rewrite and none of V2's flood of tiny
segments at high partition counts.
- V3's peak is **stable and slightly decreasing across tiers** (≈26–30
GB) and **does not grow with partition count (up to 1024)**; the
per-datanode usage curves (taking the busiest datanode) almost coincide
across tiers — i.e. V3's reshard memory model keeps usage independent of
the shard/partition count.
- V2's peak actually *drops* at high partition counts (2x64: only 11.6
GB), but that is because the cost is pushed into sort/IO (see ② ③), not
because it is more memory-efficient.

Signed-off-by: xaxys <tpnnghd@163.com>
2026-10-19 15:11:51 +08:00
..