Actor Flavors#
Flavors are named, reusable building blocks that platform engineers pre-create and data scientists (or any actor author) reference by name. A flavor bundles infrastructure configuration — compute resources, scaling policy, tolerations — into a single label-addressed unit that gets merged into an actor's spec at deploy time.
The intent is a clean division of responsibility:
- Platform engineers define what "GPU workload", "high-throughput scaler", or "S3-persisted actor" means once, in a controlled place.
- Actor authors say
flavors: [gpu-standard]and get the right infrastructure without touching Helm charts or cloud-provider details.
The problem flavors solve#
Without flavors, every actor needs to repeat the same boilerplate: resource requests and limits, scaling thresholds, GPU tolerations and node selectors. When platform requirements change — say, the GPU node pool gets a new taint — every actor manifest needs updating.
Flavors centralise that boilerplate. The platform team updates one
EnvironmentConfig; all actors referencing it pick up the change on the next
reconciliation cycle.
How flavors work#
A flavor is a Kubernetes EnvironmentConfig (a Crossplane cluster-scoped
resource) whose resource name matches the flavor name. Its data field
contains a partial AsyncActor spec — only the fields the flavor wants to
provide. By convention, flavors also carry the label asya.sh/flavor: <name>
for discoverability (kubectl get environmentconfigs -l asya.sh/flavor).
When Crossplane reconciles an AsyncActor that lists flavors, the
function-asya-flavors composition function runs a two-phase resolution:
Phase 1 — request: The function reads spec.flavors from the actor and tells
Crossplane to fetch the EnvironmentConfig resource that matches each flavor
name. Crossplane fetches them and calls the function again with the results.
Phase 2 — merge: The function merges all flavor data using type-aware rules, then applies the actor's inline spec as the final override:
- Start with an empty spec.
- For each flavor (in
spec.flavorsorder), merge its data using the rules described in Merge semantics. - Apply the actor's own inline spec fields last — the actor always wins silently (no error for actor-vs-flavor overlap).
The merged result is written directly to the desired XR's spec. Downstream
composition steps (render-deployment, render-scaledobject) read from that
desired spec — which, after this function runs, reflects the fully resolved
configuration.
Merge semantics#
Merge behavior is determined by Go runtime type dispatch — no per-field configuration. The rules apply recursively for nested maps:
| Go type | Behavior | Fields |
|---|---|---|
| Lists | Append across flavors | env, tolerations, volumes, volumeMounts, stateProxy, secretRefs |
| Maps/structs | Merge keys recursively; same leaf key in two flavors = error | nodeSelector, scaling, resources, sidecar, resiliency |
| Scalars | Error if two flavors both set the field | image, handler, replicas, imagePullPolicy, pythonExecutable |
Lists are always appended. Two flavors that both provide tolerations
entries get all entries combined. There is no replace or name-merge — the full
lists are concatenated.
Maps merge recursively. One flavor can set resources.limits.cpu and another
can set resources.limits.memory — distinct leaf keys at any nesting depth are
merged. Only the same leaf key in two flavors triggers an error.
Scalars conflict. If two flavors both set image, the merge fails with an
error naming both flavors. Use a single flavor for scalar fields, or let the
actor's inline spec override.
Type mismatches are errors. If one flavor defines a field as a list and another defines it as a scalar, the merge fails.
Conflict errors include flavor names and full key path:
flavor merge conflict: flavors "gpu-a100" and "high-throughput" conflict on scaling.minReplicaCount
What fields flavors can provide#
| Field | Type | Merge behavior |
|---|---|---|
image |
scalar | error if two flavors set it |
handler |
scalar | error if two flavors set it |
imagePullPolicy |
scalar | error if two flavors set it |
pythonExecutable |
scalar | error if two flavors set it |
replicas |
scalar | error if two flavors set it |
resources |
map | merge keys recursively |
env |
list | append across flavors |
tolerations |
list | append across flavors |
nodeSelector |
map | merge keys; same key = error |
volumes |
list | append across flavors |
volumeMounts |
list | append across flavors |
scaling |
map | merge keys recursively |
resiliency |
map | merge keys recursively |
sidecar |
map | merge keys recursively |
stateProxy |
list | append across flavors |
secretRefs |
list | append across flavors |
Infrastructure fields (actor, transport, flavors) are excluded from the
merge — they cannot be set by flavors.
Creating a flavor (platform engineer)#
A flavor is a plain EnvironmentConfig manifest. The resource name must match
the flavor name (the function fetches by name). By convention, add the
asya.sh/flavor: <name> label for discoverability. The data field is shaped
like a partial AsyncActor spec.
Example: compute profile for GPU inference#
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
metadata:
name: gpu-standard
labels:
asya.sh/flavor: gpu-standard
data:
scaling:
minReplicaCount: 1
maxReplicaCount: 4
queueLength: 1
resources:
requests:
cpu: 2
memory: 8Gi
nvidia.com/gpu: "1"
limits:
nvidia.com/gpu: "1"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
nodeSelector:
accelerator: nvidia-t4
Example: spot instance tolerations#
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
metadata:
name: spot-tolerant
labels:
asya.sh/flavor: spot-tolerant
data:
tolerations:
- key: cloud.google.com/gke-spot
operator: Equal
value: "true"
effect: NoSchedule
Combining gpu-standard and spot-tolerant on the same actor appends both
tolerations — the pod tolerates both GPU taints and spot instance taints.
Example: S3 persistence flavor#
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
metadata:
name: s3-checkpoints
labels:
asya.sh/flavor: s3-checkpoints
data:
stateProxy:
- name: checkpoints
mount:
path: /state/checkpoints
connector:
image: ghcr.io/deliveryhero/asya-state-proxy-s3-buffered-lww:v1.0.0
env:
- name: STATE_BUCKET
value: my-checkpoints-bucket
- name: AWS_REGION
value: eu-west-1
Multiple state flavors compose: s3-checkpoints + redis-cache would append
both stateProxy entries.
Using flavors (actor author)#
Add spec.flavors to an AsyncActor. The list is ordered: flavors are applied
left-to-right. Any inline spec fields you write in the actor manifest override
flavor values silently.
Example: GPU inference actor#
apiVersion: asya.sh/v1alpha1
kind: AsyncActor
metadata:
name: embedding-service
namespace: ml-platform
spec:
actor: embedding-service
flavors: [gpu-standard]
image: my-org/embedding-service:latest
handler: embeddings.handler
env:
- name: MODEL_NAME
value: text-embedding-ada-002
The actor defines its image and handler. The gpu-standard flavor provides
resources, tolerations, node selectors, and scaling — none of which the actor
author needs to know about.
Example: combining composable flavors#
Flavors compose when they contribute to different fields or different keys within the same field:
spec:
flavors: [gpu-standard, spot-tolerant, s3-checkpoints]
image: my-org/batch-inference:latest
handler: inference.handle
gpu-standardprovides resources, scaling, GPU tolerations, and node selectorspot-tolerantappends a spot toleration (lists append)s3-checkpointsappends a stateProxy entry (lists append)
No conflicts — each flavor contributes to different fields or appends to lists.
Example: overriding a flavor value#
A flavor provides defaults; the actor can always override them inline:
spec:
flavors: [gpu-standard]
# Override just the scaling — everything else comes from the flavor
scaling:
maxReplicaCount: 2
The gpu-standard flavor might set maxReplicaCount: 4. Writing
scaling: {maxReplicaCount: 2} in the actor's inline spec replaces the entire
scaling field from the flavor. The actor always wins.
What causes a conflict#
Two flavors conflict when they both set the same leaf key. Examples:
# flavor-a # flavor-b
data: data:
scaling: scaling:
minReplicaCount: 1 minReplicaCount: 5
This errors: flavors "flavor-a" and "flavor-b" conflict on scaling.minReplicaCount.
To fix: consolidate the conflicting field into a single flavor, or have the actor set it inline (actor always wins over all flavors).
Not a conflict — distinct leaf keys merge:
# flavor-a # flavor-b
data: data:
resources: resources:
limits: limits:
cpu: "500m" memory: "4Gi"
This merges: resources.limits gets both cpu and memory.
Constraints#
- Maximum 8 flavors per actor.
- Flavor names must be at least 3 characters.
- Flavors are cluster-scoped resources. The same
EnvironmentConfigis shared across all namespaces — a single platform-level flavor serves all tenants. - If a referenced flavor does not exist (no matching
EnvironmentConfigwith the correct label), Crossplane will keep the actor in aWaitingstate and logWaiting for flavor EnvironmentConfigs. The actor will not be deployed until all listed flavors are available.
Debugging#
Check the AsyncActor status conditions to see whether flavor resolution
succeeded:
kubectl describe asyncactor <name> -n <namespace>
Look for a condition message from the resolve-flavors step. If flavors are
missing, it shows Waiting for N flavor EnvironmentConfigs. If flavors
conflict, the Synced condition will be False with an error message naming
the conflicting flavors and key path.
Verify the EnvironmentConfig exists and carries the correct label:
kubectl get environmentconfigs -l asya.sh/flavor=<name>
To inspect what the resolved spec looks like after merging, check the Crossplane function logs:
kubectl logs -n crossplane-system \
-l pkg.crossplane.io/revision \
--all-containers=true | grep "Flavors applied"
Platform setup: To create and manage flavor resources, see setup/guide-actor-flavors.md.