Repository navigation
fix(task-worker): restore the credential on failed calls and release slots exactly once on failure - #16239
fix(task-worker): restore the credential on failed calls and release slots exactly once on failure#16239shruthi713 wants to merge 5 commits into
Conversation
…call fails runTask swaps the user credential for an encrypted copy before each attempt and only put the original back after a response arrived. When execute threw (connection refused, timeout) the encrypted copy stayed in SecurityRequestContext, so the next retry encrypted the ciphertext again and the caller was left with the wrong credential. Restore it in a finally block.
TaskWorkerHttpHandlerInternal#run leaked the slot when the task threw an Error, leaked it when sending the error response threw, and released it a second time when clearing the GCP metadata context threw after the task had already been reported. Read the request and launch the task in separate blocks so each path reports the task once, report through a helper that releases the slot even if the response fails, and log a failed context wipe instead of letting it reach the outer handler.
The handler's comment says a task that throws potentially ran user code and the runner must terminate, but it reported the failure without the request. With no class name the completion consumer skipped the restart, and the failure metrics were tagged with an empty class. Report the request.
When clearing the GCP metadata context fails after a task, the namespace's service account credential stays on the pod and the next task, from any namespace, runs with it. Mark the pod for restart, and stop it straight away if no task is running, since a failed task has already run its completion check and nothing else would act on the flag.
There was a problem hiding this comment.
Code Review
This pull request improves task execution robustness and security by ensuring user credentials are consistently restored on failures, task slots are reliably released across all failure modes (including JVM Errors), and the GCP metadata context is safely cleared or triggers a pod restart. Comprehensive unit tests are added to verify these failure paths. The review feedback suggests adding a null check for runnableTaskRequest.getParam() to prevent a potential NullPointerException if the parameter is omitted in the request payload.
| // clear the GcpMetadataTaskContext after the task is completed. | ||
| GcpMetadataTaskContextUtil.clearGcpMetadataTaskContext(cConf); | ||
| runnableTaskContext = new RunnableTaskContext(runnableTaskRequest); | ||
| if (runnableTaskRequest.getParam().getEmbeddedTaskRequest() != null) { |
There was a problem hiding this comment.
Add a null check for runnableTaskRequest.getParam() before calling getEmbeddedTaskRequest() to prevent a potential NullPointerException if the parameter is omitted or null in the request payload.
| if (runnableTaskRequest.getParam().getEmbeddedTaskRequest() != null) { | |
| if (runnableTaskRequest.getParam() != null && runnableTaskRequest.getParam().getEmbeddedTaskRequest() != null) { |
There was a problem hiding this comment.
I'll leave this as is. The line is unchanged from develop (it's at L233 there), and a null param can't come from our own callers: RunnableTaskRequest's only constructor always sets this.param = new RunnableTaskParam(param, embeddedTaskRequest), so it's only null for a hand-built JSON payload. That case is already handled. This line sits inside the request-reading try, whose catch (Exception ex) at L260 answers 500 and releases the slot through failTask. testUnparseableRequestReleasesTheSlot checks that path, including the 500.
…esponse Add a test for the wipe-failure path when another task still holds a slot: the pod must not stop until that task finishes. Check that an unparseable request answers 500, not just that it frees its slot. Pass terminateOnComplete=false when the request can't be read, to match the comment that no user code ran (no effect: without a request there is no class name to restart on). Build the request before swapping in the encrypted credential, so nothing runs between the swap and the restore.
|
Closing in favour of #16231. The RBAC task worker stack changes the same code in |
What
Four failure-path fixes in the task worker client and handler. None of them changes behaviour on the success path.
RemoteTaskExecutorrestores the user credential when a call fails.runTaskswaps the caller's credential for an encrypted copy before each attempt, but it only put the original back after a response arrived. Ifexecutethrew (connection refused, timeout, a 503 fromRemoteClient), the encrypted copy stayed inSecurityRequestContext. The next retry then encrypted the ciphertext again, and the caller was left holding the wrong credential. The restore now happens in afinally.TaskWorkerHttpHandlerInternal#runreleases the slot exactly once. There were three bugs here:Error;Reading the request and launching the task are now separate blocks, and every failure goes through one helper (
failTask) that releases the slot even if the response can't be sent.An isolated worker restarts after a task that threw. The handler's comment says a task that throws may have run user code and the runner must terminate. But the failure was reported with a null request, so the completion consumer had no class name and skipped the restart. The failure metrics were also tagged with an empty class. The request is now passed through.
The worker restarts when the credential wipe fails. If
clearGcpMetadataTaskContextfails, the namespace's service-account credential stays on the pod, and the next task, from any namespace, runs with it. The pod is now marked for restart. If no task is running, it's stopped straight away: a failed task has already run its completion check, so nothing else would act on the flag.Why now
We found (1) in a load test. The RBAC task worker stack (#16225, #16226, #16229, #16230, #16231, #16232) has the proxy answer 503 while a task worker restarts, and
RemoteClientthrows on 503. On 2026-10-07, all 25 task workers on a test instance did their periodic restart within about 20 minutes. One 50-user run hit 168 x 400 on pipeline deploy. AppFabric logged 309 x:That is the double-encrypted credential reaching the worker. The bug is on develop today too: any
executefailure that gets retried (for exampleNoRouteToHostException) triggers it. The new 503 path just makes it common.(2)-(4) turned up while reviewing the same handler.
Merge order
This should merge before or together with the RBAC task worker stack. Without (1), the stack's 503-on-restart behaviour turns every worker restart under load into a burst of credential failures.
Testing
RemoteTaskExecutorTest#testUserCredentialIsRestoredAfterFailedAttempts: discovery points at a closed port, so every attempt fails insideexecute()after encryption. The test asserts that the caller gets back the same credential object, that there was more than one attempt, and that every attempt encrypted the plaintext, never the ciphertext.TaskWorkerFailureHandlingTest(new):testErrorFromTaskReleasesTheSlottestRepeatedErrorsDoNotWedgeThePodtestUnparseableRequestReleasesTheSlottestFailedErrorResponseReleasesTheSlotOncetestFailedCredentialWipeReleasesTheSlotOncetestTaskThatThrowsCountsTowardTheRestarttestFailedCredentialWipeRestartsThePodtestFailedCredentialWipeDefersStopWhileATaskRunsFailed to decrypt credential, 0 failedcredentials/workloadIdentity/provisioncalls. Same setup without the fix: 309 decryption failures in one run.Directory not empty) and the first GCS read timing out. In regular runs they only hit the first ~30s of the first run; in the restart-wave run they followed the restarted workers. The non-RBAC instance shows the same first-run burst, so they aren't related to this change.Behaviour change