Federated Learning Flow (Global → Local → App)
This page is the complete reference for the life cycle of a federated learning experiment: which service calls what, which messages flow over which channel with which payload, how statuses transition on both sides, where data is written, and where errors are detected and propagated. Use it to debug a run that does not start, hangs, or stops with an error.
Identifiers Glossary
A run involves several distinct identifiers that are easy to confuse:
| Identifier | Created by | Example | Used for |
|---|---|---|---|
globalUniqueExperimentId | global, on experiment creation | 92bae936-9ed5-… | Correlates the experiment across global ⇄ all clinics. Key of nearly every FLNet message. |
uniqueRandomClinicId | local, per experiment (approveLearning) | 50a0fb21-87a1-… | Pseudonymous identity of a clinic within one experiment. The global never learns which clinic is which. |
websocket connectionId | Quarkus, per socket connection | fedd937a-… | Transport-level only. Not related to the clinic id — the global keeps connectionId → Set<experimentId> and nothing else. |
runId / stepId | local DB (FederatedLearningExperimentStepEntity.id) | 7 | Identifies one workflow-node execution in one clinic. Path parameter of the app run socket. |
relay clientId / coordinatorId | relay server, per channel | 7bea0c1a930376df | Identity of a participant on the relay channel. Carried in FederatedLearningRelayInfoDTO.id. |
channel | relay server | 239042527ef5… (64 hex) | The relay room for one workflow-node round. |
clientKey / relayKey | relay server | 512-hex strings | Credentials the controller needs to join the channel. Longer than 255 chars — stored as TEXT. |
appKey (controller registration) | local, random UUID per registration | — | Sent to the controller in /start-learning. Distinct from the app's APP_ID. |
APP_ID (system_settings.app_id) | app image .env | 28 | Identifies the tool, not the instance — identical in every clinic, which is why per-instance identification must not rely on it. |
Services and Deployment Topology
| Service | Repo / Module | Inside clinic? | Role |
|---|---|---|---|
| global-learning-api | learning-apis/global-learning-api | no | Coordinates experiments: acceptance counting, coordinator selection, relay setup, step synchronization, stop/finish. |
| local-learning-api | learning-apis/local-learning-api | yes | Connects to the global over a websocket, drives the local workflow, starts app containers via orch-api, registers learnings on the controller, hosts the app run socket. |
| orch-api | orch-api | yes | Docker orchestrator: pulls images, creates volumes, starts/stops app containers, streams container logs to its DB. |
| controller (FeatureCloud) | feature-cloud-controller | yes | Relay gateway: REST :8000 (learning registration, flrunmanagerport), AppCommunicatorV2 :8001 (app data exchange). Holds the relay credentials after registration and speaks TCP to the relay. |
| relay | global deployment (GLOBAL_RELAY_TCP_ADDRESS, e.g. …:9150) | no | Message broker between the controllers of all clinics for one channel. |
app (e.g. us-130-fl) | apps/us-130-fl + pyfedappwrap | yes (ephemeral) | The federated tool. One container per clinic per workflow node, started by orch-api. |
In the clinic-dind deployment (meta/deployment/clinic-dind) each clinic is one
docker-in-docker container running an inner compose stack on the network
${COMPOSE_PROJECT_NAME}_local-learning-network. orch-api attaches every app container it starts
to that same network, which is why the app can resolve the compose service names controller and
local-learning-api by DNS.
Communication Channels
| # | Channel | Between | Protocol / endpoint | Code |
|---|---|---|---|---|
| 1 | FLNet client socket | local ⇄ global | Websocket | global: FLNetClientWebsocket (+ FLNetClientWebsocketHandlerBO, FLNetClientBroadcastBO); local: bio.cosy.feddb.local.api.eam.WebsocketClient + ClientManager |
| 2 | Orchestration | local → orch-api | REST, POST /container/workflow, DELETE /container/workflow/{id}, volume upload/download | client: OrchWorkflowServiceClient etc. (configKey orch-docker-service); server: WorkflowServiceImpl, WorkflowNodeBO |
| 3 | Learning registration | local → controller :8000 | REST POST /start-learning | LocalControllerLearningService (configKey controller-api), payload ControllerStartLearningRequestDTO |
| 4 | App run socket | app ⇄ local | Websocket /learning/run/{runId}/{type} where type ∈ {app, controller} | FederatedLearningExperimentWebsocketService extends WorkflowAppServer |
| 5 | App data exchange | app → controller :8001 | REST /receive-setup, /send-data-to-aggregator, /send-data-to-clients, /receive-data-from-aggregator, /receive-data-from-clients | pyfedappwrap FLNetCommunicator (engine/service/controller/common.py) |
| 6 | Relay traffic | controller ⇄ relay ⇄ controllers | TCP | controller config relay.addressTCP |
FLNet message catalog (channel 1)
All messages are wrapped in FedDBClientDataDTO<T> with a messageType from
FedDBClientTypeEnum (core-learning-api …/socket/FedDBClientTypeEnum.java):
| Type | Direction | Payload | Purpose |
|---|---|---|---|
EXISTING_QUERY | G → L (broadcast all) | QueryDTO | Fire a cohort-count query; the clinic answers with QueryClientResponseDTO (count). |
LEARNING_QUERY | G → L (broadcast all) | ProjectFederatedExperimentForLocalDTO | Announce an experiment; clinic checks data access and answers LearningQueryClientResponseDTO (count, modelCanBePublic). |
DATA_STATISTICS | G → L | ProjectFederatedRequestDataStatisticsDTO | Request data statistics / auto-access check. |
START_LEARNING | G → L (experiment broadcast) | StartLearningClientRequestDTO (globalUniqueExperimentId, coordinatorId) | Phase 2 trigger: every accepted clinic starts its first workflow node. |
UPDATE_LEARNING | both directions | G → L: LearningClientSyncRequestDTO; L → G: LearningClientSyncResponseDTO | G → L: run request (startRunning=true, relay info) or next-step request. L → G: step/project status report. Same type, different DTOs per direction. |
STOP_LEARNING | G → L (experiment broadcast) | LearningClientStopRequestDTO | Stop and clean up the experiment in every clinic. Sent on user stop and on any error stop. |
CURRENT_LEARNINGS | L → G (on connect) | list of experiment ids | Registers the connection in the global's learningConnectionMap (connectionId → experimentIds). Without it the clinic receives no experiment-scoped messages. |
RUN_METRICS | G → L | ProjectFederatedRequestRunMetricsDTO | Ask clinics for local training metrics. |
ERROR / NO_RESPONSE | L → G | — | Error reply / explicit "nothing to send". |
App run socket catalog (channel 4)
Wrapped in AppMessageWrapperDTO<T> with AppMessageTypeEnum, dispatched in
WorkflowAppServer.handleAppMessages:
| Type | Direction | Payload | Effect on local |
|---|---|---|---|
START_FEDERATED_RUN / START_RUN | L → app | StartRunDTO | Starts the (federated) run. START_FEDERATED_RUN is chosen when the node supportsFederatedLearning (FederatedLearningExperimentBroadcastBO.startStep). |
CLIENT_STARTED | app → L | — | Ignored (no-op case). |
UPDATE_RUN | app → L | UpdateRunDTO (runId, status, progress, error) | updateRun: sets step status/progress; ERROR status also sets lastError. |
FINISH_RUN | app → L | FinishRunDTO (runId, status, error) | finishRun: ERROR ⇒ step ERROR+lastError; success ⇒ save results from the orch volume first, then step FINISHED; closes the socket. |
LOG_MESSAGE / LOG_METRIC | app → L | RunMessageLogDTO / RunMessageMetricDTO | Persisted per step (FederatedLearningExperimentStepMessageBO), feeds the UI log/metric stream. |
SEND_MODEL | app → L | — | Deprecated; logs a warning ("use HTTP endpoint instead"). Model/result files go through the HTTP upload path. |
Status Models
Two status enums exist and are mapped into each other — keep them apart:
RunStatusTypes (step level, both sides):
PENDING → INITIALIZED → STARTED → RUNNING → FINISHED | STOPPED | ERROR.
Terminal: FINISHED, STOPPED, ERROR (terminalStates()); a step can only be (re)started from a
startable state (canBeStartedStatus). Status persistence guards against overwriting terminal
states (updateStatusTransactional … where stepStatus not in terminal), which produces the log
line "Skipping duplicate status update … already terminal".
ProjectStatus (participant/experiment level on the global):
INIT → READY → PREPARE → RUNNING → FINISHED | STOPPED | ERROR | SHUTDOWN.
The local maps step → participant status via ProjectStatus.fromRunStatusTypes
(PENDING/INITIALIZED/STARTED → READY, RUNNING → RUNNING, …). The global aggregates all
participants with ProjectFederatedExperimentHelper.getNextStatus(participants):
- any participant
ERROR⇒ERROR - all
READY⇒READY(triggers relay setup + run start) - all
FINISHED⇒FINISHED(triggers next node or experiment finish) - participants with
nullstepStatus are treated asINIT(with a warning)
End-to-End Sequence
Phase 0 — Prerequisites (queries, acceptance, connection)
Before an experiment can start:
- Each clinic's
WebsocketClientconnects to the global (flnet.global.socket…) and announces its running experiments withCURRENT_LEARNINGS. The global storesconnectionId → Set<experimentId>inFLNetClientBroadcastBO.learningConnectionMap. A clinic that has not synced receives no experiment-scoped broadcasts (log: "No experiment found for connection …"). EXISTING_QUERYbroadcasts collect cohort counts.QueryBO.handleQuerydeduplicates byglobalUniqueId: a re-fired query id returns the cached count ondev/stagingprofiles and0on any other profile (privacy default).LEARNING_QUERYannounces the experiment; each clinic that grants access responds with its count. The global counts acceptances (updateExperimentCount); when the configured clinic count is reached the experiment becomesREADY(log: "reached required clinic count. Setting status to READY").
On the local side, accepting a learning request creates the experiment
(FederatedLearningExperimentBO.approveLearning): a FederatedLearningExperimentEntity with a
fresh uniqueRandomClinicId and one FederatedLearningExperimentStepEntity per workflow node.
Phase 1 — Experiment Start (Global)
Entry: ProjectFederatedExperimentBO.startLearning(projectId, experimentId, keycloakId)
(global-learning-api …/project/experiment/federated/ProjectFederatedExperimentBO.java).
-
Validates: experiment status
READY,participants.size() >= participantsMinAmount, workflow present — otherwiseNotAllowedException. -
Coordinator selection: a random participant gets
setIsCoordinator(true). Non-coordinators keepnull— safe because the entity getter is null-safe:public boolean getIsCoordinator() {return Boolean.TRUE.equals(isCoordinator);} -
projectFederatedExperimentStepBO.createForWorkflow(entity)creates the global-side step rows. -
ao.startLearningTransactional(experimentId, coordinator)— a bulk JPQL update settingexperimentStatus=RUNNING, startedAt, coordinator. Bulk updates bypass the persistence context; the code detaches/reloads the entity afterwards. -
FLNetClientBroadcastBO.startLearning(globalUniqueId, coordinatorClinicId)broadcastsSTART_LEARNINGto every connection associated with the experiment.
Phase 2 — Local Start: container, data, ready (each clinic)
Entry: WebsocketClient.onMessage → processMessage →
FederatedLearningSyncBO.handleStartLearningRequest(StartLearningClientRequestDTO).
:::info Message processing model
onMessage processes the connection's messages sequentially on a worker pool
(Multi.emitOn(...).transform(...)). A long-running handler (this one starts containers and
uploads data) delays subsequent messages on the same connection — ordering is guaranteed, latency
is not.
:::
-
Resolve the experiment by
globalUniqueLearningExperimentId; missing ⇒ error response "Experiment not found". IfcoordinatorIdequals this clinic'suniqueRandomClinicId, mark the project coordinated (setProjectAsCoordinatedTransactional). -
FederatedLearningExperimentBO.startLearning(experimentId, firstStep=true, relayInfo=null):prepareStartContextresolves what to run:- first node via
baseWorkflowEngine.firstNode(workflow)(ornextNodeafter the previous step), marks the experiment running, finds the matching step entity bynodeId, checkscanBeStartedStatus(step.stepStatus)— a non-startable step throws "… is in status X which is not startable" (this also guards duplicate starts). - sets the current node (
ao.setCurrentWorkflowNode) and builds theStartWorkflowDTO(image, container name, env, volume names, node hyperparams).
executeWorkflow(core-learning-api …/orch/WorkflowOrchestrator.java) calls orch-apiPOST /container/workflow?changeUrl=true&path=learning/run&port=8080. Error handling distinguishes HTTP errors (status + response body surfaced) from transport errors (connection refused/timeout) — both end asIllegalStateExceptionwith the real cause. - first node via
-
orch-api (
WorkflowNodeBO.startContainer):-
ensureNotRunningAlready— hard-cleans an existing container for the same workflow node. -
DockerPullService.loadApplicationImage— registry selected by image name (gitlab.cosy.bio/featurecloud/ default). Skipped if the image exists locally whenorch.docker.pull.skip-if-present=true(the pre-bundled dind image makes this the common case). Pull failures are rethrown — a swallowed pull failure would resurface later as a confusing "no such image". -
Volumes are created as
{hash}-fc-w{workflowId}-n{executionOrder}_volume_input|output(e.g.8ee0b12c9738482b-fc-w7-n0_volume_output), the container as{hash}-fc-w{workflowId}-n{executionOrder}. -
Container env injected by
WorkflowServiceImpl.startWorkflow:Env Value WS_URL/HTTP_URLws://{host}:8080/learning/run/— the app run socket / upload base.{host}is the caller's address orcontainer.host.override.DATA_DIR/OUTPUT_DIRvolume mount paths /mnt/input,/mnt/outputENABLE_LOCAL_RESULT_SAVING/ENABLE_REMOTE_RESULT_SAVINGtrue/ per requestSEND_CONSOLE_LOG,DEV_MODEtrue,falseFL_RUN__CONTROLLER_COMM_URLfrom container.controller-comm-url(defaulthttp://controller:8001) — the app's fallback controller endpoint -
The container joins the clinic network (
CONTAINER_NETWORK_NAMES) and a log stream is opened (ensureLogStream) — this is why app stdout appears in orch-api's log as "Database stream - container … log: …".
-
-
Back on local: the step is persisted
PENDING(orINITIALIZEDfor old-FC apps) with the container id; for the first node the input data is exported (PatientDataExportBO.exportDataForLearning) and uploaded into the input volume via orch-api (uploadFilesToVolume, file name from the node's input config, e.g.data.csv). -
The app engine boots (pyfedappwrap
FedDBEngine), connects tows://local-learning-api:8080/learning/run/{runId}/appwith a service-account bearer token.FederatedLearningExperimentWebsocketService.onOpen→setAppIsRunning→ stepSTARTED(RUNNINGif the connection type iscontroller). -
Every step status change runs
FederatedLearningExperimentStepBO.updateStatus, which ends withwebsocketSender.sendRunningUpdate(...)→UPDATE_LEARNINGto the global carryingLearningClientSyncResponseDTO{projectStatus, stepStatus=ready, currentNodeId, uniqueRandomClinicId, globalUniqueExperimentId, error=null}.
Example (from a real run):
{"messageType":"UPDATE_LEARNING","message":{
"type":"LEARNING_SYNC","error":null,
"uniqueRandomClinicId":"50a0fb21-87a1-4c59-8010-ee0acba84f01",
"projectStatus":"RUNNING","currentNodeId":"cd48df13-bca7-40c3-be9b-08383c8d3ccb",
"stepStatus":"ready","globalUniqueExperimentId":"92bae936-9ed5-4f15-90e4-7636e23c2bdf"}}
Phase 3 — Relay Setup and Run Request (Global)
Entry: FLNetClientWebsocketHandlerBO.handle (UPDATE_LEARNING from a clinic) →
ProjectFederatedExperimentParticipantBO.updateStatus →
ProjectFederatedExperimentBO.updateStepAndNotify(participant) (runs in a transaction with a
PESSIMISTIC_WRITE lock on the experiment).
getNextStatus(participants) aggregates; when it returns READY:
handleStartRunning(participants, experiment)
-
getNextStep(...)resolves the node to start. For the first node it also callssetCurrentStep, which persists the current node via bulk update and sets it on the in-memory entity:ao.setCurrentWorkflowNode(experimentId, step); // bulk JPQL update (DB only)experiment.setCurrentWorkflowNode(step); // keep the managed entity in sync:::warning Bulk updates bypass the persistence context
setCurrentWorkflowNodeandstartLearningTransactionalare JPQL bulk updates. Reading the same field from the in-memory entity right after returns the stale value unless it is set explicitly. Forgetting this caused acurrentWorkflowNode == nullNPE in relay setup. ::: -
ProjectFederatedExperimentStepBO.handleRelaySetup(experiment):- Resolves the node's
FederatedAppVersionEntity(node-level, falling back to the submodel's model). Missing ⇒IllegalStateException. The app must be flaggedsupportsFederatedLearning, otherwise relay setup is refused. - Slot math (v2): the relay request asks for
clients = participants − coordinatorsclient slots, because v2 has a separate coordinator/aggregator slot and the coordinator clinic occupies it. Requesting one slot per participant would leave the aggregator waiting for a client that never connects. v1 (old FeatureCloud) has no separate slot — the first client is also the coordinator. globalRelayService.setupFL(startup)returnsCreateFLLearningRelayServerResponseDTO{channel, relayKey, clientIds, clientId2ClientKey, coordinatorId, coordinatorKey}. Channel and relayKey are persisted on the global step row (project_federated_experiments_steps.channel_id/relay_key, bothTEXT— relay keys exceedvarchar(255)).- One
FederatedLearningRelayInfoDTOis mapped per participant: clients get{id=clientId, key=clientKey, coordinator=false}, the coordinator clinic gets{id=coordinatorId, key=coordinatorKey, coordinator=true}. All entries sharechannel, relayKey, maxNumClients, orderClientIds, appVersion(v1|v2). Thecoordinatorflag is set explicitly on every entry (nullable-Booleandiscipline; the DTO getter is null-safe and serializestrue|false, nevernull). - A relay-API failure inside the inner call is caught and returns an empty list — the outer
code logs "Relay setup returned no relay info …" as a warning. An exception in the outer
mapping is caught in
handleStartRunningand logged with stacktrace beforestopLearning(ERROR)— this catch used to be silent, which made error stops undebuggable.
- Resolves the node's
-
Participant step statuses are bulk-updated to
RUNNING, the global step toSTARTED. -
FLNetClientBroadcastBO.startStepLearning(...)sends oneUPDATE_LEARNINGrun request per participant:{"messageType":"UPDATE_LEARNING","message":{"type":"LEARNING_SYNC","nextStep":40,"currentNodeId":"cd48df13-…","startRunning":true,"globalUniqueLearningExperimentId":"e73d8d86-…","uniqueRandomClinicId":"e5476802-…","relayInfo":{"id":"7bea0c1a930376df","key":"3b8327b4…(512 hex)…","channel":"239042527ef5…","relayKey":"804f7b53…(512 hex)…","coordinator":false,"coordinatorId":"a9eed9cc8a12bef1","maxNumClients":3,"orderClientIds":["7bea0c1a930376df","cf738bfbf2897153","a9d96f27359178b4"],"appVersion":"v2"}}}Each request is broadcast to the whole experiment with the target clinic in
uniqueRandomClinicId; clinics ignore requests addressed to others.:::warning Why broadcast instead of targeted send? The websocket
connectionIdis unrelated to the clinic id and the global has no mapping between them. A former implementation filtered connections byconnection.id().equals(clinicId)— which never matched, so no clinic ever received the run request and every experiment hung at READY. The payload-carried target id is the supported pattern. :::
Phase 4 — Controller Registration and App Start (Local)
Entry: FederatedLearningSyncBO.handleNextStep(LearningClientSyncRequestDTO)
(annotated @Retry(maxRetries = 8, delay = 75) — note this retries a side-effectful method; the
duplicate-start guard below is what keeps that safe-ish).
For startRunning=true:
-
Self-selection: if
request.uniqueRandomClinicIdis set and differs from this clinic's id ⇒ ignore (debug log). -
Out-of-sync guard: if the requested
currentNodeIddiffers from the local current node ⇒ warn + ignore. Duplicate guard: if the current step is alreadyRUNNINGor terminal ⇒ "Ignoring duplicate start request". -
FederatedLearningExperimentBO.handleStartLearning(experimentId, relayInfo):experiment.getCurrentWorkflowNode().setRelayInfo(relayInfo); // for the calls belowstepBO.setRelayInfo(stepId, relayInfo); // transactional persist (jsonb)The method runs outside a transaction, so the entity mutation alone would never reach the DB (
relay_infois ajsonbcolumn on the local step). -
BaseWorkflowExperimentBO.handleStartLearning(experiment):(a) Controller registration —
handleStartFederatedLearningFC(currentStep)(called for old-FC and new federated apps): maps the relay info viamapper.relayToController(step.getRelayInfo(), runId)intoControllerStartLearningRequestDTO{channel, clientId, clientKey, relayKey, runId, coordinatorId, maxNumClients, orderClientIds, appKey=randomUUID, appVersion}and
POSTs it to the controller (:8000/start-learning). The controller stores the credentials and joins the relay channel. Missing relay info or a non-200 response sets the step to error and throws.(b) App start —
startStep(currentStep):-
getStartup(stepId)builds the baseStartRunDTO:hyperParams(from the node config),inputFilePaths(from the node's input interface, e.g.{"data": "data.csv"}),supportFederatedLearning,isTrainable. -
enrichWithFederatedRelay(run, step)adds the federated topology:StartRunDTOfieldValue Why participantsexactly one — this clinic: participantId = relayInfo.id,role = AGGREGATORifrelayInfo.coordinatorelseCLIENT, plus the run'shyperParams/inputFilePathsThe app self-identifies by the single-participant rule: all clinics share APP_ID, so a multi-entry list is ambiguous and rejected by the runner ("Real federated runs require … only one participant").config.channel/clientId/clientKey/relayKey/coordinatorId/maxNumClients/orderClientIds/appVersionfrom relayInfoFull relay topology for the app side. config.controllerUrl+controllerCommUrlfl.app.controller-url(defaulthttp://controller:8001)Per-run controller endpoint; the env var injected by orch-api is the fallback. startAggregatorrelayInfo.coordinatorOnly the coordinator clinic runs the aggregator. totalRoundshyperparams federated_rounds/total_roundsRound count for the aggregator. -
FederatedLearningExperimentBroadcastBO.startStep(run)wraps it asSTART_FEDERATED_RUN(runType=FEDERATED_RUN) and sends it to the connection whose pathrunIdmatches.
-
Phase 5 — The App Round (pyfedappwrap)
Code: pyfedappwrap/engine/worker/federated_worker_manager.py,
engine/tests/federated/runner.py (despite the path, this is the production runner),
engine/service/controller/common.py, engine/federated/models.py.
FederatedWorkerManager._run_federated(run_dto, run_type) per message:
_build_participants(run_dto.participants, total_rounds)— maps toFLNetLocalParticipantConfigDTO(base/data/output dirs default under/tmp/fedrun/{participantId};federated_roundsdefaulted fromtotalRounds). Empty list ⇒FINISH_FEDERATED_RUN(status=ERROR, error="No participants configured")._build_run_config(...)→FLNetLocalTestConfigDTO:use_external_controller=Truefor real runs;simulate_participants_locally=Trueonly forFEDERATED_TEST_RUN.- Controller URL resolution order:
- run message
config.controllerCommUrl/config.controllerUrl - test runs:
system_settings.fl_test.dockerized_controller_comm_url - real runs: env
FL_RUN__CONTROLLER_COMM_URL(system_settings.fl_run.controller_comm_url) - none ⇒
ValueError: External federated runs require a controller URL …
- run message
- Topology overrides:
aggregator_id_override ← config.coordinatorId,client_ids_override ← config.orderClientIds(coordinator excluded). These exist because on a real run the payload contains only this instance's participant, so neither the aggregator id nor the full client list can be derived fromparticipants—config.aggregator_idraises a descriptiveValueErrorinstead of a bareStopIterationwhen both sources are missing.
LocalFederatedRunner.run(apps_by_participant):_participants_to_execute()— real runs execute exactly one participant, selected by (in order):participant_id == system_settings.app_id; the single participant; a uniquehyper_params.app_idmatch; a uniquehyper_params.local=truematch. Anything else raises the "identify this app instance" error.- CLIENT path:
configure_appwires theFLNetCommunicator(controller_url,client_id = participantId,client_ids = resolve_client_ids(),aggregator_id,channel), validateshyper_paramsagainst the app's pydantic config type, stages input files fromDATA_DIR, thenapp.start(config, input, mode). The app exchanges data via the controller endpoints (/receive-setup,/send-data-to-aggregator,/receive-data-from-aggregator, …) usingappKey = system_settings.app_idin request payloads. - AGGREGATOR path (coordinator clinic):
_run_aggregatorloopstotalRoundstimes:await_data_from_clients(num_data_packages_per_communication_round=len(client_ids))→aggregate(packages)→broadcast(aggregated). Afterwards the aggregated result is saved to the output dir.
- Status/progress flow back over the run socket as
UPDATE_FEDERATED_RUN/FEDERATED_PARTICIPANT_UPDATE/ log messages; completion asFINISH_FEDERATED_RUNwithstatus=FINISHEDorstatus=ERROR, error="<type>: <message>".
:::note Result files
With ENABLE_LOCAL_RESULT_SAVING=true the app writes its outputs (predictions, coefficients,
report, …) to OUTPUT_DIR=/mnt/output — the output volume, which the local API harvests at
finish (Phase 6). With ENABLE_REMOTE_RESULT_SAVING=true results are additionally uploaded over
HTTP_URL (the upload endpoint persists them via saveResults(stepId, file), and the
coordinator clinic forwards the model result to the global API).
:::
Phase 6 — Finish, Result Harvest, Propagation
Entry: WorkflowAppServer.handleAppMessages(FINISH_RUN) →
FederatedLearningExperimentWebsocketService.finishRun(runId, finishTest, runType).
boolean isError = finishTest.getStatus() == RunStatusTypes.ERROR || finishTest.getError() != null;
if (isError) {
step.setStepStatus(RunStatusTypes.ERROR);
step.setLastError(error); // propagates: experiment error + global stop
} else {
stepResultBO.saveResults(runId); // harvest output volume BEFORE cleanup
step.setStepStatus(RunStatusTypes.FINISHED);
}
stepBO.updateStatus(step);
connection.closeAndAwait(RUN_FINISHED);
Ordering matters: stepBO.updateStatus → handleFinalState → on the last step
workflowOrchestratorBO.cleanup(experimentId) removes the containers and volumes. The result
harvest (saveResults → WorkflowOrchestratorBO.getFiles downloads a zip of the output volume →
createForStep persists FederatedLearningExperimentStepDataEntity rows and links files to the
next node's inputs via WorkflowConnection edges) must therefore run before the status
update.
FederatedLearningExperimentStepBO.updateStatus then:
- persists the status (
updateErrorTransactionalsetsstepStatus=ERRORandlastErroratomically, guarded against terminal states), - on
lastError != null:experimentAO.markExperimentError+federatedLearningRequestBO.stopLearning(globalRequestId)— the global is told to stop, - on terminal status:
handleFinalState— last step + FINISHED ⇒ mark experiment finished, notify global (finishLearning), clean containers/volumes; ERROR ⇒ cleanup; otherwise stop just this step's container (keep volumes for the next node), - always:
sendRunningUpdate→UPDATE_LEARNINGto the global.
Global: updateStepAndNotify aggregates again — all FINISHED ⇒ handleNextStep:
persist the step FINISHED; nextNode == null ⇒ experiment FINISHED (participants bulk-updated,
SSE event to the UI); otherwise participants → INIT for the next node and
nextStepLearning is broadcast — the cycle re-enters Phase 3 for the next node.
STOP_LEARNING (user stop or error stop) → FederatedLearningSyncBO.stopLearning in every
clinic → BaseWorkflowExperimentBO.stopLearning → steps stopped, containers and volumes removed.
Killing the app container closes the run socket; onError("Connection was closed") on a step
without a prior error is treated as a successful finish
(setAppHasError, see the TODO there — a crash that only manifests as a dropped connection is
currently recorded as FINISHED).
Error Propagation Map
| Failure | Detected at | What the logs show | Propagation |
|---|---|---|---|
| orch-api unreachable / start timeout | WorkflowOrchestrator.executeWorkflow | local: Failed to reach orch-api to start workflow … + cause (e.g. ProcessingException: timeout … for server orch-api:8080) | step ERROR → experiment error → global stop |
| Image pull / container create fails | orch-api WorkflowNodeBO / DockerAppService | orch: Failed to load Docker image … / Failed to start container … (image …, name …); the REST 500 body carries the cause | as above, with the orch response body in the step error |
| Relay setup fails | global handleStartRunning catch | global: Relay setup failed for experiment N (current node X) - stopping learning with ERROR: … + stacktrace | stopLearning(ERROR) → STOP_LEARNING broadcast to all clinics |
| Relay API down (inner call) | global handleRelaySetup inner catch | global: Failed to setup relay … then Relay setup returned no relay info … (warn) | run request goes out without relay data — round cannot start; watch for this warning |
| Controller registration fails | local handleStartFederatedLearningFC | local: Failed to start learning for runId … + controller response | step error + exception to the run-request handler → error response to global |
| App startup contract violation | app _run_federated | orch log stream: No participants configured / Real federated runs require … / External federated runs require a controller URL | FINISH_FEDERATED_RUN(ERROR) → step ERROR + lastError → global stop |
| App crashes mid-round | app thread outcome | orch log stream: Federated run N crashed: … + traceback | as above |
| App container killed externally | run socket onError | local: Error in Learning-WebsocketClient: Connection was closed | step FINISHED if no prior error (see pitfall), otherwise the prior error wins |
Clinic reports error in UPDATE_LEARNING | global updateStepAndNotify | global: Participant X reported status ERROR for experiment N - stopping learning | stopLearning(ERROR) → broadcast |
Configuration Reference
local-learning-api (application.properties)
| Property | Default | Purpose |
|---|---|---|
quarkus.rest-client.orch-docker-service.url | http://localhost:8091 (compose: http://orch-api:8080) | orch-api endpoint. |
quarkus.rest-client.orch-docker-service.read-timeout | 300000 | Workflow start may include a cold image pull — far above the 30 s rest-client default that used to kill first runs. |
quarkus.rest-client.controller-api.url | http://localhost:8092 (compose: http://controller:8000) | Controller registration endpoint. |
fl.app.controller-url | http://controller:8001 | Controller AppCommunicator URL placed into the run message (config.controllerUrl/CommUrl). |
flnet.global.socket… | per env | Global websocket address. |
orch-api (application.properties)
| Property | Default | Purpose |
|---|---|---|
orch.docker.pull.skip-if-present | true | Skip pulling images already present (pre-bundled dind images make starts near-instant). false to force-refresh mutable tags. |
orch.docker.read-timeout / connect-timeout | 300s / 10s | Docker-daemon response timeouts (cold pulls take minutes). |
container.controller-comm-url | http://controller:8001 | Injected into app containers as FL_RUN__CONTROLLER_COMM_URL. Unset to skip. |
container.host.override | unset (compose: host.docker.internal in dev) | Host placed into WS_URL/HTTP_URL for the app. |
container.memory.limit/swap, container.cpu.shares, container.enable.oomkill-disable | unset | App container resource limits. |
container.auto-stop | true | Stop started containers on orch-api shutdown. |
App container environment (pyfedappwrap Settings, env-configurable)
| Env | Maps to | Notes |
|---|---|---|
APP_ID | system_settings.app_id | Tool identity — same in every clinic; never use for instance identification. |
FL_RUN__CONTROLLER_COMM_URL | system_settings.fl_run.controller_comm_url | Fallback controller endpoint (nested env, delimiter __). |
WS_URL, HTTP_URL, DATA_DIR, OUTPUT_DIR, SEND_CONSOLE_LOG, DEV_MODE, ENABLE_*_RESULT_SAVING | corresponding settings | Injected by orch-api (Phase 2 table). |
Clinic dind build (meta/deployment/clinic-dind)
| Variable | Default | Purpose |
|---|---|---|
BUNDLE_APP_IMAGES | us-130 app image | Space-separated app images baked into the dind (images.tar), so orch-api never pulls at run time. "" disables. |
USE_LOCAL_IMAGES, *_LOCAL_IMAGE | — | Use locally built service images instead of registry ones. |
One-command build + multi-clinic start:
meta/deployment/build_and_start_us-130_clincs_local_images.sh [CLINIC_COUNT] [START_PORT].
Debugging a Run
- Clinic logs (everything in one stream):
docker logs clinic-dind-us130-001— containslocal-learning-api-1,orch-api-1,controller-1prefixes and the app's stdout relayed by orch-api as "Database stream - container … log: …". - Did the clinic get the message? Look for
Received global message message: <TYPE>inWebsocketClient. A missingUPDATE_LEARNINGafter all clinics are ready points at the global broadcast / connection map. - Did the run request act?
Processing next step request: …(full payload incl. relay info) then eitherStarting execution step …or one of the ignore guards. - Controller engaged?
controller-1must log relay/channel activity after registration. A controller log that ends atStarting AppCommunicatorV2 on port 8001means it never received/start-learningor never reached the relay. - App contract errors appear in the orch-api log stream with full Python tracebacks.
- Global side:
Relay setup for experiment … -> N relay client slot(s),Stopping FED experiment N (globalId …) with reason …— every stop now logs its reason; an ERROR stop without a preceding logged cause is a bug.
Known Pitfalls
- One participant per clinic. All clinics share
APP_ID; the app self-identifies only via the single-participant rule. Never send a second (aggregator) participant to the coordinator clinic — the runner rejects it. - Coordinator contributes no client data. The coordinator clinic runs the aggregator role — an n-clinic experiment trains on n − 1 clients' data. Coordinator-as-both-roles would need a second app container per coordinator clinic (not implemented).
- Relay slot math. v2 client slots must exclude coordinators; otherwise the aggregator waits forever for a client that does not exist.
- Nullable
Booleanflags.isCoordinator(participant entity) andcoordinator(relay DTO) are nullable in the DB/wire format. Use the null-safe getters; direct unboxing has caused run-stopping NPEs twice. - Bulk JPQL updates bypass the persistence context (
setCurrentWorkflowNode,startLearningTransactional, the status updates). Re-sync or reload the in-memory entity when the same object is read later in the flow. - Transactionality of websocket handlers. Handlers run on worker threads without an implicit transaction. Entity mutations without an explicit transactional write are silently lost — relay info persistence was such a case.
- Two finish paths. The FL app flow finishes via
FederatedLearningExperimentWebsocketService.finishRun(which must harvest results itself, before the status update triggers volume cleanup).BaseWorkflowExperimentBO.updateStatuswithonPreFinishStep/onFinishStep(incl. local auto-advance) is a separate path used by other run types — changes to finish behavior must consider both, and multi-node FL workflows must not trigger the local auto-advance race. - "Connection was closed" masks crashes. A dropped run socket on a step without a recorded
error is treated as success (
setAppHasErrorTODO). Apps should always send an explicitFINISH_RUN; infrastructure-level kills can be misrecorded as FINISHED. - Hyperparameter key casing. A hyperparameter named
Chas been observed to arrive ascat the app, where pydantic silently falls back to the default value. Verify key casing end to end when a tuned hyperparameter appears to have no effect. appKeymismatch (open risk). The local API registers the learning on the controller with a random UUIDappKey, while the app authenticates its controller requests withsystem_settings.app_id. If the controller validates these against each other,/receive-setupfails — check this first if the app reports authorization-like errors against the controller.relay_key/channel_idlengths. Relay credentials are 512-hex strings; the step columns areTEXTon the global side. Schema regenerations must not fall back tovarchar(255).- Query dedup returns 0 in prod. A re-fired
EXISTING_QUERYwith a knownglobalUniqueIdreturns the cached count only ondev/staging; production returns0by design.