Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions pkg/ddc/cache/component/advanced_statefulset_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package component
import (
"context"
"fmt"
"reflect"

workloadv1alpha1 "github.com/fluid-cloudnative/advanced-statefulset/api/workload/v1alpha1"
datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1"
Expand All @@ -29,6 +28,7 @@ import (
"github.com/go-logr/logr"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
Expand Down Expand Up @@ -293,8 +293,11 @@ func (s *AdvancedStatefulSetManager) updateResources(asts *workloadv1alpha1.Adva

container := &asts.Spec.Template.Spec.Containers[0]

// Directly compare and replace, nil is also a valid value
if !reflect.DeepEqual(container.Resources, resources) {
// Compare semantically rather than structurally: resource.Quantity caches the
// string it was parsed from, and a quantity produced by arithmetic carries an
// empty cache. reflect.DeepEqual would read that as a change on every reconcile
// even when the value is identical. nil is still a valid desired value.
if !equality.Semantic.DeepEqual(container.Resources, resources) {
logger.Info("resources changed, will update")
container.Resources = *resources.DeepCopy()
return true
Expand Down
51 changes: 51 additions & 0 deletions pkg/ddc/cache/component/sync_component_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package component

import (
"context"
"encoding/json"

workloadv1alpha1 "github.com/fluid-cloudnative/advanced-statefulset/api/workload/v1alpha1"
datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1"
Expand Down Expand Up @@ -509,5 +510,55 @@ var _ = Describe("AdvancedStatefulSetManager SyncComponentSpec", func() {
result := manager.updateResources(emptyAsts, resources, GinkgoLogr)
Expect(result).To(BeFalse())
})

It("should not report a change when an equal quantity was produced by arithmetic", func() {
// A quantity built by adding, as the tiered store quota is, carries no cached
// string, while the one decoded from the workload does. Comparing the two
// structurally reports a difference that is not there, which makes every
// reconcile patch the workload again.
sum := resource.MustParse("4Gi")
sum.Add(resource.MustParse("8Gi"))

decoded := corev1.ResourceRequirements{}
Expect(json.Unmarshal([]byte(`{"limits":{"memory":"12Gi"}}`), &decoded)).To(Succeed())

asts := &workloadv1alpha1.AdvancedStatefulSet{
Spec: workloadv1alpha1.AdvancedStatefulSetSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "test", Resources: decoded}},
},
},
},
}
recomputed := corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: sum},
}

Expect(manager.updateResources(asts, recomputed, GinkgoLogr)).To(BeFalse())
})

It("should still report a change when the quantity really differs", func() {
decoded := corev1.ResourceRequirements{}
Expect(json.Unmarshal([]byte(`{"limits":{"memory":"12Gi"}}`), &decoded)).To(Succeed())

asts := &workloadv1alpha1.AdvancedStatefulSet{
Spec: workloadv1alpha1.AdvancedStatefulSetSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "test", Resources: decoded}},
},
},
},
}
bigger := resource.MustParse("4Gi")
bigger.Add(resource.MustParse("16Gi"))

Expect(manager.updateResources(asts, corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: bigger},
}, GinkgoLogr)).To(BeTrue())
limit := asts.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory]
Expect(limit.String()).To(Equal("20Gi"))
})
})
})
35 changes: 34 additions & 1 deletion pkg/ddc/cache/engine/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
package engine

import (
"context"
"os"
"reflect"
"time"

workloadv1alpha1 "github.com/fluid-cloudnative/advanced-statefulset/api/workload/v1alpha1"
datav1alpha1 "github.com/fluid-cloudnative/fluid/api/v1alpha1"
"github.com/fluid-cloudnative/fluid/pkg/common"
"github.com/fluid-cloudnative/fluid/pkg/ddc/cache/component"
Expand All @@ -29,6 +31,7 @@ import (
"github.com/fluid-cloudnative/fluid/pkg/utils/dataset/lifecycle"
"github.com/fluid-cloudnative/fluid/pkg/utils/kubeclient"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
Expand Down Expand Up @@ -211,9 +214,28 @@ func (e *CacheEngine) syncRuntimeSpec(ctx cruntime.ReconcileRequestContext, runt
Namespace: e.namespace,
}
manager := component.NewComponentHelper(common.ComponentTypeWorker, e.Client)
// Mirror the creation path: the tiered store memory quota is charged on top
// of the resolved baseline, whether that came from the CacheRuntime or from
// the CacheRuntimeClass template. See TransformRuntimeTieredStore.
//
// The quota is read back from the workload rather than recomputed from the
// spec: tieredStore is not a supported update field, and SyncComponentSpec
// only patches resources, so a spec-derived quota would move the container's
// memory while the tmpfs volumes kept their original size.
workerResources := desiredComponentResources(runtime.Spec.Worker.Resources, runtimeClass.Topology.Worker)
if workerResources != nil {
quota, err := e.chargedWorkerTieredStoreMemoryQuota(ctx.Context, workerIdentity)
if err != nil {
e.Log.Error(err, "failed to read the tiered store memory quota charged to the worker",
"component", workerIdentity.Name)
return err
}
resources := withTieredStoreMemoryQuota(*workerResources, quota)
workerResources = &resources
}
workerSpec := component.ComponentSpec{
Version: runtime.Spec.Worker.RuntimeVersion,
Resources: desiredComponentResources(runtime.Spec.Worker.Resources, runtimeClass.Topology.Worker),
Resources: workerResources,
Replicas: &runtime.Spec.Worker.Replicas,
}
if err := manager.SyncComponentSpec(ctx.Context, workerIdentity, workerSpec); err != nil {
Expand Down Expand Up @@ -253,6 +275,17 @@ func desiredComponentResources(runtimeResources corev1.ResourceRequirements, com
return templateResources.DeepCopy()
}

// chargedWorkerTieredStoreMemoryQuota reads back the tiered store memory quota that
// the creation path charged to the worker workload.
func (e *CacheEngine) chargedWorkerTieredStoreMemoryQuota(ctx context.Context, identity *common.ComponentIdentity) (resource.Quantity, error) {
workers := &workloadv1alpha1.AdvancedStatefulSet{}
key := types.NamespacedName{Name: identity.Name, Namespace: identity.Namespace}
if err := e.Get(ctx, key, workers); err != nil {
return resource.Quantity{}, err
}
Comment thread
xliuqq marked this conversation as resolved.
Outdated
return chargedTieredStoreMemoryQuota(&workers.Spec.Template.Spec), nil
}

func (e *CacheEngine) syncDatasetCacheStates(ctx cruntime.ReconcileRequestContext, runtime *datav1alpha1.CacheRuntime, runtimeClass *datav1alpha1.CacheRuntimeClass) (err error) {
cacheStates, err := e.GetCacheStates(runtime, runtimeClass)
if err != nil {
Expand Down
176 changes: 176 additions & 0 deletions pkg/ddc/cache/engine/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,21 @@ var _ = Describe("CacheEngine Sync Tests", Label("pkg.ddc.cache.engine.sync_test
return limit.String()
}

// tmpfsSizeOf reports the size limit of the workload's memory-backed tiered
// store volume, i.e. the quota the container's memory has to cover.
tmpfsSizeOf := func(stsName string) string {
sts := &workloadv1alpha1.AdvancedStatefulSet{}
key := types.NamespacedName{Name: stsName, Namespace: "default"}
Expect(fakeClient.Get(ctx.Context, key, sts)).To(Succeed())
for _, volume := range sts.Spec.Template.Spec.Volumes {
emptyDir := volume.EmptyDir
if emptyDir != nil && emptyDir.Medium == corev1.StorageMediumMemory && emptyDir.SizeLimit != nil {
return emptyDir.SizeLimit.String()
}
}
return "<none>"
}

BeforeEach(func() {
seedTemplateResources(masterSts, &runtimeClass.Topology.Master.Template)
seedTemplateResources(workerSts, &runtimeClass.Topology.Worker.Template)
Expand Down Expand Up @@ -984,5 +999,166 @@ var _ = Describe("CacheEngine Sync Tests", Label("pkg.ddc.cache.engine.sync_test
Expect(memLimitOf(workerSts)).To(Equal("8Gi"))
})
})

Context("when the tiered store baseline comes from the template", func() {
// With the CacheRuntime declaring no resources, the baseline the quota is
// charged on top of is the CacheRuntimeClass template value. The quota must
// still be added, otherwise the worker is short by the quota it was created
// with the moment a sync runs.
BeforeEach(func() {
runtimeObj.Spec.Worker.TieredStore = datav1alpha1.RuntimeTieredStore{
Levels: []datav1alpha1.RuntimeTieredStoreLevel{
{ProcessMemory: &datav1alpha1.ProcessMemoryMediumSource{Quota: resource.MustParse("8Gi")}},
},
}
Expect(fakeClient.Update(ctx.Context, runtimeObj)).To(Succeed())
})

It("should charge the quota on top of the template value", func() {
Expect(runtimeObj.Spec.Worker.Resources.Limits).To(BeNil())
Expect(runtimeObj.Spec.Worker.Resources.Requests).To(BeNil())

// The workload carries the template value plus the quota, as creation left it.
key := types.NamespacedName{Name: workerSts, Namespace: "default"}
sts := &workloadv1alpha1.AdvancedStatefulSet{}
Expect(fakeClient.Get(ctx.Context, key, sts)).To(Succeed())
sts.Spec.Template.Spec.Containers[0].Resources = corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("10Gi")},
}
chargedQuota := resource.MustParse("8Gi")
sts.Spec.Template.Spec.Volumes = []corev1.Volume{{
Name: "tiered-store-level-0-memory",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{
Medium: corev1.StorageMediumMemory,
SizeLimit: &chargedQuota,
},
},
}}
Expect(fakeClient.Update(ctx.Context, sts)).To(Succeed())

syncRuntime, err := engine.getRuntime()
Expect(err).NotTo(HaveOccurred())
Expect(engine.syncRuntimeSpec(ctx, syncRuntime, runtimeClass)).To(Succeed())

// template 2Gi + quota 8Gi, not the bare template value.
Expect(memLimitOf(workerSts)).To(Equal("10Gi"))
})
})

Context("when the CacheRuntime declares a memory tiered store", func() {
// The creation path derives the worker's memory as
// <user baseline> + <tiered store memory quota>. A later sync recomputes
// the desired state from the same spec and must land on the same value,
// otherwise the quota silently disappears on the second reconcile.
BeforeEach(func() {
runtimeObj.Spec.Worker.Resources = corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")},
}
runtimeObj.Spec.Worker.TieredStore = datav1alpha1.RuntimeTieredStore{
Levels: []datav1alpha1.RuntimeTieredStoreLevel{
{ProcessMemory: &datav1alpha1.ProcessMemoryMediumSource{Quota: resource.MustParse("8Gi")}},
},
}
Expect(fakeClient.Update(ctx.Context, runtimeObj)).To(Succeed())
})

It("should keep the tiered store quota when syncing after creation", func() {
// Creation reconcile: derive the desired state and create the workload.
createRuntime, err := engine.getRuntime()
Expect(err).NotTo(HaveOccurred())

// Read the baseline before transforming: the transform must not be
// trusted to leave the runtime spec alone.
baseline := createRuntime.Spec.Worker.Resources.Limits[corev1.ResourceMemory].DeepCopy()

value, err := engine.transform(dataset, createRuntime, runtimeClass)
Expect(err).NotTo(HaveOccurred())
desired := value.Worker.PodTemplateSpec.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory]

// Guard against a vacuous comparison: creation must actually charge the quota.
Expect(desired.Cmp(baseline)).To(Equal(1), "creation path must charge the tiered store quota")

key := types.NamespacedName{Name: workerSts, Namespace: "default"}
seeded := &workloadv1alpha1.AdvancedStatefulSet{}
Expect(fakeClient.Get(ctx.Context, key, seeded)).To(Succeed())
Expect(fakeClient.Delete(ctx.Context, seeded)).To(Succeed())

_, err = engine.SetupWorkerComponent(value.Worker)
Expect(err).NotTo(HaveOccurred())
Expect(memLimitOf(workerSts)).To(Equal(desired.String()))

// Sync reconcile: re-read the runtime the way a fresh reconcile would,
// so the sync can only rely on what is persisted in the spec.
syncRuntime, err := engine.getRuntime()
Expect(err).NotTo(HaveOccurred())
Expect(engine.syncRuntimeSpec(ctx, syncRuntime, runtimeClass)).To(Succeed())

Expect(memLimitOf(workerSts)).To(Equal(desired.String()))
})

// createWorker runs the creation reconcile and returns the memory the
// creation path derives, i.e. baseline + quota.
createWorker := func() string {
createRuntime, err := engine.getRuntime()
Expect(err).NotTo(HaveOccurred())
value, err := engine.transform(dataset, createRuntime, runtimeClass)
Expect(err).NotTo(HaveOccurred())
desired := value.Worker.PodTemplateSpec.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory]

key := types.NamespacedName{Name: workerSts, Namespace: "default"}
seeded := &workloadv1alpha1.AdvancedStatefulSet{}
Expect(fakeClient.Get(ctx.Context, key, seeded)).To(Succeed())
Expect(fakeClient.Delete(ctx.Context, seeded)).To(Succeed())
_, err = engine.SetupWorkerComponent(value.Worker)
Expect(err).NotTo(HaveOccurred())
return desired.String()
}

syncOnce := func() {
syncRuntime, err := engine.getRuntime()
Expect(err).NotTo(HaveOccurred())
Expect(engine.syncRuntimeSpec(ctx, syncRuntime, runtimeClass)).To(Succeed())
}

It("should not let an edited tiered store quota half-apply", func() {
// tieredStore is not a supported update field, and SyncComponentSpec only
// patches resources. A spec-derived quota would move the container's memory
// to baseline+16Gi while the tmpfs volume stayed at the 8Gi it was created
// with.
desired := createWorker()
Expect(tmpfsSizeOf(workerSts)).To(Equal("8Gi"))

edited, err := engine.getRuntime()
Expect(err).NotTo(HaveOccurred())
edited.Spec.Worker.TieredStore.Levels[0].ProcessMemory.Quota = resource.MustParse("16Gi")
Expect(fakeClient.Update(ctx.Context, edited)).To(Succeed())

syncOnce()

Expect(memLimitOf(workerSts)).To(Equal(desired))
Expect(tmpfsSizeOf(workerSts)).To(Equal("8Gi"))
})

It("should recharge a quota that an earlier release stripped", func() {
// Workloads created before this fix have the bare baseline on the container
// and the full quota on the tmpfs volume. Recovering the quota from the
// volume rather than the container is what lets them be repaired in place.
desired := createWorker()

key := types.NamespacedName{Name: workerSts, Namespace: "default"}
stripped := &workloadv1alpha1.AdvancedStatefulSet{}
Expect(fakeClient.Get(ctx.Context, key, stripped)).To(Succeed())
stripped.Spec.Template.Spec.Containers[0].Resources = corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi")},
}
Expect(fakeClient.Update(ctx.Context, stripped)).To(Succeed())
Expect(memLimitOf(workerSts)).To(Equal("4Gi"))

syncOnce()

Expect(memLimitOf(workerSts)).To(Equal(desired))
})
})
})
})
Loading
Loading