design/one-pager-per-resource-poll-interval.md
The --poll-interval flag controls how often Crossplane reconciles resources,
but it applies globally to all resources managed by a controller. This creates a
dilemma for operators:
There is no middle ground. All resources share the same reconciliation frequency regardless of how often they actually change. This is especially painful when a single provider manages both stable long-lived resources and dynamic ones against APIs with strict rate limits.
Additionally, there is no way to trigger an immediate reconciliation of a specific resource without modifying its spec. Operators sometimes need to force a re-sync after an out-of-band change or to verify drift correction, without waiting for the next poll cycle.
See issue #7204 for the original proposal.
Introduce two annotations, inspired by similar patterns in Flux CD, that give operators fine-grained control over reconciliation behavior on a per-resource basis.
crossplane.io/poll-intervalOverrides the controller-level --poll-interval for a specific resource.
apiVersion: postgresql.sql.crossplane.io/v1alpha1
kind: Database
metadata:
name: my-database
annotations:
crossplane.io/poll-interval: "24h"
Behavior:
30m, 1h, 24h).--min-poll-interval flag (defaults to
1s) to prevent tight reconciliation loops. This is similar to how
--max-function-cache-ttl lets operators cap the function cache TTL.--min-poll-interval are clamped to the configured minimum.--poll-interval applies as today.crossplane.io/reconcile-requested-atTriggers an immediate reconciliation when its value changes. This follows the
pattern established by Flux CD's reconcile.fluxcd.io/requestedAt.
apiVersion: postgresql.sql.crossplane.io/v1alpha1
kind: Database
metadata:
name: my-database
annotations:
crossplane.io/reconcile-requested-at: "2024-01-15T10:30:00Z"
Behavior:
status.lastHandledReconcileAt so operators can confirm the
request was processed.ReconcileRequestHandled) is emitted when the token is
processed.Confirming processing:
# Request reconciliation
kubectl annotate database my-database \
crossplane.io/reconcile-requested-at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--overwrite
# Verify it was handled
kubectl get database my-database -o jsonpath='{.status.lastHandledReconcileAt}'
The original issue ([#7204]) describes the managed resource (MR) case, but this feature affects two distinct reconciliation paths that are owned by different repositories:
| Composite Resources (XRs) | Managed Resources (MRs) | |
|---|---|---|
| Reconciled by | Crossplane core | Providers (via crossplane-runtime) |
| Code location | internal/controller/apiextensions/composite/ | crossplane-runtime/pkg/reconciler/managed/ |
| Poll purpose | Re-sync composed resources, detect drift | Detect out-of-band changes to external resources |
| Who benefits | Platform teams managing compositions | End users managing cloud resources |
Both paths use the same poll-and-requeue pattern and both benefit from per-resource control. The annotation contract is identical for both — the difference is purely in where the reconciler code lives.
Annotation constants and parsing helpers live in crossplane-runtime's pkg/meta
package so they are shared by both reconcilers:
GetPollInterval(obj) (time.Duration, bool) — parses the annotation and
returns the duration and whether a valid interval was present.GetReconcileRequest(obj) (string, bool) — reads the reconcile-requested-at
token.SetReconcileRequest(obj, token) — sets the token (for programmatic use).XR reconciliation lives in Crossplane core at
internal/controller/apiextensions/composite/reconciler.go. The changes are:
effectivePollInterval(xr) method checks the annotation on the composite
resource and falls back to the controller default if absent or invalid.RequeueAfter uses effectivePollInterval(xr)
instead of the hardcoded r.pollInterval.Reconcile()
method. When a new token is detected, it is recorded in
status.lastHandledReconcileAt and an event is emitted.MR reconciliation lives in crossplane-runtime and is
consumed by every provider. The managed reconciler receives the same
effectivePollInterval and reconcile-request token tracking as the XR
reconciler. Because the managed reconciler lives in crossplane-runtime, providers
inherit this behavior when they upgrade their crossplane-runtime dependency
without any code changes.
The effective poll interval for a resource is determined by:
crossplane.io/poll-interval) if present and valid.--poll-interval) as the default.The ObservedStatus type in apis/core/v2/observation.go gains a new field:
type ObservedStatus struct {
// ...existing fields...
// LastHandledReconcileAt holds the value of the
// reconcile-requested-at annotation from the most recent
// reconciliation that was triggered by a reconcile request.
LastHandledReconcileAt string `json:"lastHandledReconcileAt,omitempty"`
}
A spec.pollInterval field was considered but rejected because: