docs/design-docs/design_docs/20260526-milvus-table-external-source.md
This design adds milvus-table as an external table format. A milvus-table
external collection uses a Milvus snapshot metadata JSON file as its source and
maps the source StorageV3 segment manifests into target external segments.
Unlike Parquet external tables, a Milvus snapshot is not a set of independent
data files. It contains Milvus collection schema, segment manifests, delta logs,
and primary-key statistics. The target external collection therefore needs to
preserve the source field identity for data fields while still exposing normal
Milvus field names and external_field mappings to users.
The implementation keeps the public API small and pushes Milvus-specific behavior into the existing external table lifecycle:
storagev2/packed parses snapshot metadata, resolves source-relative paths,
imports source manifests through Loon FFI, and exposes Milvus-table manifest
helpers.The main data path is zero-copy: target manifests point at source StorageV3
column-group files after resolving them against external_source. Target-owned
files are written only for generated function outputs and delta logs that must
live in the target PK space.
external_source points to the
snapshot metadata JSON file, and external_spec.format is milvus-table.external_field as the source field name instead of storing
source field IDs in the collection schema.source_collection_id and snapshot_id.milvus-table is selected through external_spec:
{
"format": "milvus-table",
"extfs": {
"cloud_provider": "aws",
"region": "us-west-2",
"access_key_id": "...",
"access_key_value": "..."
}
}
The external_source must be the concrete snapshot metadata JSON path:
s3://bucket/snapshots/{source_collection_id}/metadata/{snapshot_id}.json
The source snapshot must come from a normal Milvus collection. Creating or
refreshing a milvus-table external collection from another external
collection snapshot is rejected. Chaining external tables would require refresh
and read paths to follow another collection's external source and storage
contract, which is outside the milvus-table snapshot contract.
The implementation intentionally does not add a Milvus-table-specific
source_collection_id or snapshot_id API. The snapshot metadata JSON is the
canonical artifact. This avoids exposing Milvus internal snapshot path layout as
part of the external table API.
The target collection schema uses normal field names. For each user data field,
external_field must be the source field name:
target field name -> external_field source field name -> source field ID
At create time, RootCoord reads the source snapshot metadata and copies the
source field ID into the target field. The persisted target schema keeps
external_field as the source field name, not as a numeric field ID string.
Milvus StorageV3 segment manifests store physical columns by field ID string.
For example, source field ID 101 is stored as physical column "101".
If the target collection generated different field IDs, data reads would point
to missing or incorrect columns.
RootCoord handles this at collection creation:
external_source and external_spec.target.field_id to the matching
source field ID found through target.external_field.preserve_field_ids=true.System fields and the target virtual primary key field are target-owned fields. They do not map to source data columns.
Target function output fields are also target-owned fields. They are not read
from source data and are assigned target-only field IDs during creation. If the
source normal Milvus snapshot already contains a function output field, a target
ordinary field may still map to that stored source column through
external_field; the target collection's own function outputs are recomputed.
During DDL replay, RootCoord skips rereading the external snapshot when
preserve_field_ids is already set. This keeps recovery independent from the
source bucket and credentials after the schema has been persisted.
DataCoord refresh revalidates the external source and spec, reads the snapshot metadata, and writes an explore manifest under the target storage. DataNode consumes that explore manifest for its assigned file range and creates target segment manifests.
For milvus-table, DataCoord exploration reads the snapshot metadata JSON and
requires storagev2_manifest_list to be present. If it is missing, refresh
fails fast with an explicit error because the source snapshot was not created
from a StorageV3 collection.
Each source StorageV3 segment manifest becomes one or more external fragments identified by:
source_manifest_path:start_row:end_row
Delete logs are intentionally not part of this fragment identity. If the L1
data fragment is unchanged but snapshot L0 overlays change, DataNode rewrites
the target segment manifest and returns the existing segment ID as an updated
segment. DataCoord then updates that segment's ManifestPath in place instead
of dropping and recreating the segment. If the source L1 manifest path or row
range changes, the old target segment is invalidated and a new target segment is
created.
| L1 fragment | L0 overlay | Refresh behavior |
|---|---|---|
| Changed | Any state | Drop the old target segment and create a new target segment. |
| Unchanged | Unchanged | Keep the target segment unchanged. |
| Unchanged | Added, removed, or changed | Keep the target segment ID and rewrite its manifest. |
For milvus-table, DataNode currently creates one target segment per refreshed
fragment rather than bin-packing multiple source fragments into one target
segment. This keeps row-offset based virtual-PK and delete translation local to
one target manifest. The tradeoff is that a source snapshot with many StorageV3
segments can create many target external segments and consume more
pre-allocated IDs. Future bin-packing would need an explicit source-row-offset
to target-row-offset mapping layer for virtual-PK delete conversion.
Target segment manifest creation is different from generic external files:
milvus-table imports source StorageV3 column groups from source segment
manifests into a target segment manifest.This keeps data files zero-copy for the main column data. Target-owned delta logs may still be written when source deletes need to be converted or copied.
DataNode writes manifests directly under final StorageV3 insert-log paths using the ID range pre-allocated by DataCoord. The range is consumed by target segment IDs, fake-binlog log IDs, and fallback deltalog IDs when a source deltalog does not carry an explicit LogID.
The persisted schema keeps external_field as a source field name. Physical
column selection is centralized in StorageColumnResolver on the Go side and
Schema::GetPhysicalColumnName / Schema::IsExternalManifestStoredField on
the C++ side. The rule depends on whether the caller is reading source data or
a target segment manifest:
| Format | Physical column name |
|---|---|
| Parquet, Lance, Vortex, Iceberg | external_field |
| Milvus table | target field ID string, aligned to source field ID |
For milvus-table, source data fields use numeric field ID strings because the
source StorageV3 manifests were written by Milvus. Target-only fields, such as
virtual PK and target function outputs, are not source data. Function outputs
are stored under target numeric field IDs after refresh.
This rule is applied at the actual physical access points:
The old approach of rewriting external_field to a numeric string is avoided
because it hides user intent in persisted schema and makes future maintenance
error-prone.
If the target schema contains a user primary key, it must map to the source snapshot primary key. The real primary key column is eagerly loaded during QueryNode segment load so retrieve/take/search output can return real IDs.
For real primary key segments:
If the target schema has no user primary key, Milvus injects the external virtual primary key:
virtual_pk = (target_segment_id & 0xffffffff) << 32 | row_offset
For virtual primary key segments:
The conversion first reads source delete keys, then scans the source primary key column only for matching keys. This keeps memory proportional to delete key count plus matched rows, not to total source rows.
Milvus snapshots can contain two delete sources:
Real primary key targets keep delete semantics in source PK space:
milvus-table rows so
delete timestamps are compared against the original insert timestamps.Virtual primary key targets must convert delete semantics:
Duplicate source primary keys are handled by mapping one source delete key to all matching target row offsets. Timestamp ordering follows existing Milvus delete semantics: a delete only removes rows with insert timestamp smaller than the delete timestamp.
QueryNode loads milvus-table external segments as normal sealed external
segments with StorageV3 manifests.
During load:
milvus-table segments also load source timestamps eagerly.milvus-table segments, QueryNode separates source external delta
paths from target-owned delta paths before decoding them.During search/query/retrieve/take:
Schema::GetPhysicalColumnName.milvus-table, this returns the field ID string.external_field.DataNode passes external_source and external_spec through
BuildIndexInfo. The C++ index build path reads external_spec and resolves
manifest columns with the same rule as the query path:
milvus-table: field ID string.external_field.This keeps index build consistent with load/search/query/take and avoids a separate schema mutation path for indexes.
external_source is not a JSON path for milvus-table, create or refresh
fails with a parameter error.storagev2_manifest_list, refresh fails fast._delta path, refresh fails fast.external_field physical-column behavior.The implementation adds coverage for: