Skip to content

Commit b7f6f99

Browse files
committed
HYPERFLEET-706 - fix: lastUpdateTime for Ready
1 parent 89c8376 commit b7f6f99

6 files changed

Lines changed: 302 additions & 16 deletions

File tree

docs/api-resources.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ The status object contains synthesized conditions computed from adapter reports:
456456
- All above fields plus:
457457
- `observed_generation` - Generation this condition reflects
458458
- `created_time` - When condition was first created (API-managed)
459-
- `last_updated_time` - When adapter last reported (API-managed, from AdapterStatus.last_report_time)
459+
- `last_updated_time` - When this condition was last refreshed (API-managed). For **Available**, always the evaluation time. For **Ready**: when Ready=True, the minimum of `last_report_time` across all required adapters that report Available=True at the current generation; when Ready=False, the evaluation time (so consumers can detect staleness).
460460
- `last_transition_time` - When status last changed (API-managed)
461461

462462
## Parameter Restrictions

pkg/services/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func NewClusterService(dao, adapterStatusDao, config) ClusterService
2525
- **Available**: True if all required adapters report `Available=True` (any generation)
2626
- **Ready**: True if all adapters report `Available=True` AND `observed_generation` matches current generation
2727

28+
Ready's `LastUpdatedTime` is computed in `status_aggregation.computeReadyLastUpdated`: when Ready=False it is the evaluation time (so Sentinel can apply a freshness threshold); when Ready=True it is the minimum of `LastReportTime` across required adapters that have Available=True at the current generation.
29+
2830
`ProcessAdapterStatus()` validates mandatory conditions (`Available`, `Applied`, `Health`) before persisting. Rejects `Available=Unknown` on subsequent reports (only allowed on first report).
2931

3032
## GenericService

pkg/services/cluster_test.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,9 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
767767
StatusConditions: initialConditionsJSON,
768768
}
769769
cluster.ID = clusterID
770+
beforeCreate := time.Now()
770771
created, svcErr := service.Create(ctx, cluster)
772+
afterCreate := time.Now()
771773
Expect(svcErr).To(BeNil())
772774

773775
var createdConds []api.ResourceCondition
@@ -787,12 +789,18 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
787789
Expect(createdReady).ToNot(BeNil())
788790
Expect(createdAvailable.CreatedTime).To(Equal(fixedNow))
789791
Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow))
790-
Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow))
792+
// Available.LastUpdatedTime is refreshed to the evaluation time; assert it lies in the Create() window.
793+
Expect(createdAvailable.LastUpdatedTime).To(BeTemporally(">=", beforeCreate))
794+
Expect(createdAvailable.LastUpdatedTime).To(BeTemporally("<=", afterCreate))
791795
Expect(createdReady.CreatedTime).To(Equal(fixedNow))
792796
Expect(createdReady.LastTransitionTime).To(Equal(fixedNow))
793-
Expect(createdReady.LastUpdatedTime).To(Equal(fixedNow))
797+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false; assert it lies in the Create() window.
798+
Expect(createdReady.LastUpdatedTime).To(BeTemporally(">=", beforeCreate))
799+
Expect(createdReady.LastUpdatedTime).To(BeTemporally("<=", afterCreate))
794800

801+
beforeUpdate := time.Now()
795802
updated, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID)
803+
afterUpdate := time.Now()
796804
Expect(err).To(BeNil())
797805

798806
var updatedConds []api.ResourceCondition
@@ -812,10 +820,14 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
812820
Expect(updatedReady).ToNot(BeNil())
813821
Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow))
814822
Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow))
815-
Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow))
823+
// Available.LastUpdatedTime is refreshed to the evaluation time; assert it lies in the UpdateClusterStatusFromAdapters() window.
824+
Expect(updatedAvailable.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
825+
Expect(updatedAvailable.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
816826
Expect(updatedReady.CreatedTime).To(Equal(fixedNow))
817827
Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow))
818-
Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow))
828+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false; assert it lies in the UpdateClusterStatusFromAdapters() window.
829+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
830+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
819831
}
820832

821833
// TestProcessAdapterStatus_MissingMandatoryCondition_Available tests that updates missing Available are rejected

pkg/services/node_pool_test.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,9 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
627627
StatusConditions: initialConditionsJSON,
628628
}
629629
nodePool.ID = nodePoolID
630+
beforeCreate := time.Now()
630631
created, svcErr := service.Create(ctx, nodePool)
632+
afterCreate := time.Now()
631633
Expect(svcErr).To(BeNil())
632634

633635
var createdConds []api.ResourceCondition
@@ -647,12 +649,18 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
647649
Expect(createdReady).ToNot(BeNil())
648650
Expect(createdAvailable.CreatedTime).To(Equal(fixedNow))
649651
Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow))
650-
Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow))
652+
// Available.LastUpdatedTime is refreshed to the evaluation time; assert it lies in the Create() window.
653+
Expect(createdAvailable.LastUpdatedTime).To(BeTemporally(">=", beforeCreate))
654+
Expect(createdAvailable.LastUpdatedTime).To(BeTemporally("<=", afterCreate))
651655
Expect(createdReady.CreatedTime).To(Equal(fixedNow))
652656
Expect(createdReady.LastTransitionTime).To(Equal(fixedNow))
653-
Expect(createdReady.LastUpdatedTime).To(Equal(fixedNow))
657+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false; assert it lies in the Create() window.
658+
Expect(createdReady.LastUpdatedTime).To(BeTemporally(">=", beforeCreate))
659+
Expect(createdReady.LastUpdatedTime).To(BeTemporally("<=", afterCreate))
654660

661+
beforeUpdate := time.Now()
655662
updated, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID)
663+
afterUpdate := time.Now()
656664
Expect(err).To(BeNil())
657665

658666
var updatedConds []api.ResourceCondition
@@ -672,8 +680,12 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
672680
Expect(updatedReady).ToNot(BeNil())
673681
Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow))
674682
Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow))
675-
Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow))
683+
// Available.LastUpdatedTime is refreshed to the evaluation time; assert it lies in the UpdateNodePoolStatusFromAdapters() window.
684+
Expect(updatedAvailable.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
685+
Expect(updatedAvailable.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
676686
Expect(updatedReady.CreatedTime).To(Equal(fixedNow))
677687
Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow))
678-
Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow))
688+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false; assert it lies in the UpdateNodePoolStatusFromAdapters() window.
689+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
690+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
679691
}

pkg/services/status_aggregation.go

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,79 @@ func ComputeReadyCondition(
234234
return numReady == numRequired
235235
}
236236

237+
// findAdapterStatus returns the first adapter status in the list with the given adapter name, or (nil, false).
238+
func findAdapterStatus(adapterStatuses api.AdapterStatusList, adapterName string) (*api.AdapterStatus, bool) {
239+
for _, s := range adapterStatuses {
240+
if s.Adapter == adapterName {
241+
return s, true
242+
}
243+
}
244+
return nil, false
245+
}
246+
247+
// adapterConditionsHasAvailableTrue returns true if the adapter conditions JSON
248+
// contains a condition with type Available and status True.
249+
func adapterConditionsHasAvailableTrue(conditions []byte) bool {
250+
if len(conditions) == 0 {
251+
return false
252+
}
253+
var conds []struct {
254+
Type string `json:"type"`
255+
Status string `json:"status"`
256+
}
257+
if err := json.Unmarshal(conditions, &conds); err != nil {
258+
return false
259+
}
260+
for _, c := range conds {
261+
if c.Type == api.ConditionTypeAvailable && c.Status == "True" {
262+
return true
263+
}
264+
}
265+
return false
266+
}
267+
268+
// computeReadyLastUpdated returns the timestamp to use for the Ready condition's LastUpdatedTime.
269+
// When isReady is false, it returns now (Ready=False changes frequently; 10s threshold applies).
270+
// When isReady is true, it returns the minimum LastReportTime across all required adapters
271+
// that have Available=True at the current generation. Falls back to now if no timestamps found.
272+
func computeReadyLastUpdated(
273+
adapterStatuses api.AdapterStatusList,
274+
requiredAdapters []string,
275+
resourceGeneration int32,
276+
now time.Time,
277+
isReady bool,
278+
) time.Time {
279+
if !isReady {
280+
return now
281+
}
282+
283+
var minTime *time.Time
284+
for _, adapterName := range requiredAdapters {
285+
status, ok := findAdapterStatus(adapterStatuses, adapterName)
286+
if !ok {
287+
return now // safety: required adapter missing
288+
}
289+
if status.LastReportTime == nil {
290+
return now // safety: no timestamp
291+
}
292+
if status.ObservedGeneration != resourceGeneration {
293+
continue // not at current gen, skip
294+
}
295+
if !adapterConditionsHasAvailableTrue(status.Conditions) {
296+
continue
297+
}
298+
if minTime == nil || status.LastReportTime.Before(*minTime) {
299+
t := *status.LastReportTime
300+
minTime = &t
301+
}
302+
}
303+
304+
if minTime == nil {
305+
return now // safety fallback
306+
}
307+
return *minTime
308+
}
309+
237310
func BuildSyntheticConditions(
238311
existingConditionsJSON []byte,
239312
adapterStatuses api.AdapterStatusList,
@@ -271,7 +344,7 @@ func BuildSyntheticConditions(
271344
CreatedTime: now,
272345
LastUpdatedTime: now,
273346
}
274-
preserveSyntheticCondition(&availableCondition, existingAvailable, now)
347+
applyConditionHistory(&availableCondition, existingAvailable, now, now)
275348

276349
isReady := ComputeReadyCondition(adapterStatuses, requiredAdapters, resourceGeneration)
277350
readyStatus := api.ConditionFalse
@@ -286,13 +359,25 @@ func BuildSyntheticConditions(
286359
CreatedTime: now,
287360
LastUpdatedTime: now,
288361
}
289-
preserveSyntheticCondition(&readyCondition, existingReady, now)
362+
readyLastUpdated := computeReadyLastUpdated(
363+
adapterStatuses, requiredAdapters, resourceGeneration, now, isReady,
364+
)
365+
applyConditionHistory(&readyCondition, existingReady, now, readyLastUpdated)
290366

291367
return availableCondition, readyCondition
292368
}
293369

294-
func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.ResourceCondition, now time.Time) {
370+
// applyConditionHistory copies stable timestamps and metadata from an existing condition.
371+
// lastUpdatedTime is used unconditionally for LastUpdatedTime — the caller is responsible
372+
// for computing the correct value (e.g. now, computeReadyLastUpdated(...)).
373+
func applyConditionHistory(
374+
target *api.ResourceCondition,
375+
existing *api.ResourceCondition,
376+
now time.Time,
377+
lastUpdatedTime time.Time,
378+
) {
295379
if existing == nil {
380+
target.LastUpdatedTime = lastUpdatedTime
296381
return
297382
}
298383

@@ -303,9 +388,7 @@ func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.Res
303388
if !existing.LastTransitionTime.IsZero() {
304389
target.LastTransitionTime = existing.LastTransitionTime
305390
}
306-
if !existing.LastUpdatedTime.IsZero() {
307-
target.LastUpdatedTime = existing.LastUpdatedTime
308-
}
391+
target.LastUpdatedTime = lastUpdatedTime
309392
if target.Reason == nil && existing.Reason != nil {
310393
target.Reason = existing.Reason
311394
}
@@ -319,5 +402,5 @@ func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.Res
319402
target.CreatedTime = existing.CreatedTime
320403
}
321404
target.LastTransitionTime = now
322-
target.LastUpdatedTime = now
405+
target.LastUpdatedTime = lastUpdatedTime
323406
}

0 commit comments

Comments
 (0)