Skip to content

fix(dataset): recreate the ThinRuntime of a reference dataset stuck in NotBound - #6138

Merged
RongGu merged 1 commit into
fluid-cloudnative:masterfrom
cheyang:fix/ref-dataset-stuck-notbound
Jul 29, 2026
Merged

fix(dataset): recreate the ThinRuntime of a reference dataset stuck in NotBound#6138
RongGu merged 1 commit into
fluid-cloudnative:masterfrom
cheyang:fix/ref-dataset-stuck-notbound

Conversation

@cheyang

@cheyang cheyang commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6136

The bug

A reference Dataset (spec.mounts[0].mountPoint: dataset://<ns>/<name>) can stay in NotBound forever, with no ThinRuntime in the namespace and no error surfaced anywhere. Observed on a live 3-node Kubernetes v1.36 cluster running Fluid from master; kubectl annotate dataset <ref-dataset> probe=1 --overwrite recreated the runtime and the Dataset became Bound within seconds, i.e. the state was wedged, not slow. Full timeline and logs in #6136.

Root cause and fix

1. A terminating ThinRuntime was treated as an existing oneCreateRuntimeForReferenceDatasetIfNotExist (pkg/utils/dataset_runtime.go)

GetThinRuntime is a plain Get, so it also returns objects that already carry a deletionTimestamp — e.g. a leftover runtime whose controller had been scaled to 0 and therefore could not remove its finalizer. The helper concluded "the runtime already exists", tried to adopt the dying object by overwriting its ownerReferences, and returned success. Moments later the object really disappeared and nothing had been created.

It now reports a terminating runtime as an error. The error is deliberately not a conflict error, so the surrounding retry.RetryOnConflict does not swallow it and the caller (reconcileDataset step 3 → utils.RequeueIfError) requeues until the object is gone and a fresh runtime can be created. The dying object is neither adopted nor mutated.

2. Nothing re-triggered the Dataset once the runtime vanishedpkg/controllers/v1alpha1/dataset/dataset_controller.go

SetupWithManager only registered For(&Dataset{}), and reconcileDataset ends with NoRequeue(), so the deletion of the auto-created ThinRuntime produced neither an event nor a resync. The controller now Owns(&datav1alpha1.ThinRuntime{}).

The owner-based watch resolves a dependent through the kind and apiVersion of its ownerReference. Both were read from the Dataset's TypeMeta, which a typed client may hand back empty, so they now fall back to the well-known values of the Dataset CRD.

RBAC: no file changes needed. charts/fluid/fluid/templates/role/dataset/rbac.yaml already grants the dataset-controller get;list;watch;create;update;patch;delete on thinruntimes, and the generated config/rbac/role.yaml is byte-identical before/after the added kubebuilder marker (verified with the repo's pinned controller-gen v0.19.0), because thinruntimes already carries that verb set from other controllers' markers.

Tests

  • pkg/utils/dataset_runtime_test.go: new ThinRuntimeTerminating case (deletionTimestamp + finalizer so the fake client keeps the object) asserting the helper errors out and leaves the terminating object untouched, ownerReferences still empty.
  • pkg/controllers/v1alpha1/dataset/dataset_reconciler_test.go: two specs — reconcile returns an error mentioning terminating and does not adopt the dying runtime; and the created ThinRuntime carries a resolvable controller ownerReference that Owns() can map back to the Dataset.
$ go test -gcflags="all=-N -l" -count=1 ./pkg/controllers/v1alpha1/dataset/...
ok      github.com/fluid-cloudnative/fluid/pkg/controllers/v1alpha1/dataset     1.342s

$ go test -gcflags="all=-N -l" -count=1 -run TestCreateRuntimeForReferenceDatasetIfNotExist ./pkg/utils/
ok      github.com/fluid-cloudnative/fluid/pkg/utils                            1.307s

(These packages need the repo's LOCAL_FLAGS — without -gcflags="all=-N -l" the dataset package hits a pre-existing gomonkey SIGBUS on darwin/arm64.)

One pre-existing test flake, diagnosed and fixed here

While verifying, the pre-existing spec "should requeue after the resync period when runtime scaleout returns an error" failed in ~1% of runs (3/248 with this change; 0/248 on the same commit without it — it is layout-sensitive, not logic-sensitive: that spec does not touch the reference-dataset path at all).

The spec reset the patch installed by BeforeEach and applied a second gomonkey patch to the same function. An isolated probe of exactly that sequence shows gomonkey leaves the first patch in effect in 2217 out of 20000 iterations, in which case the spec observes a successful scaleout and hence no requeue — precisely the observed failure (RequeueAfter == 0). Note this direction matters: if the new patch had merely been skipped and the real function run, the fake client would have produced an error and the spec would have passed.

The spec now switches behaviour through a variable that the single BeforeEach patch delegates to, so the function is patched exactly once. 500 consecutive runs green (previously 3 failures per ~250 runs with the same binary shape).

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.09%. Comparing base (44cfae9) to head (47077ff).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #6138   +/-   ##
=======================================
  Coverage   65.08%   65.09%           
=======================================
  Files         485      485           
  Lines       33989    34000   +11     
=======================================
+ Hits        22122    22132   +10     
- Misses      10126    10127    +1     
  Partials     1741     1741           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a wedge where reference Datasets could remain NotBound indefinitely when a leftover/terminating ThinRuntime was mistakenly treated as “existing”, combined with the dataset controller not watching ThinRuntime deletions to trigger a re-reconcile.

Changes:

  • Treats a terminating ThinRuntime (has deletionTimestamp) as an error in CreateRuntimeForReferenceDatasetIfNotExist, so the dataset reconcile will retry after the object is truly gone (instead of adopting/mutating a dying object and returning success).
  • Updates the dataset controller to Owns(&ThinRuntime{}) so changes/deletions of the auto-created runtime re-trigger reconciliation of the owning Dataset.
  • Adds targeted tests for the terminating-runtime path and for ensuring the created ThinRuntime has a resolvable controller OwnerReference; also fixes a pre-existing gomonkey patch flake by patching once and switching behavior via a variable.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
pkg/utils/dataset_runtime.go Adds terminating-runtime detection and centralizes correct Dataset controller OwnerReference construction (with Kind/APIVersion fallback).
pkg/utils/dataset_runtime_test.go Adds a ThinRuntimeTerminating test case and verifies the terminating object is not adopted/mutated.
pkg/controllers/v1alpha1/dataset/dataset_reconciler_test.go Adds coverage that the created ThinRuntime has a watch-resolvable controller ownerRef and that terminating runtimes cause reconcile to error without adoption.
pkg/controllers/v1alpha1/dataset/dataset_controller.go Watches owned ThinRuntime objects and adds the corresponding kubebuilder RBAC marker.
pkg/controllers/v1alpha1/dataset/dataset_controller_ut_test.go Removes flaky double-patching by delegating the patched function’s return to a per-test variable.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cheyang cheyang left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid fix. The two-part root cause (adopting a terminating runtime + no watch on ThinRuntime deletion) is well-diagnosed, and the repair is minimal and targeted. The TypeMeta fallback in datasetControllerOwnerReference is a nice catch — without it the new Owns() watch would silently fail to map events back to the Dataset when the typed client hands back empty GVK.

Two observations, neither blocking:

  1. pkg/utils/transformer/owner_reference.go (GenerateOwnerReferenceFromObject) has the same empty-TypeMeta vulnerability. If any of its callers rely on Owns()-style watches, they'd hit the same silent mapping failure. Worth a follow-up audit.

  2. The error string uses thinRuntime (lowercase t) while the API kind is ThinRuntime. Trivial, but consistent casing helps grep-ability in logs.

The gomonkey flake fix is well-justified by the 20k-iteration data. Nice cleanup.

…n NotBound

A reference dataset (spec.mounts[0].mountPoint: dataset://<ns>/<name>) could stay
in NotBound forever, without any ThinRuntime and without any error being surfaced.
Two independent gaps combined to wedge it:

1. CreateRuntimeForReferenceDatasetIfNotExist treated a terminating ThinRuntime as
   an existing one. GetThinRuntime is a plain Get, so it also returns objects which
   already have a deletionTimestamp, e.g. a leftover runtime whose controller was
   scaled to 0 and could not remove its finalizer. The helper concluded that the
   runtime already exists, tried to adopt the dying object by overwriting its
   ownerReferences and returned success, so no runtime was ever created once the
   object finally disappeared. A runtime which is being deleted is now reported as
   an error (which is not a conflict error, so it is not swallowed by the
   surrounding retry.RetryOnConflict), letting the caller requeue until the object
   is really gone and a fresh runtime can be created.

2. The dataset controller did not watch the ThinRuntime it creates, so nothing
   re-triggered the dataset after the runtime vanished. The controller now Owns()
   the ThinRuntime.

The owner based watch resolves a dependent through the kind and apiVersion of its
ownerReference. Both were read from the dataset's TypeMeta, which a typed client
may hand back empty, so they now fall back to the well-known values of the Dataset
CRD.

The chart role for the dataset controller already allows watching thinruntimes and
the generated config/rbac/role.yaml is unchanged by the added kubebuilder marker.

While verifying the change, the pre-existing spec "should requeue after the resync
period when runtime scaleout returns an error" turned out to be flaky (~1% of runs,
depending on binary layout): it reset the patch installed by BeforeEach and applied
a second patch on the very same function, and gomonkey leaves the first patch in
effect in a measurable share of such reset-then-reapply sequences (2217 out of
20000 iterations of an isolated probe). The spec then observed a successful scaleout
and no requeue. It now switches behaviour through a variable which the single patch
delegates to, instead of patching twice; 500 consecutive runs are green.

Fixes fluid-cloudnative#6136

Signed-off-by: cheyang <cheyang@163.com>
@cheyang
cheyang force-pushed the fix/ref-dataset-stuck-notbound branch from 36852b2 to 47077ff Compare July 27, 2026 14:04
@sonarqubecloud

Copy link
Copy Markdown

@cheyang

cheyang commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — pushed 47077ff addressing (2), and here is the audit for (1).

(2) casing: the error string now says ThinRuntime, matching the API kind.

(1) GenerateOwnerReferenceFromObject: audited — 10 non-test callers, all in pkg/ddc/* (transform.go, load_data.go, transform_pvc_mounts.go, plus the CacheRuntime engine), and they all feed the ownerReference into helm chart values rather than setting it directly on an object. Two of those chains do end up under an Owns() watch: DataLoad/DataMigrate render a batchv1.Job whose ownerReference comes from this helper, and dataload_controller.go / datamigrate_controller.go register Owns(&batchv1.Job{}). So the concern is well-placed in principle.

In practice those flows work today, which is itself evidence that the objects those controllers read do carry a populated TypeMeta — consistent with what I observed on a live cluster for the auto-created ThinRuntime, whose ownerReference is complete:

ownerReferences: [{"apiVersion":"data.fluid.io/v1alpha1","controller":true,"kind":"Dataset","name":"curvine-demo-ref","uid":"717ce1a1-..."}]

Worth noting for the record: with an empty Kind/APIVersion the failure would not be silent in those chains — the API server rejects an ownerReference without kind/apiVersion, so the rendered Job/StatefulSet would fail to create outright. The silent-mapping failure mode only applies where such a reference somehow gets persisted.

So I'd treat the same fallback inside GenerateOwnerReferenceFromObject as cheap hardening rather than a bug fix. Happy to open a follow-up issue (or a small PR) for it — it is deliberately out of scope here to keep this change reviewable.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

pkg/utils/dataset_runtime_test.go:194

  • These assertions run after the table loop and depend on shared fake-client state accumulated across prior subtests, which makes the test order-dependent and harder to reason about (and can hide failures if earlier cases unintentionally mutate shared objects). Prefer moving this verification into the ThinRuntimeTerminating subtest and/or building a fresh fake client per tt so each case is isolated.

	// The terminating runtime must be left untouched, especially it must not be adopted by the dataset.
	terminatingRuntime, err := GetThinRuntime(fakeClient, "ThinRuntimeTerminating", "default")
	if err != nil {
		t.Fatalf("failed to get the terminating thinRuntime: %v", err)
	}
	if !HasDeletionTimestamp(terminatingRuntime.ObjectMeta) {
		t.Errorf("expected the thinRuntime ThinRuntimeTerminating to be still terminating")
	}
	if len(terminatingRuntime.GetOwnerReferences()) != 0 {
		t.Errorf("expected no ownerReference set on the terminating thinRuntime, but got %v", terminatingRuntime.GetOwnerReferences())
	}

pkg/utils/dataset_runtime.go:57

  • The helper derives Kind from the object’s GVK but derives APIVersion from dataset.APIVersion (TypeMeta). This can still produce an empty/mismatched APIVersion even when the GVK is set (e.g., Kind is present but TypeMeta fields are not). To ensure the ownerReference is consistently resolvable by Owns(), derive both Kind and APIVersion from the same GroupVersionKind() when available (and only fall back to CRD constants if the GVK is empty).
	kind := dataset.GetObjectKind().GroupVersionKind().Kind
	if len(kind) == 0 {
		kind = datav1alpha1.Datasetkind
	}
	apiVersion := dataset.APIVersion
	if len(apiVersion) == 0 {
		apiVersion = datav1alpha1.GroupVersion.String()
	}

pkg/utils/dataset_runtime.go:65

  • For controller ownerReferences, it’s generally safer to construct them via the standard helpers (e.g., metav1.NewControllerRef or controllerutil.SetControllerReference) so fields like BlockOwnerDeletion are set consistently and you match Kubernetes/controller-runtime conventions. This reduces subtle GC/deletion-behavior differences and avoids manually maintaining ownerRef semantics.
	return metav1.OwnerReference{
		Kind:       kind,
		APIVersion: apiVersion,
		Name:       dataset.GetName(),
		UID:        dataset.GetUID(),
		Controller: ptr.To(true),
	}

pkg/utils/dataset_runtime_test.go:111

  • Change 'can not' to 'cannot' in the comment.
			// A leftover runtime which is stuck in Terminating, e.g. because its controller is
			// scaled to 0 and can not remove the finalizer. The finalizer is also required to keep
			// the object in the fake client's tracker once a deletionTimestamp is set.

@xliuqq xliuqq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm

@RongGu RongGu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm
/approve

@RongGu
RongGu merged commit f7067fd into fluid-cloudnative:master Jul 29, 2026
23 of 24 checks passed
}

runtimeToUpdate := runtime.DeepCopy()
runtimeToUpdate.SetOwnerReferences([]metav1.OwnerReference{

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetOwnerReferences overwrites the slice rather than merging into it, so any owner reference already on the runtime is dropped when the reference dataset adopts it. I checked this with a unit test on this branch: seeding the ThinRuntime with an unrelated example.com/v1 SomeOtherKind owner and calling this function leaves exactly [{data.fluid.io/v1alpha1 Dataset ...}] — the foreign owner is gone.

The behaviour predates this PR, but the new Owns(&ThinRuntime{}) watch makes ownerReferences load-bearing for reconcile, so it seems worth tightening while you are here. Upserting the Dataset controller reference into the existing slice — replacing only a stale Dataset entry whose UID differs — would also cover the delete-and-recreate-same-name case, which currently leaves a stale-UID owner in place.

The existing ThinRuntimeExists fixture carries a single Dataset owner, so nothing covers the extra-owner shape today.

datasetControllerOwnerReference(dataset),
},
Labels: map[string]string{
common.LabelAnnotationDatasetId: GetDatasetId(dataset.GetNamespace(), dataset.GetName(), string(dataset.GetUID())),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common.LabelAnnotationDatasetId is applied here on the create branch but not on the adopt branch above, so a ThinRuntime that predates the label — or any runtime that goes through adoption — never gets it. Verified on this branch: adopting a runtime with no labels leaves labels = map[], with the key wholly absent.

The label is read in a few places (pkg/utils/label.go, pkg/ddc/thin/referencedataset/volume.go, pkg/utils/kubeclient/configmap.go), so the gap is not cosmetic. Setting it next to the ownerReference in the adopt branch would make the two paths converge.

Separately, and out of scope for this PR: GetDatasetId only substitutes the UID once the <ns>-<name> string reaches 63 characters, so for ordinary short names the label carries no UID at all and is not unique across a delete/recreate.

// 1.1 The runtime is being deleted, it can neither be adopted nor be re-created for now.
// Return an error (not a conflict error, so retry.RetryOnConflict won't swallow it) to
// let the caller requeue until the terminating runtime is really gone.
if HasDeletionTimestamp(runtime.ObjectMeta) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning an error here makes CreateRuntimeForReferenceDatasetIfNotExist fail, and the caller in dataset_controller.go requeues before it reaches the phase update. So while the old ThinRuntime is still terminating, a reference dataset in NoneDatasetPhase stays at "" and never advances to NotBound. I reproduced this on this branch, and the control case without a terminating runtime does reach NotBound, which isolates the behaviour to this guard.

Given the PR is about a reference dataset stuck in NotBound, I wanted to confirm this is the intended trade-off — fail loudly and back off — rather than surfacing NotBound while the recreate is pending. Either reading seems defensible, I would just like it to be a deliberate choice.

// Kind and APIVersion come from the dataset's TypeMeta, and fall back to the well-known values of the Dataset
// CRD when it is empty, which a typed client may hand back depending on how the object was read. The owner
// based watch of the dataset controller resolves a dependent through those two fields, so they must be set.
func datasetControllerOwnerReference(dataset *datav1alpha1.Dataset) metav1.OwnerReference {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#6139 adds the same kind/apiVersion fallback to pkg/utils/transformer/owner_reference.go via a scheme lookup. Once both land there are two independent implementations of "recover the owner GVK" with different semantics: this one fills the two fields independently, while the transformer gates the whole recovery on gvk.Empty() and therefore misses partially populated TypeMeta.

Worth converging on one. The per-field approach here is the more robust of the two, so folding it into the shared helper and having this call site delegate would remove the duplication without losing coverage. Not something to fix in this PR — I will open a follow-up issue.

cheyang added a commit to cheyang/fluid that referenced this pull request Jul 29, 2026
…Meta is empty

GenerateOwnerReferenceFromObject read the kind and the apiVersion straight from the
object's TypeMeta, which a typed client is allowed to hand back empty. The resulting
reference carries an empty kind and "/" as its apiVersion, which the API server
rejects, and which an owner based watch can not resolve back to its owner. The unit
tests of the DataLoad value transformers encoded exactly that broken output as their
expectation.

Fall back to the GroupVersionKind known by the fluid scheme when the TypeMeta of the
object is empty, and fix the expectations of the affected tests. Objects which do
carry a TypeMeta are unaffected, so this only changes references which the API server
would have rejected anyway.

Two of the chains fed by this helper end up under an owner based watch: DataLoad and
DataMigrate render a batchv1.Job whose ownerReference comes from here, and both
controllers register Owns(&batchv1.Job{}).

Reported by @cheyang while reviewing fluid-cloudnative#6138.

Signed-off-by: cheyang <cheyang@163.com>
cheyang added a commit to cheyang/fluid that referenced this pull request Jul 29, 2026
…Meta is incomplete

GenerateOwnerReferenceFromObject read the kind and the apiVersion straight from the
object's TypeMeta, which a typed client is allowed to hand back empty. The resulting
reference carries an empty kind and "/" as its apiVersion, which the API server
rejects, and which an owner based watch cannot resolve back to its owner. The unit
tests of the DataLoad value transformers encoded exactly that broken output as their
expectation.

Recover the missing fields from the GroupVersionKind known by the fluid scheme. The
kind and the apiVersion fall back on their own rather than only when the whole
TypeMeta is empty, because a partially populated TypeMeta (only the kind, or only the
apiVersion, as objects decoded from user YAML can carry) produces a reference which is
just as malformed, and because that matches the per-field fallback used for the Dataset
owner in fluid-cloudnative#6138. A type which is missing from the scheme is logged, since the function
has no error return and the incomplete reference would be invisible otherwise.

Objects which do carry a complete TypeMeta are unaffected, so this only changes
references which the API server would have rejected anyway. The expectations of the
affected tests are fixed accordingly.

Two of the chains fed by this helper end up under an owner based watch: DataLoad and
DataMigrate render a batchv1.Job whose ownerReference comes from here, and both
controllers register Owns(&batchv1.Job{}).

Reported by @cheyang while reviewing fluid-cloudnative#6138.

Signed-off-by: cheyang <cheyang@163.com>
RongGu pushed a commit that referenced this pull request Jul 29, 2026
…Meta is incomplete (#6139)

GenerateOwnerReferenceFromObject read the kind and the apiVersion straight from the
object's TypeMeta, which a typed client is allowed to hand back empty. The resulting
reference carries an empty kind and "/" as its apiVersion, which the API server
rejects, and which an owner based watch cannot resolve back to its owner. The unit
tests of the DataLoad value transformers encoded exactly that broken output as their
expectation.

Recover the missing fields from the GroupVersionKind known by the fluid scheme. The
kind and the apiVersion fall back on their own rather than only when the whole
TypeMeta is empty, because a partially populated TypeMeta (only the kind, or only the
apiVersion, as objects decoded from user YAML can carry) produces a reference which is
just as malformed, and because that matches the per-field fallback used for the Dataset
owner in #6138. A type which is missing from the scheme is logged, since the function
has no error return and the incomplete reference would be invisible otherwise.

Objects which do carry a complete TypeMeta are unaffected, so this only changes
references which the API server would have rejected anyway. The expectations of the
affected tests are fixed accordingly.

Two of the chains fed by this helper end up under an owner based watch: DataLoad and
DataMigrate render a batchv1.Job whose ownerReference comes from here, and both
controllers register Owns(&batchv1.Job{}).

Reported by @cheyang while reviewing #6138.

Signed-off-by: cheyang <cheyang@163.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reference Dataset can get stuck in NotBound forever when a terminating ThinRuntime is treated as existing

4 participants