@@ -61,6 +61,7 @@ const (
6161 readyConditionReasonProcessing = "Processing"
6262 readyConditionReasonReady = "Ready"
6363 readyConditionReasonError = "Error"
64+ readyConditionReasonTimeout = "Timeout"
6465 readyConditionReasonDeletionPending = "DeletionPending"
6566 readyConditionReasonDeletionBlocked = "DeletionBlocked"
6667 readyConditionReasonDeletionProcessing = "DeletionProcessing"
@@ -169,11 +170,7 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
169170 }
170171 component .GetObjectKind ().SetGroupVersionKind (r .groupVersionKind )
171172
172- // convenience accessors
173- status := component .GetStatus ()
174- savedStatus := status .DeepCopy ()
175-
176- // requeue/retry interval
173+ // fetch requeue interval, retry interval and timeout
177174 requeueInterval := time .Duration (0 )
178175 if requeueConfiguration , ok := assertRequeueConfiguration (component ); ok {
179176 requeueInterval = requeueConfiguration .GetRequeueInterval ()
@@ -188,6 +185,17 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
188185 if retryInterval == 0 {
189186 retryInterval = requeueInterval
190187 }
188+ timeout := time .Duration (0 )
189+ if timeoutConfiguration , ok := assertTimeoutConfiguration (component ); ok {
190+ timeout = timeoutConfiguration .GetTimeout ()
191+ }
192+ if timeout == 0 {
193+ timeout = requeueInterval
194+ }
195+
196+ // convenience accessors
197+ status := component .GetStatus ()
198+ savedStatus := status .DeepCopy ()
191199
192200 // always attempt to update the status
193201 skipStatusUpdate := false
@@ -197,11 +205,27 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
197205 // re-panic in order skip the remaining steps
198206 panic (r )
199207 }
208+
209+ status .ObservedGeneration = component .GetGeneration ()
210+
200211 if status .State == StateReady || err != nil {
212+ // clear backoff if state is ready (obviously) or if there is an error;
213+ // even is the error is a RetriableError which will be turned into a non-error;
214+ // this is correct, because in that case, the RequeueAfter will be determined through the RetriableError
201215 r .backoff .Forget (req )
202216 }
203- status .ObservedGeneration = component .GetGeneration ()
217+ if status .State != StateProcessing || err != nil {
218+ // clear ProcessingDigest and ProcessingSince in all non-error cases where state is StateProcessing
219+ status .ProcessingDigest = ""
220+ status .ProcessingSince = nil
221+ }
222+ if status .State == StateProcessing && now .Sub (status .ProcessingSince .Time ) >= timeout {
223+ // TODO: maybe it would be better to have a dedicated StateTimeout?
224+ status .SetState (StateError , readyConditionReasonTimeout , "Reconcilation of dependent resources timed out" )
225+ }
226+
204227 if err != nil {
228+ // convert retriable errors into non-errors (Pending or DeletionPending state), and return specified or default backoff
205229 retriableError := & types.RetriableError {}
206230 if errors .As (err , retriableError ) {
207231 retryAfter := retriableError .RetryAfter ()
@@ -220,10 +244,12 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
220244 status .SetState (StateError , readyConditionReasonError , err .Error ())
221245 }
222246 }
247+
223248 if result .RequeueAfter > 0 {
224249 // add jitter of 1-5 percent to RequeueAfter
225250 addJitter (& result .RequeueAfter , 1 , 5 )
226251 }
252+
227253 log .V (1 ).Info ("reconcile done" , "withError" , err != nil , "requeue" , result .Requeue || result .RequeueAfter > 0 , "requeueAfter" , result .RequeueAfter .String ())
228254 if err != nil {
229255 if status , ok := err .(apierrors.APIStatus ); ok || errors .As (err , & status ) {
@@ -232,22 +258,34 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
232258 metrics .ReconcileErrors .WithLabelValues (r .controllerName , "other" ).Inc ()
233259 }
234260 }
235- // TODO: should we move this behind the DeepEqual check below?
236- // note: it seems that no events will be written if the component's namespace is in deletion
261+
262+ // TODO: should we move this behind the DeepEqual check below to avoid noise?
263+ // also note: it seems that no events will be written if the component's namespace is in deletion
237264 state , reason , message := status .GetState ()
238265 if state == StateError {
239266 r .client .EventRecorder ().Event (component , corev1 .EventTypeWarning , reason , message )
240267 } else {
241268 r .client .EventRecorder ().Event (component , corev1 .EventTypeNormal , reason , message )
242269 }
270+
243271 if skipStatusUpdate {
244272 return
245273 }
246274 if reflect .DeepEqual (status , savedStatus ) {
247275 return
248276 }
249- // note: it's crucial to set the following timestamp late (otherwise the DeepEqual() check before would always be false)
277+
278+ // note: it's crucial to set the following timestamps late (otherwise the DeepEqual() check above would always be false)
279+ // on the other hand it's a bit weird, because LastObservedAt will not be updated if no other changes have happened to the status;
280+ // and same for the conditions' LastTransitionTime timestamps;
281+ // maybe we should remove this optimization, and always do the Update() call
250282 status .LastObservedAt = & now
283+ for i := 0 ; i < len (status .Conditions ); i ++ {
284+ cond := & status .Conditions [i ]
285+ if savedCond := savedStatus .getCondition (cond .Type ); savedCond == nil || cond .Status != savedCond .Status {
286+ cond .LastTransitionTime = & now
287+ }
288+ }
251289 if updateErr := r .client .Status ().Update (ctx , component , client .FieldOwner (r .name )); updateErr != nil {
252290 err = utilerrors .NewAggregate ([]error {err , updateErr })
253291 result = ctrl.Result {}
@@ -256,7 +294,7 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
256294
257295 // set a first status (and requeue, because the status update itself will not trigger another reconciliation because of the event filter set)
258296 if status .ObservedGeneration <= 0 {
259- status .SetState (StateProcessing , readyConditionReasonNew , "First seen" )
297+ status .SetState (StatePending , readyConditionReasonNew , "First seen" )
260298 return ctrl.Result {Requeue : true }, nil
261299 }
262300
@@ -301,7 +339,8 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
301339 return ctrl.Result {}, errors .Wrap (err , "error adding finalizer" )
302340 }
303341 // trigger another round trip
304- // this is necessary because the update call invalidates potential changes done by the post-read hook above
342+ // this is necessary because the update call invalidates potential changes done to the component by the post-read
343+ // hook above; this means, not to the object itself, but for example to loaded secrets or config maps;
305344 // in the following round trip, the finalizer will already be there, and the update will not happen again
306345 return ctrl.Result {Requeue : true }, nil
307346 }
@@ -312,7 +351,7 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
312351 return ctrl.Result {}, errors .Wrapf (err , "error running pre-reconcile hook (%d)" , hookOrder )
313352 }
314353 }
315- ok , err := target .Apply (ctx , component )
354+ ok , digest , err := target .Apply (ctx , component )
316355 if err != nil {
317356 log .V (1 ).Info ("error while reconciling dependent resources" )
318357 return ctrl.Result {}, errors .Wrap (err , "error reconciling dependent resources" )
@@ -324,16 +363,21 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
324363 }
325364 }
326365 log .V (1 ).Info ("all dependent resources successfully reconciled" )
327- status .SetState (StateReady , readyConditionReasonReady , "Dependent resources successfully reconciled" )
328366 status .AppliedGeneration = component .GetGeneration ()
329367 status .LastAppliedAt = & now
368+ status .SetState (StateReady , readyConditionReasonReady , "Dependent resources successfully reconciled" )
330369 return ctrl.Result {RequeueAfter : requeueInterval }, nil
331370 } else {
332371 log .V (1 ).Info ("not all dependent resources successfully reconciled" )
333- status .SetState (StateProcessing , readyConditionReasonProcessing , "Reconcilation of dependent resources triggered; waiting until all dependent resources are ready" )
372+ if digest != status .ProcessingDigest {
373+ status .ProcessingDigest = digest
374+ status .ProcessingSince = & now
375+ r .backoff .Forget (req )
376+ }
334377 if ! reflect .DeepEqual (status .Inventory , savedStatus .Inventory ) {
335378 r .backoff .Forget (req )
336379 }
380+ status .SetState (StateProcessing , readyConditionReasonProcessing , "Reconcilation of dependent resources triggered; waiting until all dependent resources are ready" )
337381 return ctrl.Result {RequeueAfter : r .backoff .Next (req , readyConditionReasonProcessing )}, nil
338382 }
339383 } else {
@@ -352,16 +396,16 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
352396 log .V (1 ).Info ("deletion not allowed" )
353397 // TODO: have an additional StateDeletionBlocked?
354398 // TODO: eliminate this msg logic
355- status .SetState (StateDeleting , readyConditionReasonDeletionBlocked , "Deletion blocked: " + msg )
356399 r .client .EventRecorder ().Event (component , corev1 .EventTypeNormal , readyConditionReasonDeletionBlocked , "Deletion blocked: " + msg )
400+ status .SetState (StateDeleting , readyConditionReasonDeletionBlocked , "Deletion blocked: " + msg )
357401 return ctrl.Result {RequeueAfter : 1 * time .Second + r .backoff .Next (req , readyConditionReasonDeletionBlocked )}, nil
358402 }
359403 if len (slices .Remove (component .GetFinalizers (), r .name )) > 0 {
360404 // deletion is blocked because of foreign finalizers
361405 log .V (1 ).Info ("deleted blocked due to existence of foreign finalizers" )
362406 // TODO: have an additional StateDeletionBlocked?
363- status .SetState (StateDeleting , readyConditionReasonDeletionBlocked , "Deletion blocked due to existing foreign finalizers" )
364407 r .client .EventRecorder ().Event (component , corev1 .EventTypeNormal , readyConditionReasonDeletionBlocked , "Deletion blocked due to existing foreign finalizers" )
408+ status .SetState (StateDeleting , readyConditionReasonDeletionBlocked , "Deletion blocked due to existing foreign finalizers" )
365409 return ctrl.Result {RequeueAfter : 1 * time .Second + r .backoff .Next (req , readyConditionReasonDeletionBlocked )}, nil
366410 }
367411 // deletion case
@@ -392,10 +436,10 @@ func (r *Reconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result
392436 } else {
393437 // deletion triggered for dependent resources, but some are not yet gone
394438 log .V (1 ).Info ("not all dependent resources are successfully deleted" )
395- status .SetState (StateDeleting , readyConditionReasonDeletionProcessing , "Deletion of dependent resources triggered; waiting until dependent resources are deleted" )
396439 if ! reflect .DeepEqual (status .Inventory , savedStatus .Inventory ) {
397440 r .backoff .Forget (req )
398441 }
442+ status .SetState (StateDeleting , readyConditionReasonDeletionProcessing , "Deletion of dependent resources triggered; waiting until dependent resources are deleted" )
399443 return ctrl.Result {RequeueAfter : r .backoff .Next (req , readyConditionReasonDeletionProcessing )}, nil
400444 }
401445 }
0 commit comments