-
Notifications
You must be signed in to change notification settings - Fork 82
fix: make distributed index builds version-consistent and report real coverage #795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
40d6d8a
8774517
ff648f4
6f8a5cc
1afb14b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,18 +88,18 @@ case class AddIndexExec( | |
| val btreeBuildMode = IndexUtils.btreeBuildMode(indexType, args) | ||
| val scalarSegmentIndexType = IndexUtils.scalarSegmentIndexType(method) | ||
|
|
||
| val (fragmentWorkloads, canonicalColumns) = { | ||
| // Plan and build at one pinned version; the commit opens the live dataset. | ||
| val (fragmentWorkloads, canonicalColumns, buildReadOptions) = { | ||
| val ds = Utils.openDatasetBuilder(readOptions).build() | ||
| try { | ||
| val canonical = columns.map { column => | ||
| val field = IndexUtils.resolveIndexField(ds.getLanceSchema, indexType, column) | ||
| FieldPathUtils.pathByFieldId(ds.getLanceSchema, field.getId) | ||
| } | ||
| ( | ||
| ds.getFragments.asScala | ||
| .map(fragment => FragmentWorkload(fragment.getId, fragment.metadata().getNumRows)) | ||
| .toList, | ||
| canonical) | ||
| IndexUtils.fragmentWorkloads(ds), | ||
| canonical, | ||
| IndexUtils.pinVersion(readOptions, ds)) | ||
| } finally { | ||
| ds.close() | ||
| } | ||
|
|
@@ -163,27 +163,26 @@ case class AddIndexExec( | |
| val (nsImpl, nsProps, tableId, initialStorageOpts) = | ||
| extractNamespaceInfo(lanceDataset, readOptions) | ||
|
|
||
| // Range-mode BTree uses preprocessed data from Spark and keeps its dedicated path. | ||
| if (btreeBuildMode.contains("range")) { | ||
| val segments = new RangeBasedBTreeIndexJob( | ||
| this.copy(columns = canonicalColumns), | ||
| readOptions, | ||
| buildReadOptions, | ||
| fragmentIds.size, | ||
| nsImpl, | ||
| nsProps, | ||
| tableId, | ||
| initialStorageOpts).run() | ||
| commitIndexSegments(readOptions, canonicalColumns.head, segments) | ||
| val indexed = commitIndexSegments(readOptions, canonicalColumns.head, segments) | ||
| return Seq(new GenericInternalRow(Array[Any]( | ||
| fragmentIds.size.toLong, | ||
| indexed.toLong, | ||
| UTF8String.fromString(indexName)))) | ||
| } | ||
|
|
||
| // Scalar segment indexes use the logical segment commit path. | ||
| if (scalarSegmentIndexType.isDefined) { | ||
| val segmentJob = new ScalarSegmentIndexJob( | ||
| this.copy(columns = canonicalColumns), | ||
| readOptions, | ||
| buildReadOptions, | ||
| fragmentWorkloads, | ||
| validatedNumSegments, | ||
| nsImpl, | ||
|
|
@@ -192,9 +191,9 @@ case class AddIndexExec( | |
| initialStorageOpts) | ||
| val segments = segmentJob.run() | ||
| // Atomic add+remove via Lance core; see commitIndexSegments | ||
| commitIndexSegments(readOptions, canonicalColumns.head, segments) | ||
| val indexed = commitIndexSegments(readOptions, canonicalColumns.head, segments) | ||
| return Seq(new GenericInternalRow(Array[Any]( | ||
| fragmentIds.size.toLong, | ||
| indexed.toLong, | ||
| UTF8String.fromString(indexName)))) | ||
| } | ||
|
|
||
|
|
@@ -231,16 +230,31 @@ case class AddIndexExec( | |
| // Lance core's commitExistingIndexSegments handles atomic replacement: | ||
| // it finds existing segments whose fragments overlap with incoming ones | ||
| // and removes them in the same CreateIndex transaction. | ||
| // | ||
| // Returns the number of fragments the commit actually covers, which is what the command reports. | ||
| private def commitIndexSegments( | ||
| readOptions: LanceSparkReadOptions, | ||
| column: String, | ||
| segments: Seq[Index]): Unit = { | ||
| segments: Seq[Index]): Int = { | ||
| val dataset = Utils.openDatasetBuilder(readOptions).build() | ||
| try { | ||
| dataset.commitExistingIndexSegments( | ||
| IndexUtils.requireCommittableCoverage( | ||
| IndexUtils.liveFragmentIds(dataset), | ||
| segments, | ||
| indexName) | ||
| val committed = dataset.commitExistingIndexSegments( | ||
| indexName, | ||
| column, | ||
| segments.toList.asJava) | ||
| // The commit advances this handle to the manifest it wrote, so both the returned metadata and | ||
| // the fragment list below describe the committed state rather than the one validated above. | ||
| IndexUtils | ||
| .establishedCoverage( | ||
| segments, | ||
| committed.asScala.toSeq, | ||
| IndexUtils.liveFragmentIds(dataset), | ||
| indexName) | ||
| .size | ||
| } finally { | ||
| dataset.close() | ||
| } | ||
|
|
@@ -295,6 +309,17 @@ class RangeBasedBTreeIndexJob( | |
|
|
||
| private val VALUE_COLUMN_NAME = "value" | ||
|
|
||
| /** Version `readOptions` is pinned to. Throws if the ref is not a version on main. */ | ||
| private def pinnedVersion: Long = { | ||
| val ref = readOptions.getRef | ||
| if (ref == null || !ref.isMain || !ref.getVersionNumber.isPresent) { | ||
| throw new IllegalStateException( | ||
| "Range-mode BTree builds need read options pinned to a version on main so the scan and the " + | ||
| "segment stamp describe one snapshot; got a ref that names none") | ||
| } | ||
| ref.getVersionNumber.get.longValue() | ||
| } | ||
|
|
||
| def run(): Seq[Index] = { | ||
| if (addIndexExec.columns.size != 1) { | ||
| throw new UnsupportedOperationException( | ||
|
|
@@ -316,9 +341,10 @@ class RangeBasedBTreeIndexJob( | |
| } | ||
| val fullTableName = parts.mkString(".") | ||
|
|
||
| // Read the indexed column with the row id and fragment id metadata columns. | ||
| val fragmentColumn = LanceDataset.FRAGMENT_ID_COLUMN.name | ||
| val df = session.table(fullTableName) | ||
| val df = session.read | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pin now sense here now. Can we add a test for this similar to the gatekeeper repro?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The mechanism is tested by I tried to think of a targeted test here but the false-negative scenario needs a concurrent rewrite landing between the scan and the build, and I don't think I can control that timing from a single CREATE INDEX statement without something flaky. Once #794 lands a coverage assertion might work, but right now I'm not sure what to assert that the mechanism test doesn't already cover. Open to ideas if you see something I missed! |
||
| .option(LanceSparkReadOptions.CONFIG_VERSION, pinnedVersion) | ||
| .table(fullTableName) | ||
| val selectDf = df.select( | ||
| df.col(columns.head).as(VALUE_COLUMN_NAME), | ||
| df.col(LanceDataset.ROW_ID_COLUMN.name), | ||
|
|
@@ -619,6 +645,89 @@ object IndexUtils extends Logging { | |
| .get(method.toLowerCase(Locale.ROOT)) | ||
| .filter(scalarSegmentIndexTypes.contains) | ||
|
|
||
| /** Pins `readOptions` to the version `dataset` is open at. */ | ||
| def pinVersion( | ||
| readOptions: LanceSparkReadOptions, | ||
| dataset: Dataset): LanceSparkReadOptions = | ||
| readOptions.withRef(Utils.pinOpenedRef(dataset, readOptions.getRef)) | ||
|
|
||
| /** | ||
| * Fragment ids and live row counts of `dataset`, in manifest order. | ||
| * | ||
| * Reads the primitive fragment-statistics view rather than [[Dataset#getFragments]], which | ||
| * materializes a Java object per fragment and per data file. Both commands enumerate fragments on | ||
| * the driver, once to plan and once to check the commit, so on a large table that difference is | ||
| * the bulk of planning cost. | ||
| */ | ||
| def fragmentWorkloads(dataset: Dataset): List[FragmentWorkload] = { | ||
| val stats = dataset.getFragmentStatistics | ||
| val ids = stats.getIds | ||
| val rowCounts = stats.getRowCounts | ||
| List.tabulate(ids.length)(index => | ||
| FragmentWorkload(Integer.valueOf(ids(index)), rowCounts(index))) | ||
| } | ||
|
|
||
| /** Fragment ids live in `dataset`. See [[fragmentWorkloads]] for why this avoids getFragments. */ | ||
| def liveFragmentIds(dataset: Dataset): Set[Int] = | ||
| dataset.getFragmentStatistics.getIds.toSet | ||
|
|
||
| /** Fragment ids the given segments declare coverage of. */ | ||
| def declaredCoverage(segments: Seq[Index]): Set[Int] = | ||
| segments.iterator | ||
| .flatMap(_.fragments().orElse(Collections.emptyList[Integer]()).asScala) | ||
| .map(_.intValue) | ||
| .toSet | ||
|
|
||
| /** | ||
| * Refuses a segment set that declares coverage but intersects no live fragment. Partial loss is | ||
| * accepted: fragments can be retired by compaction or deletion during the build, and the segments | ||
| * covering what remains are still correct. [[establishedCoverage]] reports what was actually | ||
| * committed. | ||
| */ | ||
| def requireCommittableCoverage( | ||
| liveFragmentIds: Set[Int], | ||
| segments: Seq[Index], | ||
| indexName: String): Unit = { | ||
| val declared = declaredCoverage(segments) | ||
| if (declared.nonEmpty && declared.intersect(liveFragmentIds).isEmpty) { | ||
| throw new IllegalStateException( | ||
| s"Index '$indexName' build raced a concurrent operation: every fragment it covers " + | ||
| s"(${describeFragmentIds(declared)}) was retired while the build ran, so the segments " + | ||
| "would cover nothing. No index change was committed; re-run the command.") | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: name kind of implies this we can say this builds committed frag ids, and instersected with what is still live or something along those lines. |
||
| * Committed fragment ids from the segments this build produced, intersected with what is still | ||
| * live. Only segments whose UUID matches `builtSegments` are counted; pre-existing survivors are | ||
| * not this command's coverage. | ||
| */ | ||
| def establishedCoverage( | ||
| builtSegments: Seq[Index], | ||
| committedSegments: Seq[Index], | ||
| liveFragmentIds: Set[Int], | ||
| indexName: String): Set[Int] = { | ||
| val builtUuids = builtSegments.map(_.uuid).toSet | ||
| val established = | ||
| declaredCoverage(committedSegments.filter(segment => builtUuids.contains(segment.uuid))) | ||
| .intersect(liveFragmentIds) | ||
| val uncovered = declaredCoverage(builtSegments).diff(established) | ||
| if (uncovered.nonEmpty) { | ||
| logWarning( | ||
| s"Index '$indexName' build raced a concurrent operation: fragments " + | ||
| s"${describeFragmentIds(uncovered)} are not covered by this commit, because they were " + | ||
| "retired or because their indexed field was rewritten while the build ran. The segments " + | ||
| "for the remaining fragments are committed; re-run the command to cover them.") | ||
| } | ||
| established | ||
| } | ||
|
|
||
| private def describeFragmentIds(ids: Set[Int]): String = { | ||
| val ordered = ids.toSeq.sorted | ||
| val shown = ordered.take(10).mkString(", ") | ||
| if (ordered.size > 10) s"$shown, ... (${ordered.size} total)" else shown | ||
| } | ||
|
|
||
| def resolveIndexField( | ||
| schema: LanceSchema, | ||
| indexType: IndexType, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successor to the earlier range-snapshot finding: pinning only the builder to the planning version still permits stale appended-fragment keys to survive core validation.
If planning pins v1, an append creates fragment F at v2, the catalog scan captures F, and then the indexed field of F is rewritten at v3, this line stamps the v2 rows with v1. Lance 11.0.0-beta.10 historical pruning is invoked with prune_historically_missing=false; because F did not exist at v1, it is retained without comparison to v3 and the stale coverage survives. Pin both the producer scan and builder to one snapshot. The final commit may still open the live dataset.
Executed reproducer
A temporary test created an initial fragment and pinned its read options, appended a second fragment, captured range input rows, rewrote the appended fragment, then built with the pinned options:
./mvnw -q test -pl lance-spark-3.5_2.12 -Djava.io.tmpdir=/home/agent/tmp -Dtest=RangeVersionRaceReproducerTest#pinnedBuilderStillTrustsAStaleFragmentMissingAtItsStampedVersion
Result on ff648f4: failed; expected one matching row, observed zero.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with this and still can repro:
session.tableis live. the builder is pinned. that's two snapshots.pin v1, insert fragment F, scan F, rewrite F, build at v1. WHERE id = 1001 returns 0. same split as this job.
can we pin the scan with
VERSION AS OFthe planning version too? commit can stay live.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed. The current head still combines a live Spark scan with a builder pinned to the earlier planning version, so this finding remains. Pinning the scan with VERSION AS OF to that same planning version satisfies the required snapshot contract; the final commit can remain live.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 6f8a5cc: the range producer scan now uses the same planning-version options as the executor builder. The append/rewrite reproducer now excludes the post-planning fragment from segment coverage and the indexed query returns the expected rewritten row; the final commit remains live for coverage reconciliation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, you're right, pinned the scan too. Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 6f8a5cc: confirmed—the producer scan and executor builder remain pinned to the same planning version, so this finding stays addressed.