enhance: [reader] add snapshot-backed read entrypoint - #94
Conversation
Add MilvusReadApp and a spark-submit wrapper for client and snapshot reads, and prefer client-created snapshots with fallback only when snapshot RPCs are unavailable. Reuse snapshot planning for V2/V3 segments, expose real row_id/timestamp plus $segment_id/$row_offset metadata, and make Arrow string conversion handle JSON stored as binary. Update Milvus proto for snapshot RPCs and add focused tests for the read app, snapshot planner helpers, and Arrow VarBinary StringType conversion. Also: reader/backfill metadata columns are now named $segment_id and $row_offset instead of segment_id and row_offset. Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: congqixia The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
MilvusDataSource.scala:540 In snapshot mode, Fix: Have |
|
MilvusSnapshotReader.scala:645 Three things conspire to produce nulls for V3 system fields in snapshot mode: (1) |
|
MilvusClient.scala:887 The check |
|
MilvusDataSource.scala:1026 Both |
|
MilvusDataSource.scala:977 Nearly identical bucket-scoped S3A configuration code exists in |
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
|
src/main/scala/operations/backfill/BackfillApp.scala:219 The check at line 219 is |
|
src/main/scala/operations/backfill/README.md:248 Lines 248-253 reference |
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
| s3Location = snapshot.s3Location | ||
| ) | ||
| ) | ||
| } catch { |
There was a problem hiding this comment.
At line 670, the inner catch calls dropSnapshot(...) and ignores the Try result. If the drop fails, there is no warning logged, making it impossible to diagnose orphaned snapshots.
Fix: Log a warning when the cleanup dropSnapshot call fails.
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
|
src/main/scala/zilliztech/spark/milvus/MilvusDataSource.scala:0 When the connector detects a legacy server that doesn't support snapshots and falls back, it drops filter pushdown entirely. This is a performance regression for all users on older Milvus versions — queries that previously pushed filters down to the server now scan the full dataset on the Spark side. |
|
src/main/scala/zilliztech/spark/milvus/SchemaUtil.scala:0 When field IDs are missing and the code falls back to |
|
src/main/scala/zilliztech/spark/milvus/MilvusOption.scala:0 MilvusOption is serialized as part of the InputPartition closure and shipped to Spark executors. This means access keys, secret keys, and tokens are embedded in the serialized task payload, which may be logged, persisted to disk, or visible in the Spark UI. Sensitive credentials must be stripped before serialization or fetched independently on the executor side. |
|
src/main/scala/zilliztech/spark/milvus/ArrowConverter.scala:0 ArrowConverter performs unchecked casts that will throw ClassCastException at runtime if the actual Arrow vector type doesn't match expectations. Additionally, BinaryVector values (unsigned bytes 0-255) are mapped to Spark's ByteType (signed -128 to 127), silently reinterpreting values above 127 as negative numbers. This is a silent data corruption issue. |
|
src/main/scala/zilliztech/spark/milvus/ArrowConverter.scala:0 Spark's BinaryType is already |
|
src/main/scala/zilliztech/spark/milvus/SchemaUtil.scala:0 SchemaUtil uses case-sensitive |
|
src/main/scala/zilliztech/spark/milvus/MilvusClient.scala:1129 At lines 1129-1144, the retry interceptor calls |
|
src/main/scala/zilliztech/spark/milvus/MilvusDataSource.scala:713 When dropClientReadSnapshot fails, it retries up to 3 times in a tight loop with no Thread.sleep, backoff, or jitter. During transient network issues, these immediate retries are ineffective and add unnecessary load. For distributed cleanup operations, even a simple fixed delay would significantly improve resilience. |
|
src/main/scala/zilliztech/spark/milvus/MilvusPackedV2PartitionReader.scala:109 When a requested column is not present in the Column Group, the reader silently skips it. If this happens due to an upstream planning error, the query returns incomplete data with no indication of the missing column. This makes debugging extremely difficult in production. |
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
| val server = MilvusServiceGrpc | ||
| .blockingStub(channel) | ||
| .withWaitForReady() | ||
| server |
There was a problem hiding this comment.
The interceptor wraps a single ClientCall from next.newCall(...) at line 1104. On retry, executeCall() recursively invokes super.start(...) on that same call from inside the listener's onClose (line 1157). gRPC's ClientCall is single-use — the second start() will throw IllegalStateException("call already started"). Combined with the removal of the pre-retry super.onClose(...), the interceptor also never delivers the original failure to the caller, so the first transient UNAVAILABLE swallows the result silently. This is worse than having no retry interceptor. Either delete the custom interceptor and use gRPC's built-in retryPolicy via ManagedChannelBuilder.enableRetry() + service config, or rewrite to allocate a fresh next.newCall(...) per attempt.
|
|
||
| val sparkSchema = MilvusSnapshotReader.toSparkSchema(schema) | ||
|
|
||
| sparkSchema("binary").dataType shouldBe DataTypes.createArrayType( |
There was a problem hiding this comment.
The test at line 41 expects BinaryVector code 100 to map to ArrayType(ByteType), but the implementation at MilvusSnapshotReader.scala:625 returns BinaryType. The implementation matches the PR description and DataTypeUtilTest. This test will fail in CI. The commit 0689668 ("Address comment") appears to have flipped the expectation in the wrong direction.
| private[read] def buildFieldMappings( | ||
| milvusSchema: CollectionSchema | ||
| ): FieldMappings = { | ||
| val systemFields = Map(0L -> "RowID", 1L -> "Timestamp") |
There was a problem hiding this comment.
Line 37 filters lowercase aliases (row_id/timestamp) but not the CamelCase canonical names RowID/Timestamp. If a user collection has a field named RowID with fieldID 100, the mapping produces Map(0L → "RowID", 100L → "RowID"); inverting to fieldNameToId is order-dependent and non-deterministic. The same issue exists in V3 at MilvusLoonPartitionReader.scala:64.
| @@ -62,7 +62,12 @@ class MilvusLoonPartitionReader( | |||
| private val sourceSchema = schema | |||
|
|
|||
| private val fieldNameToId: Map[String, Long] = { | |||
There was a problem hiding this comment.
Same issue as the V2 reader: line 64 handles lowercase aliases but not the literal canonical names RowID/Timestamp. If Milvus allows those as user field names, system-field access becomes ambiguous.
| } else { | ||
| // Try to load next batch | ||
| if (_currentBatch != null) { | ||
| _currentBatchStartRowOffset += _currentBatch.getRowCount |
There was a problem hiding this comment.
The vector-search branch at line 271 returns rows from vectorSearchResults.next() without setting _lastReturnedRowOffset, which defaults to -1L. The wrapper at MilvusPartitionReaderFactory.scala:97 reads underlyingReader.lastReturnedRowOffset, so $row_offset is always -1 for vector search results. Either carry the segment-local row offset alongside the row+distance pair, or reject the combination with a clear error.
| resultValues(writeIdx) = p.partitionName | ||
| case MilvusOption.MilvusExtraColumnSegmentID => | ||
| resultValues(writeIdx) = p.segmentID | ||
| case MilvusOption.MilvusExtraColumnRowOffset => |
There was a problem hiding this comment.
The V3 path emits p.partitionName (string name like "_default") while the V2 path emits p.partitionID.toString (numeric ID as string). A snapshot read can mix V2 + V3 segments, so users selecting the partition extra column get heterogeneous values from the same column depending on segment format. Both paths must agree — either both emit the partition name or both emit the ID.
|
src/main/scala/MilvusDataSource.scala:0 Snapshot cleanup is tied to |
| val hasSegmentId = schema.fieldNames.contains("segment_id") | ||
| val hasRowOffset = schema.fieldNames.contains("row_offset") | ||
| val hasPartition = schema.fieldNames.contains( | ||
| MilvusOption.MilvusExtraColumnPartition |
There was a problem hiding this comment.
The V3 wrapper reads underlyingReader.lastReturnedRowOffset (real segment-relative offset accounting for filter-skipped rows). The V2 wrapper uses a wrapper-local var rowOffset counter that increments per get() — a post-filter sequential index, not the segment-relative offset. If downstream code uses $row_offset to re-order rows for backfill (the documented use case), V2-segment rows will be misordered relative to V3-segment rows in the same collection.
| ) | ||
| server | ||
| } | ||
|
|
There was a problem hiding this comment.
The fallback that checks for substring matches in UNKNOWN-status error messages is fragile across proxy versions. It would not match if the message is ever rephrased (e.g., "method not registered" instead of the current wording).
|
src/main/scala/MilvusReadApp.scala:0 Passing S3 credentials via |
| "milvus.snapshot.schema.json" // Optional: raw schema JSON for building MilvusCollectionInfo | ||
| val SnapshotSchemaBytes = | ||
| "milvus.snapshot.schema.bytes" // Base64 encoded protobuf CollectionSchema bytes | ||
| val SnapshotMaxJsonBytes = "milvus.snapshot.maxJsonBytes" |
There was a problem hiding this comment.
Sets fs.s3a.bucket.$bucket.path.style.access=true and fs.s3a.bucket.$bucket.connection.ssl.enabled=false unconditionally, overriding any explicit global Hadoop config the user expects to inherit. The fs.s3a.impl check preserves existing values; these two keys should follow the same pattern or be configurable.
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
| } | ||
| .getOrElse(Seq.empty) | ||
|
|
||
| if (manifestList.isEmpty && packedV2Segments.isEmpty) { |
There was a problem hiding this comment.
A valid empty collection triggers IllegalArgumentException("Snapshot mode has no StorageV3 manifests or StorageV2 segments"). This should return an empty DataFrame (Array.empty[InputPartition]). The guard is redundant for safety since parseSnapshotMetadata already fails loudly on unparseable data. Distinguish "zero segments" (return empty) from "unparseable snapshot" (fail loud) and remove this overly aggressive guard.
Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
|
src/main/scala/MilvusPartitionReaderFactory.scala:94 The |
|
src/main/scala/MilvusDataSource.scala:384 When the Milvus collection already contains fields named |
|
src/main/scala/MilvusScanBuilder.scala:0 The |
|
|
||
| def isSnapshotMode(options: Map[String, String]): Boolean = { | ||
| options.get(SnapshotMode).exists(_.equalsIgnoreCase("true")) || | ||
| options.contains(SnapshotManifests) || |
There was a problem hiding this comment.
Both overloads of isSnapshotMode use .contains to check whether the snapshot manifest key is present. Setting milvus.snapshot.manifests="" will trigger snapshot mode even though no manifests are specified, which is fragile. Filter out empty/blank values before treating the option as present.
| s"describeSnapshot failed for $snapshotName (attempt $attempt/$maxAttempts)", | ||
| e | ||
| ) | ||
| TimeUnit.MILLISECONDS.sleep(200L * attempt) |
There was a problem hiding this comment.
When an InterruptedException is caught during the retry sleep at line 713, the interrupt flag is cleared. The method then throws without restoring Thread.currentThread().interrupt(), which violates the Java interrupt contract and can cause callers to miss the interrupt signal. Catch InterruptedException, restore the interrupt flag, and stop retrying.
| getConnectionMetadataInterceptor(), | ||
| retryInterceptor | ||
| ) | ||
| val interceptors = Seq(getConnectionMetadataInterceptor()) |
There was a problem hiding this comment.
The gRPC channel builder calls .enableRetry() but no defaultServiceConfig is installed, so the retry policy is never actually applied. Either install a proper service config with retry parameters, or remove the misleading .enableRetry() call.
Add MilvusReadApp and a spark-submit wrapper for client and snapshot reads, and prefer client-created snapshots with fallback only when snapshot RPCs are unavailable.
Reuse snapshot planning for V2/V3 segments, expose real row_id/timestamp plus $segment_id/$row_offset metadata, and make Arrow string conversion handle JSON stored as binary.
Update Milvus proto for snapshot RPCs and add focused tests for the read app, snapshot planner helpers, and Arrow VarBinary StringType conversion.
Also: reader/backfill metadata columns are now named $segment_id and $row_offset instead of segment_id and row_offset.