Async Federated Learning with Tekton — Architecture
Overview
This document describes an async federated learning system built on Tekton and an OCI-compatible
model registry. It borrows the client and strategy interface style from Flower but does not modify
or depend on the Flower codebase. Orchestration is fully event-driven and coordinator-free: OCI
registry webhooks trigger Tekton PipelineRuns, each PipelineRun reads a per-participant metadata
store to decide whether work should proceed, and all state transitions are driven by OCI artifact
pushes. There is no always-on coordinator process.
Each collaborator runs in its own independent Kubernetes cluster. The OCI registry is the sole
cross-cluster communication channel — no k8s API access crosses cluster boundaries. Each cluster
manages its own local metadata (Kubernetes ConfigMaps) and its own Tekton EventListener scoped to
events relevant to that participant.
The design is informed by the PAPAYA async FL system (arxiv.org/abs/2111.04877): no round
barriers, staleness-discounted aggregation, hybrid time/quantity triggering, max staleness
cutoff, and differential privacy at the aggregator.
Background: Flower's Federated Learning Model
The interface contracts in this system are modelled directly on Flower's design. Understanding the
original is useful context.
Flower Core Abstractions
Client — what each collaborator implements:
| Method |
Purpose |
get_parameters() |
Return current local model weights |
fit(ins) |
Train on local data, return updated weights + num_examples + metrics |
evaluate(ins) |
Evaluate model on local data, return loss + num_examples + metrics |
Strategy — what the aggregator implements:
| Method |
Purpose |
initialize_parameters() |
Provide initial global model |
configure_fit() |
Select clients and send training instructions |
aggregate_fit() |
Merge client updates into new global model |
configure_evaluate() |
Select clients for evaluation |
aggregate_evaluate() |
Aggregate evaluation results |
evaluate() |
Optional centralized server-side evaluation |
Flower's synchronous round barrier (what we are replacing):
for each round:
1. configure_fit() → select clients, send global params
2. fit_clients() → ThreadPoolExecutor, wait for ALL to respond
3. aggregate_fit() → merge updates
4. evaluate_round() → optional distributed evaluation
All existing Flower strategies are synchronous — every round waits for a quorum before
aggregating. The FaultTolerantFedAvg and round_timeout give straggler tolerance but
the round barrier remains.
Why Not Use Flower's Grid API
Flower's Grid API (push_messages / pull_messages) does support async patterns via
polling and message TTLs, but it requires the ServerApp and SuperNode processes to be
always-on and communicates via gRPC through the SuperLink. For a Kubernetes-native,
Tekton-based system we want ephemeral compute (PipelineRuns), not long-running processes,
and object-store-driven coordination rather than gRPC message routing.
System Design Goals
- No modifications to Flower — borrow the interface contract, not the code
- No API tie-in — OCI Distribution Spec (not S3) as the storage API standard
- True async, no rounds — each collaborator runs independently; the model version
counter is the only clock (PAPAYA-style)
- No coordinator process — state lives in cluster-local ConfigMaps and OCI; decisions
are made inside PipelineRuns via when expressions
- Multi-cluster isolation — each collaborator runs in its own k8s cluster; the OCI
registry is the only shared infrastructure; no cross-cluster k8s API access
- Ephemeral compute — Tekton PipelineRuns, not always-on processes
- Kubernetes-native — GPU scheduling, resource limits, RBAC via standard k8s primitives
- Idempotent by design — every PipelineRun checks metadata before acting; duplicate
triggers are harmless no-ops
No Rounds — PAPAYA's Core Principle
In synchronous FL, a "round" is a hard barrier: all clients train on model vN, submit,
server aggregates, everyone moves to vN+1 together. Round number and model version are
the same concept.
In this system (following PAPAYA), the global model version is a monotonic counter that
increments every time aggregation fires. At any moment:
hospital-a might be training on v7
hospital-b might still be training on v4 (slow, started earlier)
hospital-c might have submitted v7 and already started on v8
These are not "different rounds" — they are clients operating at their own pace. When
aggregation fires using {hospital-a, hospital-c} updates:
hospital-a and hospital-c are idle — give them new work on v8 immediately
hospital-b is in-flight on v4 — leave it alone; its stale update will be
staleness-discounted when it eventually submits
The per-collaborator metadata store makes this explicit and prevents duplicate launches.
Multi-Cluster Topology
┌─────────────────────────────────────────────────────────────────────────────┐
│ OCI Registry (Quay / Harbor / ghcr.io) │
│ — the only shared infrastructure across all clusters — │
│ │
│ fl-global vN global model weights │
│ fl-updates {client}-vN collaborator model updates │
│ fl-client-metrics {client}-vN per-run training metrics (JSON) │
│ fl-aggregation-triggers vN threshold-met marker │
│ fl-train-signals vN-{client} per-collaborator re-launch signal │
│ fl-time-checks latest CronJob heartbeat for time trigger │
└──────────────────────────────────────────────────────────────────────────────┘
▲ ▼ push/pull (oras) ▲ ▼ push/pull (oras)
│ webhooks to each cluster │ webhooks to aggregator cluster
│ │
┌────────┴──────────────────┐ ┌──────────┴───────────────────────────────┐
│ Hospital-A Cluster │ │ Aggregator Cluster │
│ │ │ │
│ EventListener │ │ EventListener │
│ Trigger C: │ │ Trigger A: fl-updates push │
│ fl-train-signals │ │ → AggregationCheckPipelineRun │
│ *-hospital-a │ │ Trigger B: fl-aggregation-triggers │
│ → TrainingPipelineRun │ │ → AggregationPipelineRun │
│ │ │ Trigger D: fl-time-checks │
│ ConfigMap: │ │ → AggregationCheckPipelineRun │
│ fl-meta-hospital-a │ │ │
│ (cluster-local) │ │ ConfigMap: │
│ │ │ fl-meta-aggregator │
│ TrainingPipelineRun │ │ (cluster-local) │
│ reads/writes own │ │ │
│ ConfigMap only │ │ AggregationCheckPipelineRun │
│ │ │ AggregationPipelineRun │
│ GPU nodes, local data │ │ reads/writes own ConfigMap only │
└───────────────────────────┘ └───────────────────────────────────────────┘
┌───────────────────────────┐
│ Hospital-B Cluster │ (identical structure to Hospital-A)
│ Hospital-C Cluster │
│ ... │
└───────────────────────────┘
The critical constraint: no k8s API call crosses a cluster boundary. The aggregator
never patches a collaborator's ConfigMap. A collaborator never creates a PipelineRun in
another cluster. All cross-cluster communication is OCI push events.
Metadata Store (Per Participant, Cluster-Local)
Mutable coordination state lives in a Kubernetes ConfigMap per participant, local to
that participant's cluster. OCI artifacts are immutable content-addressed storage with no
atomic read-modify-write semantics. ConfigMaps are k8s-native, and their resourceVersion
field provides optimistic concurrency — two simultaneous writers, one gets a 409 Conflict
and retries.
Because ConfigMaps are cluster-local, each cluster manages its own state independently.
The aggregator never needs to read or write a collaborator's ConfigMap, and vice versa.
Per-collaborator ConfigMap (lives in collaborator cluster)
apiVersion: v1
kind: ConfigMap
metadata:
name: fl-meta-hospital-a
namespace: fl
data:
in_flight: "false"
last_trained_version: "7"
last_submitted_version: "7"
Aggregator ConfigMap (lives in aggregator cluster)
apiVersion: v1
kind: ConfigMap
metadata:
name: fl-meta-aggregator
namespace: fl
data:
current_global_version: "8"
aggregation_in_progress: "false"
aggregation_threshold: "3"
max_staleness: "10"
OCI Registry as Model Store and Cross-Cluster Bus
Why OCI over S3/MinIO
| Property |
OCI Distribution Spec |
S3 API |
| Standardization |
IANA/ISO open standard |
Amazon's API (de-facto standard) |
| Vendor portability |
Quay, Harbor, ghcr.io, ECR, GCR — identical client code |
MinIO, S3, GCS compat — mostly compatible |
| Versioning |
Tags + SHA256 content digests, first-class |
Object keys + ETags, manual |
| Integrity verification |
Automatic digest verification on push/pull |
Manual checksumming |
| Metadata |
Manifest annotations — no sidecar files |
Object metadata or sidecar files |
| Notifications |
Repository push webhooks |
Object-level PUT events |
| Multi-cluster |
Single registry serves all clusters; webhooks fan out to each |
Same, but S3 event routing is less flexible |
Repository Structure
quay.io/myorg/
├── fl-global ← aggregator writes; tags: v0, v1, v2 ...
├── fl-updates ← all collaborators write; tags: {client-id}-v{N}
│ e.g. hospital-a-v7, hospital-b-v4, hospital-c-v7
├── fl-client-metrics ← collaborators write per-run metrics; tags: {client-id}-v{N}
│ small JSON artifact, one per training run
├── fl-aggregation-triggers← aggregator writes when threshold met; tags: v{N}
├── fl-train-signals ← aggregator writes after aggregation; tags: v{N}-{client-id}
└── fl-time-checks ← CronJob writes for hybrid time trigger; tags: latest
Manifest Annotations
Training metadata lives on the fl-updates OCI manifest — no sidecar files:
oras_client.push(
files=["model_weights.npz"],
target="quay.io/myorg/fl-updates:hospital-a-v7",
manifest_annotations={
"fl.base-version": "7",
"fl.num-examples": "4821",
"fl.client-id": "hospital-a",
"fl.timestamp": "2026-03-14T10:22:00Z",
}
)
Per-Run Metrics Artifact
Training metrics from client.fit() are pushed as a separate small JSON artifact to
fl-client-metrics. This is the cross-cluster-safe metrics store: the aggregator can
pull it during aggregation without any network path beyond the OCI registry.
oras_client.push(
files=["metrics.json"], # {"train_loss": 0.234, "train_accuracy": 0.891, ...}
target="quay.io/myorg/fl-client-metrics:hospital-a-v7",
artifact_type="application/vnd.fl.client-metrics",
manifest_annotations={
"fl.client-id": "hospital-a",
"fl.base-version": "7",
"fl.num-examples": "4821",
"fl.timestamp": "2026-03-14T10:22:00Z",
}
)
The run identity is (client_id, base_version) — the same tag used for the model update.
No separate run ID system required. The aggregation pipeline can optionally pull these
metrics artifacts alongside model updates to compute weighted aggregate metrics
(e.g. weighted average training loss across contributors).
OCI Layering in the MVP
Full models are pushed as a single blob layer per artifact. OCI layer deduplication
only helps when layers are byte-for-byte identical across pushes. Since gradient descent
updates all weights each round, no round-over-round savings apply to fully trainable models.
OCI layering becomes meaningful post-MVP:
- Frozen backbone: frozen layer hash never changes, never re-uploaded after first push
- Delta compression: sparse per-block layers deduplicate unchanged blocks
For the MVP: single blob, no layer decomposition.
High-Level Event Flow
┌──────────────────────────────────────────────────────────────────────────────┐
│ OCI Registry — sends webhooks to registered endpoint per repository │
└──┬──────────────────────────────────────┬──────────────────────────────────┘
│ fl-updates push │ fl-train-signals push
│ fl-aggregation-triggers push │ (one per contributing client)
│ fl-time-checks push │
▼ ▼
Aggregator Cluster EventListener Hospital-A Cluster EventListener
Trigger A → AggCheckPipelineRun Trigger C (CEL: *-hospital-a only)
Trigger B → AggPipelineRun → TrainingPipelineRun
Trigger D → AggCheckPipelineRun
│ │
│ (all within aggregator │ (all within hospital-a
│ cluster only) │ cluster only)
▼ ▼
AggCheckPipelineRun TrainingPipelineRun
reads fl-meta-aggregator reads fl-meta-hospital-a
counts fl-updates tags when(should_train):
when(threshold_met): ClaimTraining
ClaimAggregation Train
PushAggTrigger →OCI pushes fl-updates:hospital-a-vN
│ pushes fl-client-metrics:hospital-a-vN
▼ ReleaseMetadata (finally)
AggPipelineRun
Aggregate
pushes fl-global:vN+1
ReleaseAndSignal:
patches fl-meta-aggregator (local)
pushes fl-train-signals:vN+1-hospital-a → OCI
pushes fl-train-signals:vN+1-hospital-c → OCI
(hospital-b not in batch, left alone)
Pipeline Definitions
TrainingPipeline (runs in collaborator cluster)
Triggered by fl-train-signals:vN-{client-id} push (Trigger C, collaborator EventListener):
Task: ReadMetadata
kubectl get configmap fl-meta-{client-id} ← cluster-local, always accessible
outputs:
in_flight → "false"
last_trained_version → "7"
should_train → "true" (new_version > last_trained AND not in_flight)
when (should_train == "true"):
Task: ClaimTraining
kubectl patch configmap fl-meta-{client-id}
in_flight=true, last_trained_version={new_version}
resourceVersion ensures only one claimer wins; 409 = another PipelineRun
already claimed, skip remaining tasks
Task: Train (runAfter: ClaimTraining)
oras pull fl-global:v{new_version} ← from shared OCI registry
client.fit(params, config)
oras push fl-updates:{client-id}-v{new_version}
manifest_annotations(num_examples, timestamp)
oras push fl-client-metrics:{client-id}-v{new_version}
metrics.json (train_loss, train_accuracy, etc. from fit() return)
← both OCI pushes fire webhooks to aggregator's EventListener (Trigger A)
Task: ReleaseMetadata (finally — always runs regardless of Train outcome)
kubectl patch configmap fl-meta-{client-id}
in_flight=false, last_submitted_version={new_version}
← cluster-local, no cross-cluster access needed
Self-contained: the collaborator cluster reads and writes only its own ConfigMap.
The aggregator is never involved in collaborator metadata management.
If in_flight=true when ReadMetadata runs, should_train=false and the when guard
skips ClaimTraining and Train. Duplicate trigger = harmless no-op.
AggregationCheckPipeline (runs in aggregator cluster)
Triggered by fl-updates:{client}-vN push (Trigger A) or fl-time-checks push (Trigger D):
Task: ReadAggregatorMeta
kubectl get configmap fl-meta-aggregator ← aggregator cluster-local
outputs:
current_version → "8"
aggregation_in_progress → "false"
threshold → "3"
max_staleness → "10"
Task: CountUpdates (runAfter: ReadAggregatorMeta)
oras tag list quay.io/myorg/fl-updates \
| grep -E "^[a-z-]+-v{current_version}$" | wc -l
outputs:
update_count → "3"
threshold_met → "true"
# PAPAYA hybrid trigger
if time_triggered=true AND update_count > 0:
threshold_met = "true"
# PAPAYA max staleness: exclude updates beyond threshold
eligible_updates → '[{"client_id":"hospital-a","base_version":"8","staleness":0},...]'
when (threshold_met == "true" AND aggregation_in_progress == "false"):
Task: ClaimAggregation
kubectl patch configmap fl-meta-aggregator
aggregation_in_progress=true
resourceVersion — 409 on conflict means another PipelineRun already claimed; exit
Task: PushAggregationTrigger (runAfter: ClaimAggregation)
oras push quay.io/myorg/fl-aggregation-triggers:v{current_version}
manifest_annotations(eligible_updates=...)
← fires webhook to aggregator EventListener (Trigger B)
AggregationPipeline (runs in aggregator cluster)
Triggered by fl-aggregation-triggers:vN push (Trigger B, aggregator EventListener):
Task: Aggregate
# Idempotency: exit if fl-global:v{N+1} already exists
if oras tag list fl-global | grep "^v{N+1}$"; then exit 0; fi
# Pull eligible updates and their metrics from shared OCI registry
for each update in eligible_updates:
oras pull fl-updates:{client_id}-v{base_version} ← model weights
oras pull fl-client-metrics:{client_id}-v{base_version} ← training metrics
staleness = current_version - base_version
# Pull base global model
oras pull fl-global:v{current_version}
# Staleness-discounted aggregation
strategy.aggregate_fit(global_params, [(weights, n_examples, metrics, staleness), ...])
# PAPAYA Differential Privacy
noise = np.random.normal(0, dp_noise_scale, shape)
new_params = aggregated_params + noise
# Push new global model
oras push fl-global:v{N+1}
Task: ReleaseAndSignal (runAfter: Aggregate)
# Update aggregator's own local metadata only
kubectl patch configmap fl-meta-aggregator
aggregation_in_progress=false, current_global_version={N+1}
← cluster-local, no cross-cluster k8s access
# Signal contributing clients via OCI — cross-cluster safe
for each contributing client_id:
oras push fl-train-signals:v{N+1}-{client_id}
← fires webhook to that client's cluster EventListener (Trigger C)
# Non-contributing (in-flight) clients are not signalled
# Their own ReleaseMetadata (finally) task manages their local ConfigMap
# They will submit their stale update to the next aggregation
Key property: ReleaseAndSignal only touches the aggregator's own ConfigMap and
pushes to OCI. It never issues a k8s API call to any collaborator cluster.
Event Flow (One Complete Async Cycle)
Bootstrap: in each collaborator cluster, kubectl create pipelinerun
pointing at fl-train-signals:v0-{client-id}
① TrainingPipeline — hospital-a cluster, model_version=8
ReadMetadata → in_flight=false, should_train=true (cluster-local ConfigMap)
ClaimTraining → patches fl-meta-hospital-a: in_flight=true (cluster-local)
Train → pulls fl-global:v8 from OCI
→ client.fit()
→ pushes fl-updates:hospital-a-v8 to OCI
→ pushes fl-client-metrics:hospital-a-v8 to OCI
ReleaseMetadata (finally) → patches fl-meta-hospital-a: in_flight=false (cluster-local)
│
│ OCI webhook on fl-updates fires to aggregator cluster endpoint
▼
② AggregationCheckPipeline — aggregator cluster
ReadAggregatorMeta → current_version=8, not_in_progress (cluster-local ConfigMap)
CountUpdates → count=1, threshold=3, threshold_met=false
when guard fails → exits (no-op)
(hospital-b and hospital-c complete in their own clusters; OCI webhooks fire)
③ AggregationCheckPipeline — triggered by hospital-c-v8 push
CountUpdates → count=3, threshold_met=true
ClaimAggregation → patches fl-meta-aggregator: in_progress=true (cluster-local)
PushAggregationTrigger → pushes fl-aggregation-triggers:v8 to OCI
│
│ OCI webhook fires to aggregator cluster endpoint
▼
④ AggregationPipeline — aggregator cluster
Aggregate → checks fl-global:v9 not yet exists (idempotency)
→ pulls fl-updates and fl-client-metrics for each eligible update from OCI
→ staleness: hospital-a=0, hospital-b=2, hospital-c=0
→ strategy.aggregate_fit() with staleness discounting
→ adds DP noise
→ pushes fl-global:v9 to OCI
ReleaseAndSignal
→ patches fl-meta-aggregator: in_progress=false, version=9 (cluster-local)
→ pushes fl-train-signals:v9-hospital-a to OCI
→ pushes fl-train-signals:v9-hospital-b to OCI
→ pushes fl-train-signals:v9-hospital-c to OCI
│
│ OCI webhooks fire to each collaborator cluster's EventListener endpoint
▼
⑤ Each collaborator cluster independently:
hospital-a EventListener (CEL: *-hospital-a) → TrainingPipelineRun in hospital-a cluster
hospital-b EventListener (CEL: *-hospital-b) → TrainingPipelineRun in hospital-b cluster
hospital-c EventListener (CEL: *-hospital-c) → TrainingPipelineRun in hospital-c cluster
→ each runs independently → back to ①
EventListener Configuration
Each cluster runs its own Tekton EventListener scoped to events relevant to that
participant. The OCI registry sends webhooks for a given repository to all registered
endpoints. CEL interceptors discard irrelevant events.
Aggregator cluster EventListener
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
name: fl-listener
namespace: fl
spec:
serviceAccountName: fl-trigger-sa
triggers:
# Trigger A: collaborator pushed an update
- name: update-written
interceptors:
- ref:
name: cel
params:
- name: filter
value: "body.repository == 'fl-updates'"
- name: overlays
value:
- key: client_id
expression: "body.updated_tags[0].split('-v')[0]"
- key: base_version
expression: "body.updated_tags[0].split('-v')[1]"
bindings:
- ref: trigger-a-binding
template:
ref: aggregation-check-pipeline-template
# Trigger B: threshold marker pushed → run aggregation
- name: aggregation-triggered
interceptors:
- ref:
name: cel
params:
- name: filter
value: "body.repository == 'fl-aggregation-triggers'"
- name: overlays
value:
- key: current_version
expression: "body.updated_tags[0].split('v')[1]"
bindings:
- ref: trigger-b-binding
template:
ref: aggregation-pipeline-template
# Trigger D: time-check → run aggregation check in time-triggered mode
- name: time-check
interceptors:
- ref:
name: cel
params:
- name: filter
value: "body.repository == 'fl-time-checks'"
bindings:
- ref: trigger-d-binding
template:
ref: aggregation-check-pipeline-template
Collaborator cluster EventListener (hospital-a shown)
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
name: fl-listener
namespace: fl
spec:
serviceAccountName: fl-trigger-sa
triggers:
# Trigger C: train signal for this collaborator only
- name: train-signal-received
interceptors:
- ref:
name: cel
params:
# CEL filter: only act on signals addressed to this collaborator
- name: filter
value: "body.repository == 'fl-train-signals'
&& body.updated_tags[0].endsWith('-hospital-a')"
- name: overlays
value:
- key: new_version
expression: "body.updated_tags[0].split('-')[0].split('v')[1]"
- key: client_id
expression: "body.updated_tags[0].split('-', 1)[1]"
bindings:
- ref: trigger-c-binding
template:
ref: training-pipeline-template
The endsWith('-hospital-a') filter means this EventListener ignores train signals for
hospital-b, hospital-c, etc. even though the OCI registry sends the webhook to all
registered endpoints for the fl-train-signals repository.
Staleness-Aware Aggregation
Since clients train asynchronously they may train on different global model versions.
A client that trained on v6 while the global model is now at v8 has staleness 2.
Delta-based aggregation is more principled than full-weight blending for stale updates:
# Full-weight blending (problematic):
new_global = 0.7 * global_v8 + 0.3 * client_weights_trained_on_v6
# Anchors part of the model to an outdated point in time
# Delta aggregation (correct):
delta_A = client_weights_A - global_v6 # what the client learned
staleness = current_version - base_version
weight = num_examples / (1 + staleness)
new_global = global_v8 + Σ(weight_i * delta_i) / Σ(weight_i)
MVP uses full model weights (no delta compression). The aggregation pipeline computes
deltas internally by subtracting the base model from the client update.
Max staleness cutoff (PAPAYA): updates with staleness > max_staleness are excluded
from eligible_updates in CountUpdates. The client is re-signalled with the current
model version so it restarts training on a fresh base.
PAPAYA Features Incorporated
| Feature |
Where implemented |
Status |
| Async aggregation, no round barrier |
PipelineRun-per-client, independent clusters |
MVP |
Staleness-discounted aggregation 1/(1+s) |
strategy.aggregate_fit() |
MVP |
| Quantity threshold trigger |
CountUpdates task |
MVP |
| Time window trigger (hybrid) |
CronJob → fl-time-checks → Trigger D |
MVP |
| Max staleness cutoff |
CountUpdates excludes stale updates |
MVP |
| Differential Privacy (Gaussian noise) |
Aggregate task after aggregate_fit() |
MVP |
| Per-run collaborator metrics |
fl-client-metrics OCI artifact per training run |
MVP |
| Secure Aggregation |
Requires pre-training key agreement; conflicts with independent-cluster model |
Post-MVP |
| Adaptive client prioritisation |
Requires per-client history tracking in aggregator |
Post-MVP |
Interfaces (Borrowed from Flower, No Dependency)
interfaces/client.py
from abc import ABC, abstractmethod
import numpy as np
NDArrays = list[np.ndarray]
Scalar = int | float | str | bool
class NumPyClient(ABC):
"""Implement this on each collaborator node."""
@abstractmethod
def get_parameters(self) -> NDArrays:
"""Return initial model weights."""
@abstractmethod
def fit(
self,
parameters: NDArrays,
config: dict[str, Scalar],
) -> tuple[NDArrays, int, dict[str, Scalar]]:
"""Train on local data.
Returns
-------
parameters : updated model weights
num_examples: number of training examples used
metrics : arbitrary scalar metrics (loss, accuracy, ...)
stored as fl-client-metrics OCI artifact
"""
@abstractmethod
def evaluate(
self,
parameters: NDArrays,
config: dict[str, Scalar],
) -> tuple[float, int, dict[str, Scalar]]:
"""Evaluate model on local data.
Returns
-------
loss : scalar loss value
num_examples: number of evaluation examples used
metrics : arbitrary scalar metrics
"""
interfaces/strategy.py
from abc import ABC, abstractmethod
from functools import reduce
import numpy as np
class Strategy(ABC):
@abstractmethod
def initialize_parameters(self) -> NDArrays:
"""Return the initial global model weights."""
@abstractmethod
def aggregate_fit(
self,
global_params: NDArrays,
results: list[tuple[NDArrays, int, dict, int]],
# each entry: (client_weights, num_examples, metrics, staleness)
) -> NDArrays:
"""Aggregate client updates into a new global model."""
@abstractmethod
def aggregate_evaluate(
self,
results: list[tuple[float, int, dict]],
# each entry: (loss, num_examples, metrics)
) -> tuple[float, dict]:
"""Aggregate evaluation results."""
class FedAvg(Strategy):
"""Staleness-discounted federated averaging."""
def aggregate_fit(self, global_params, results):
total_weight = sum(n / (1 + s) for _, n, _, s in results)
aggregated_delta = [
reduce(
np.add,
[
(weights[i] - global_params[i]) * (num_examples / (1 + staleness))
for weights, num_examples, _, staleness in results
],
) / total_weight
for i in range(len(global_params))
]
return [g + d for g, d in zip(global_params, aggregated_delta)]
def aggregate_evaluate(self, results):
total = sum(n for _, n, _ in results)
loss = sum(l * n for l, n, _ in results) / total
return loss, {}
Directory Structure
fl-tekton/
│
├── interfaces/
│ ├── client.py # NumPyClient ABC
│ └── strategy.py # Strategy ABC + FedAvg
│
├── runtime/
│ ├── train_runner.py # Train task entrypoint
│ ├── aggregate_runner.py # Aggregate task entrypoint
│ └── evaluate_runner.py # Evaluate task entrypoint
│
├── store/
│ └── model_store.py # ModelStore ABC + OCIModelStore
│
└── k8s/
├── aggregator/ # deployed to aggregator cluster
│ ├── pipelines/
│ │ ├── aggregation-check-pipeline.yaml
│ │ └── aggregation-pipeline.yaml
│ ├── tasks/
│ │ ├── fl-read-aggregator-meta-task.yaml
│ │ ├── fl-count-updates-task.yaml
│ │ ├── fl-claim-aggregation-task.yaml
│ │ ├── fl-push-aggregation-trigger-task.yaml
│ │ ├── fl-aggregate-task.yaml
│ │ └── fl-release-and-signal-task.yaml
│ ├── triggers/
│ │ ├── event-listener.yaml # Triggers A, B, D
│ │ ├── trigger-a-binding.yaml
│ │ ├── trigger-b-binding.yaml
│ │ ├── trigger-d-binding.yaml
│ │ ├── aggregation-check-template.yaml
│ │ └── aggregation-pipeline-template.yaml
│ ├── config/
│ │ └── fl-meta-aggregator.yaml
│ ├── rbac/
│ │ ├── trigger-sa.yaml
│ │ ├── pipeline-sa.yaml
│ │ └── oci-registry-secret.yaml
│ └── cron/
│ └── fl-time-check-cronjob.yaml
│
└── collaborator/ # deployed to each collaborator cluster
├── pipelines/
│ └── training-pipeline.yaml
├── tasks/
│ ├── fl-read-metadata-task.yaml
│ ├── fl-claim-training-task.yaml
│ ├── fl-train-task.yaml
│ └── fl-release-metadata-task.yaml
├── triggers/
│ ├── event-listener.yaml # Trigger C only, CEL-filtered per client
│ ├── trigger-c-binding.yaml
│ └── training-pipeline-template.yaml
├── config/
│ └── fl-meta-collaborator.yaml # parameterised; client_id set at deploy time
└── rbac/
├── trigger-sa.yaml
├── pipeline-sa.yaml
└── oci-registry-secret.yaml # push: fl-updates, fl-client-metrics only
The collaborator/ directory is deployed identically to each collaborator cluster with
one parameter substituted: the client_id value in the ConfigMap and the CEL filter in
the EventListener. Everything else is identical across collaborators.
Component Detail
store/model_store.py
class ModelStore(ABC):
@abstractmethod
def push_global(self, version: int, arrays: NDArrays) -> str:
"""Push full global model. Returns OCI digest."""
@abstractmethod
def pull_global(self, version: int) -> NDArrays:
"""Pull global model by version tag."""
@abstractmethod
def push_update(
self,
client_id: str,
base_version: int,
arrays: NDArrays,
num_examples: int,
) -> str:
"""Push full client model. Returns OCI digest."""
@abstractmethod
def push_metrics(
self,
client_id: str,
base_version: int,
metrics: dict,
num_examples: int,
) -> str:
"""Push per-run training metrics as OCI artifact. Returns digest."""
@abstractmethod
def pull_update(
self,
client_id: str,
base_version: int,
) -> tuple[NDArrays, int]:
"""Pull client update. Returns (arrays, num_examples)."""
@abstractmethod
def pull_metrics(
self,
client_id: str,
base_version: int,
) -> dict:
"""Pull per-run training metrics for a client update."""
@abstractmethod
def pull_updates_batch(
self,
refs: list[dict],
) -> list[tuple[NDArrays, int, dict, int]]:
"""Pull updates + metrics. Returns (arrays, num_examples, metrics, staleness)."""
@abstractmethod
def global_version_exists(self, version: int) -> bool:
"""Check if fl-global:vN tag exists (idempotency check)."""
@abstractmethod
def list_updates_for_version(self, version: int) -> list[str]:
"""List client IDs that have submitted updates for a given version."""
runtime/train_runner.py
Args: --client-module path.to.module:ClassName
--model-version N
--client-id hospital-a
--config '{"epochs": 5, "lr": 0.01}'
Steps:
1. Build ModelStore from env (OCI_REGISTRY, OCI_NAMESPACE, credentials)
2. Import and instantiate user's NumPyClient subclass
3. Pull global model: store.pull_global(model_version)
4. Call client.fit(params, config) → (updated_params, num_examples, metrics)
5. Push model update: store.push_update(client_id, model_version, updated_params, num_examples)
6. Push run metrics: store.push_metrics(client_id, model_version, metrics, num_examples)
7. Exit ← both OCI pushes fire webhooks to aggregator cluster EventListener (Trigger A)
runtime/aggregate_runner.py
Args: --strategy-module path.to.module:ClassName
--current-version N
--eligible-updates '[{"client_id":"hospital-a","base_version":"8","staleness":"0"},...]'
--dp-noise-scale 0.01
Steps:
1. Build ModelStore from env
2. Idempotency check: exit if store.global_version_exists(current_version + 1)
3. Import and instantiate user's Strategy subclass
4. Pull current global: store.pull_global(current_version)
5. Pull updates + metrics batch: store.pull_updates_batch(refs)
→ (arrays, num_examples, metrics, staleness) per entry
6. Call strategy.aggregate_fit(global_params, results)
7. Add Gaussian DP noise: new_params[i] += np.random.normal(0, dp_noise_scale, shape)
8. Push new global: store.push_global(current_version + 1, new_params)
9. Exit
Tekton Trigger Paths Summary
| OCI push |
Fires webhook to |
Trigger |
PipelineRun (cluster) |
Decision |
fl-updates:{client}-vN |
aggregator cluster |
A |
AggregationCheckPipeline (aggregator) |
Count updates; if threshold met, push aggregation trigger |
fl-client-metrics:{client}-vN |
aggregator cluster |
A (same repo? or separate) |
— |
Metrics available for aggregator to pull; no separate trigger needed |
fl-aggregation-triggers:vN |
aggregator cluster |
B |
AggregationPipeline (aggregator) |
Aggregate; push new global; push per-client train signals |
fl-train-signals:vN-{client} |
all collaborator clusters |
C (CEL-filtered per cluster) |
TrainingPipeline (collaborator cluster) |
Read local metadata; if not in-flight, claim and train |
fl-time-checks:latest |
aggregator cluster |
D |
AggregationCheckPipeline (aggregator) |
Same as Trigger A with time_triggered=true |
| Bootstrap |
n/a |
Manual kubectl create pipelinerun per collaborator cluster |
TrainingPipeline (each collaborator cluster) |
Starts initial training |
Access Control
OCI credentials (per cluster)
Aggregator cluster:
push: fl-global, fl-aggregation-triggers, fl-train-signals
pull: fl-updates (all client tags), fl-client-metrics (all client tags), fl-global
Hospital-A cluster:
push: fl-updates (hospital-a-* tags only), fl-client-metrics (hospital-a-* tags only)
pull: fl-global
no access to: fl-aggregation-triggers, fl-train-signals, other clients' tags
Hospital-B, Hospital-C: (same pattern as Hospital-A, scoped to their own tag prefix)
k8s RBAC (per cluster, cluster-local only)
Aggregator cluster:
EventListener SA: create PipelineRuns in fl namespace
Pipeline SA: read/patch fl-meta-aggregator ConfigMap
no credentials to any collaborator cluster
Hospital-A cluster:
EventListener SA: create PipelineRuns in fl namespace
Pipeline SA: read/patch fl-meta-hospital-a ConfigMap only
no credentials to aggregator cluster or other collaborator clusters
CronJob (aggregator cluster):
push fl-time-checks:latest only
No cluster has k8s API credentials for any other cluster. The OCI registry is the
complete and sole cross-cluster interface.
Network Requirements
OCI registry:
- Reachable from all clusters (read/write)
- Webhook endpoints reachable from OCI registry:
aggregator cluster: https://aggregator-el.example.com (EventListener ingress)
hospital-a cluster: https://hospital-a-el.example.com (EventListener ingress)
hospital-b cluster: https://hospital-b-el.example.com
...
Clusters do NOT need network connectivity to each other.
All inter-cluster communication is via the OCI registry.
Each cluster's EventListener Service requires an ingress endpoint reachable from the OCI
registry's webhook delivery. In an enterprise/air-gapped setup this is typically handled
via VPN, private link, or a shared internal network that all clusters and the registry
share. Clusters themselves need not peer with each other.
Build Order
-
store/model_store.py — ModelStore ABC and OCIModelStore with push_metrics
and pull_metrics. All runtime code depends on this.
-
interfaces/ — NumPyClient and Strategy ABCs. Write toy implementations for
local testing.
-
runtime/train_runner.py + runtime/aggregate_runner.py — wire interfaces
to store. Test standalone.
-
k8s/aggregator/config/fl-meta-aggregator.yaml and
k8s/collaborator/config/fl-meta-collaborator.yaml — deploy ConfigMaps to
respective clusters. Verify kubectl patch with resourceVersion.
-
k8s/aggregator/tasks/ and k8s/collaborator/tasks/ — deploy tasks to each
cluster. Test each with a standalone TaskRun before assembling into Pipelines.
-
k8s/collaborator/pipelines/training-pipeline.yaml — deploy to a test collaborator
cluster. Trigger manually. Verify when skip on in_flight=true.
-
OCI webhook config — configure push notifications for all repositories to point at
each cluster's EventListener ingress endpoint. Verify with oras push + webhook logs.
-
k8s/aggregator/triggers/event-listener.yaml and
k8s/collaborator/triggers/event-listener.yaml — deploy to respective clusters.
Verify CEL filtering with mock payloads.
-
k8s/aggregator/pipelines/ — deploy aggregation pipelines. Verify idempotency:
trigger twice, confirm second is a no-op via global_version_exists check.
-
k8s/aggregator/cron/fl-time-check-cronjob.yaml — deploy CronJob to aggregator
cluster. Verify Trigger D fires.
-
End-to-end test — two toy collaborator clusters (random fit()), one aggregator
cluster, threshold=2, shared OCI registry, watch the full cross-cluster loop run
autonomously for 10+ cycles.
Post-MVP Roadmap
| Feature |
What changes |
| Delta compression (TopK sparsification) |
train_runner.py computes delta before push; aggregate_runner.py applies deltas additively; error buffer stored per client in fl-client-metrics OCI artifact |
| OCI layer decomposition |
OCIModelStore.push_update() splits model into per-block layers; frozen layers deduplicated across clients and rounds |
| Evaluation pipeline |
EvaluationPipeline in collaborator cluster; fl-eval-signals OCI repo mirrors fl-train-signals; aggregator pulls eval metrics artifacts |
| Global model evaluation (PAPAYA-style) |
Aggregator holds validation set; Aggregate task evaluates fl-global:vN+1 centrally and stores result in fl-agg-metrics OCI artifact |
| Adaptive client prioritisation |
CountUpdates reads per-client history from fl-client-metrics; biases which updates are included |
| Secure Aggregation |
Requires cohort formation phase before training; aggregator coordinates key exchange via new OCI signal type; breaks pure independence of collaborator clusters |
| Dynamic client registration |
New collaborator cluster deploys k8s/collaborator/ with their client_id; OCI tag-prefix policy added; no aggregator changes |
| Multi-federation isolation |
Separate OCI namespace + aggregator cluster per federation; collaborator clusters can participate in multiple federations with separate credentials |
| OCI Referrers API |
Replace tag-listing in CountUpdates with oras discover; requires same-repository setup for referrers to work |
Async Federated Learning with Tekton — Architecture
Overview
This document describes an async federated learning system built on Tekton and an OCI-compatible
model registry. It borrows the client and strategy interface style from Flower but does not modify
or depend on the Flower codebase. Orchestration is fully event-driven and coordinator-free: OCI
registry webhooks trigger Tekton PipelineRuns, each PipelineRun reads a per-participant metadata
store to decide whether work should proceed, and all state transitions are driven by OCI artifact
pushes. There is no always-on coordinator process.
Each collaborator runs in its own independent Kubernetes cluster. The OCI registry is the sole
cross-cluster communication channel — no k8s API access crosses cluster boundaries. Each cluster
manages its own local metadata (Kubernetes ConfigMaps) and its own Tekton EventListener scoped to
events relevant to that participant.
The design is informed by the PAPAYA async FL system (arxiv.org/abs/2111.04877): no round
barriers, staleness-discounted aggregation, hybrid time/quantity triggering, max staleness
cutoff, and differential privacy at the aggregator.
Background: Flower's Federated Learning Model
The interface contracts in this system are modelled directly on Flower's design. Understanding the
original is useful context.
Flower Core Abstractions
Client— what each collaborator implements:get_parameters()fit(ins)evaluate(ins)Strategy— what the aggregator implements:initialize_parameters()configure_fit()aggregate_fit()configure_evaluate()aggregate_evaluate()evaluate()Flower's synchronous round barrier (what we are replacing):
All existing Flower strategies are synchronous — every round waits for a quorum before
aggregating. The
FaultTolerantFedAvgandround_timeoutgive straggler tolerance butthe round barrier remains.
Why Not Use Flower's Grid API
Flower's
GridAPI (push_messages/pull_messages) does support async patterns viapolling and message TTLs, but it requires the ServerApp and SuperNode processes to be
always-on and communicates via gRPC through the SuperLink. For a Kubernetes-native,
Tekton-based system we want ephemeral compute (PipelineRuns), not long-running processes,
and object-store-driven coordination rather than gRPC message routing.
System Design Goals
counter is the only clock (PAPAYA-style)
are made inside PipelineRuns via
whenexpressionsregistry is the only shared infrastructure; no cross-cluster k8s API access
triggers are harmless no-ops
No Rounds — PAPAYA's Core Principle
In synchronous FL, a "round" is a hard barrier: all clients train on model
vN, submit,server aggregates, everyone moves to
vN+1together. Round number and model version arethe same concept.
In this system (following PAPAYA), the global model version is a monotonic counter that
increments every time aggregation fires. At any moment:
hospital-amight be training onv7hospital-bmight still be training onv4(slow, started earlier)hospital-cmight have submittedv7and already started onv8These are not "different rounds" — they are clients operating at their own pace. When
aggregation fires using
{hospital-a, hospital-c}updates:hospital-aandhospital-care idle — give them new work onv8immediatelyhospital-bis in-flight onv4— leave it alone; its stale update will bestaleness-discounted when it eventually submits
The per-collaborator metadata store makes this explicit and prevents duplicate launches.
Multi-Cluster Topology
The critical constraint: no k8s API call crosses a cluster boundary. The aggregator
never patches a collaborator's ConfigMap. A collaborator never creates a PipelineRun in
another cluster. All cross-cluster communication is OCI push events.
Metadata Store (Per Participant, Cluster-Local)
Mutable coordination state lives in a Kubernetes ConfigMap per participant, local to
that participant's cluster. OCI artifacts are immutable content-addressed storage with no
atomic read-modify-write semantics. ConfigMaps are k8s-native, and their
resourceVersionfield provides optimistic concurrency — two simultaneous writers, one gets a 409 Conflict
and retries.
Because ConfigMaps are cluster-local, each cluster manages its own state independently.
The aggregator never needs to read or write a collaborator's ConfigMap, and vice versa.
Per-collaborator ConfigMap (lives in collaborator cluster)
Aggregator ConfigMap (lives in aggregator cluster)
OCI Registry as Model Store and Cross-Cluster Bus
Why OCI over S3/MinIO
Repository Structure
Manifest Annotations
Training metadata lives on the
fl-updatesOCI manifest — no sidecar files:Per-Run Metrics Artifact
Training metrics from
client.fit()are pushed as a separate small JSON artifact tofl-client-metrics. This is the cross-cluster-safe metrics store: the aggregator canpull it during aggregation without any network path beyond the OCI registry.
The run identity is
(client_id, base_version)— the same tag used for the model update.No separate run ID system required. The aggregation pipeline can optionally pull these
metrics artifacts alongside model updates to compute weighted aggregate metrics
(e.g. weighted average training loss across contributors).
OCI Layering in the MVP
Full models are pushed as a single blob layer per artifact. OCI layer deduplication
only helps when layers are byte-for-byte identical across pushes. Since gradient descent
updates all weights each round, no round-over-round savings apply to fully trainable models.
OCI layering becomes meaningful post-MVP:
For the MVP: single blob, no layer decomposition.
High-Level Event Flow
Pipeline Definitions
TrainingPipeline (runs in collaborator cluster)
Triggered by
fl-train-signals:vN-{client-id}push (Trigger C, collaborator EventListener):Self-contained: the collaborator cluster reads and writes only its own ConfigMap.
The aggregator is never involved in collaborator metadata management.
If
in_flight=truewhenReadMetadataruns,should_train=falseand thewhenguardskips
ClaimTrainingandTrain. Duplicate trigger = harmless no-op.AggregationCheckPipeline (runs in aggregator cluster)
Triggered by
fl-updates:{client}-vNpush (Trigger A) orfl-time-checkspush (Trigger D):AggregationPipeline (runs in aggregator cluster)
Triggered by
fl-aggregation-triggers:vNpush (Trigger B, aggregator EventListener):Key property:
ReleaseAndSignalonly touches the aggregator's own ConfigMap andpushes to OCI. It never issues a k8s API call to any collaborator cluster.
Event Flow (One Complete Async Cycle)
EventListener Configuration
Each cluster runs its own Tekton EventListener scoped to events relevant to that
participant. The OCI registry sends webhooks for a given repository to all registered
endpoints. CEL interceptors discard irrelevant events.
Aggregator cluster EventListener
Collaborator cluster EventListener (hospital-a shown)
The
endsWith('-hospital-a')filter means this EventListener ignores train signals forhospital-b, hospital-c, etc. even though the OCI registry sends the webhook to all
registered endpoints for the
fl-train-signalsrepository.Staleness-Aware Aggregation
Since clients train asynchronously they may train on different global model versions.
A client that trained on
v6while the global model is now atv8has staleness2.Delta-based aggregation is more principled than full-weight blending for stale updates:
MVP uses full model weights (no delta compression). The aggregation pipeline computes
deltas internally by subtracting the base model from the client update.
Max staleness cutoff (PAPAYA): updates with
staleness > max_stalenessare excludedfrom
eligible_updatesinCountUpdates. The client is re-signalled with the currentmodel version so it restarts training on a fresh base.
PAPAYA Features Incorporated
1/(1+s)strategy.aggregate_fit()CountUpdatestaskfl-time-checks→ Trigger DCountUpdatesexcludes stale updatesAggregatetask afteraggregate_fit()fl-client-metricsOCI artifact per training runInterfaces (Borrowed from Flower, No Dependency)
interfaces/client.pyinterfaces/strategy.pyDirectory Structure
The
collaborator/directory is deployed identically to each collaborator cluster withone parameter substituted: the
client_idvalue in the ConfigMap and the CEL filter inthe EventListener. Everything else is identical across collaborators.
Component Detail
store/model_store.pyruntime/train_runner.pyruntime/aggregate_runner.pyTekton Trigger Paths Summary
fl-updates:{client}-vNfl-client-metrics:{client}-vNfl-aggregation-triggers:vNfl-train-signals:vN-{client}fl-time-checks:latesttime_triggered=truekubectl create pipelinerunper collaborator clusterAccess Control
OCI credentials (per cluster)
k8s RBAC (per cluster, cluster-local only)
No cluster has k8s API credentials for any other cluster. The OCI registry is the
complete and sole cross-cluster interface.
Network Requirements
Each cluster's EventListener Service requires an ingress endpoint reachable from the OCI
registry's webhook delivery. In an enterprise/air-gapped setup this is typically handled
via VPN, private link, or a shared internal network that all clusters and the registry
share. Clusters themselves need not peer with each other.
Build Order
store/model_store.py—ModelStoreABC andOCIModelStorewithpush_metricsand
pull_metrics. All runtime code depends on this.interfaces/—NumPyClientandStrategyABCs. Write toy implementations forlocal testing.
runtime/train_runner.py+runtime/aggregate_runner.py— wire interfacesto store. Test standalone.
k8s/aggregator/config/fl-meta-aggregator.yamlandk8s/collaborator/config/fl-meta-collaborator.yaml— deploy ConfigMaps torespective clusters. Verify
kubectl patchwithresourceVersion.k8s/aggregator/tasks/andk8s/collaborator/tasks/— deploy tasks to eachcluster. Test each with a standalone
TaskRunbefore assembling into Pipelines.k8s/collaborator/pipelines/training-pipeline.yaml— deploy to a test collaboratorcluster. Trigger manually. Verify
whenskip onin_flight=true.OCI webhook config — configure push notifications for all repositories to point at
each cluster's EventListener ingress endpoint. Verify with
oras push+ webhook logs.k8s/aggregator/triggers/event-listener.yamlandk8s/collaborator/triggers/event-listener.yaml— deploy to respective clusters.Verify CEL filtering with mock payloads.
k8s/aggregator/pipelines/— deploy aggregation pipelines. Verify idempotency:trigger twice, confirm second is a no-op via
global_version_existscheck.k8s/aggregator/cron/fl-time-check-cronjob.yaml— deploy CronJob to aggregatorcluster. Verify Trigger D fires.
End-to-end test — two toy collaborator clusters (random
fit()), one aggregatorcluster, threshold=2, shared OCI registry, watch the full cross-cluster loop run
autonomously for 10+ cycles.
Post-MVP Roadmap
train_runner.pycomputes delta before push;aggregate_runner.pyapplies deltas additively; error buffer stored per client infl-client-metricsOCI artifactOCIModelStore.push_update()splits model into per-block layers; frozen layers deduplicated across clients and roundsEvaluationPipelinein collaborator cluster;fl-eval-signalsOCI repo mirrorsfl-train-signals; aggregator pulls eval metrics artifactsAggregatetask evaluatesfl-global:vN+1centrally and stores result infl-agg-metricsOCI artifactCountUpdatesreads per-client history fromfl-client-metrics; biases which updates are includedk8s/collaborator/with theirclient_id; OCI tag-prefix policy added; no aggregator changesCountUpdateswithoras discover; requires same-repository setup for referrers to work