Skip to content

[controller] Treat a deleted version as KILLED in job status instead of throwing NPE - #3070

Open
LeoLeo718 wants to merge 5 commits into
linkedin:mainfrom
LeoLeo718:leoli/fix-job-status-deleted-version
Open

LeoLeo718 wants to merge 5 commits into
linkedin:mainfrom
LeoLeo718:leoli/fix-job-status-deleted-version

Conversation

@LeoLeo718

@LeoLeo718 LeoLeo718 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem Statement

When a client polls the parent controller for the job status of a version that was already deleted, the job endpoint can return HTTP 500. This happens, for example, when a system store push was killed and its stranded version was removed while a client was still polling it.

getOffLineJobStatus gets the version with parentStore.getVersion(versionNum), which returns null once the version is deleted, and then calls version.getStatus() in the KILLED check and in the deferred swap check. Each poll that reaches either check fails with a NullPointerException. getVersion also returns null before the push has created the version, so a poll that comes that early fails the same way.

Solution

This picks up #3001 by @Amar-C and keeps its two commits, which read the version status null-safely and compare it with ==.

The third commit follows @ymuppala's review suggestion on #3001 and treats a deleted version as KILLED instead of NOT_CREATED, since a deleted version can never complete:

  • With NOT_CREATED, a non-terminal aggregate status is returned as is. VPJ keeps polling until it times out, and its kill check monitor never stops the data writer.
  • With KILLED, a non-terminal aggregate status is reported as ERROR, the same as for an existing version whose status is KILLED. VPJ fails on its next poll with "Push job error reported by controller", and during the data writer phase its kill check monitor stops the data writer.
  • SystemStoreRepairTask logs the repair job as failed and stops polling it, instead of polling until its check timeout.

The fourth commit reads the version status where it is used, through a small helper, as main does. updateParentVersionStatusIfTerminal can move the version from STARTED to ERROR earlier in the same call, and the deferred swap check has to see that change to run its topic truncation in the poll that sees the failure. VPJ stops polling once it gets that terminal status, so a later poll may never come.

The fifth commit limits the third commit's change to versions that were deleted. A client can also poll before the parent has created the version, e.g. right after it started the push job. With the third commit such a poll got ERROR, and tests in TestDeferredVersionSwap and TestDeferredVersionSwapWithSequentialRollout that start the push job in a background thread and poll right away failed in this CI run with "Unexpected push failure". A missing version now counts as deleted, and so as KILLED, only when its number is not larger than the store's largest used version number. A larger number is reported as NOT_CREATED, as in #3001, so the job status stays non-terminal and the client keeps polling. VeniceHelixAdmin#truncateOldTopics uses the same rule to tell an old topic from one whose version is not created yet.

Nothing changes while the version exists. For a deleted version, a terminal aggregate status other than COMPLETED is returned unchanged, also on a deferred swap poll that used to fail with HTTP 500, and no topic is truncated for it. A COMPLETED aggregate status for a deleted version still fails in handleTerminalJobStatus when it updates the version status, as before. For a version that the push has not created yet, a non-terminal aggregate status is returned as is, as in #3001.

Code changes

  • Added new code behind a config. If so list the config names and their default values in the PR description.
  • Introduced new log lines.
    • Confirmed if logs need to be rate limited to avoid excessive logging.
    • No new log lines. The existing log line for this case now includes the version number and whether the version was deleted or KILLED.

Concurrency-Specific Checks

Both reviewer and PR author to verify

  • Code has no race conditions or thread safety issues. The version is still read once, and its status is read where it is used, as before. The largest used version number is read from the same store object as the version, and the parent adds a version and raises that number in one store update.
  • Proper synchronization mechanisms (e.g., synchronized, RWLock) are used where needed. No new shared state.
  • No blocking calls inside critical sections that could lead to deadlocks or performance degradation.
  • Verified thread-safe collections are used (e.g., ConcurrentHashMap, CopyOnWriteArrayList). No new collections.
  • Validated proper exception handling in multi-threaded code to avoid silent thread termination.

How was this PR tested?

  • New unit tests added.
    • TestVeniceParentHelixAdmin#testDeletedVersionExecutionStatus, added in [controller] Avoid NPE in job status when the version was deleted #3001, now expects ERROR for a non-terminal aggregate status, and also covers a terminal one and a deferred swap poll. It fails if a deleted version is mapped back to NOT_CREATED, or if the deferred swap check calls getStatus() on the null version.
    • TestVeniceParentHelixAdmin#testNotYetCreatedVersionExecutionStatus polls a version whose number is larger than the largest used version number, in a regular and in a deferred swap poll, and expects NOT_CREATED. It fails if every missing version is treated as KILLED.
    • TestVeniceParentHelixAdmin#testTargetRegionDeferredSwapFailureHandledInSamePoll covers the poll in which a target region fails: the parent version moves to ERROR and the stream reprocessing topic is truncated in that same poll. It fails if the version status is read before updateParentVersionStatusIfTerminal.
  • New integration tests added.
  • Modified or extended existing tests.
  • Verified backward compatibility (if applicable).
    • No change to the response format. ERROR is an existing job status.
    • Existing integration tests that cover deferred swap, killed pushes and version deletion pass: VeniceParentHelixAdminTest, TestTargetedRegionPushWithNativeReplication#testTargetRegionPushWithDeferredVersionSwap and #testKilledRepushJobVersionStatus, TestIncrementalPush#testIncrementalPushAfterKilledPush, and TestAdminOperationWithPreviousVersion#testKillOfflinePushJob, #testDeleteAllVersions and #testDeleteOldVersion.
    • TestDeferredVersionSwap and TestDeferredVersionSwapWithSequentialRollout, which failed with the third commit in the CI run above, pass with the fifth.

Does this PR introduce any user-facing or breaking changes?

  • No. You can skip the rest of this section.
  • Yes. Clearly explain the behavior change and its impact.
    • While the regions report a non-terminal status, a job status query for a deleted version used to fail with HTTP 500 and now returns ERROR. A push job polling that version now fails on its next poll with "Push job error reported by controller", instead of failing with a "Failed to connect to" error after its poll retries.
    • A job status query for a version that the push has not created yet also used to fail with HTTP 500 while the regions report a non-terminal status, and now returns that status.

Amar Chaudhari and others added 4 commits October 9, 2026 17:14
getOffLineJobStatus dereferenced version.getStatus() after
parentStore.getVersion(versionNum), which returns null when a version was
deleted while a client is still polling job status for it (e.g. a killed
system push whose stranded version was removed). The null deref threw a
NullPointerException, so the parent controller returned HTTP 500 for every
poll of the job endpoint instead of a valid execution status.

Derive the version status null-safely (NOT_CREATED when the version is
absent) and use it for the deferred-swap terminal check and the KILLED
check, mirroring AbstractStore#getVersionStatus semantics.

Added TestVeniceParentHelixAdmin#testDeletedVersionExecutionStatus covering
the deleted-version path, which previously NPE'd and now returns a valid
aggregated status.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address review feedback: compare the enum with == instead of equals().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When the parent version was deleted while a client still polls its job
status, the job status path now treats it as KILLED instead of
NOT_CREATED. A deleted version can never complete, so a non-terminal
aggregate status is reported as ERROR. VPJ then fails fast and its kill
check stops the data writer, instead of polling until the job timeout.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
updateParentVersionStatusIfTerminal can move the parent version from
STARTED to PUSHED or ERROR earlier in the same call, and the
target-region deferred-swap block needs that new status to run its
terminal handling in the poll that sees the failure. Read the status
at each point of use through a helper that maps a deleted version to
KILLED, instead of capturing it once before the update.

Add a test for a target region push with deferred swap whose target
region fails, and cover a deleted version on the deferred-swap path in
testDeletedVersionExecutionStatus.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@LeoLeo718
LeoLeo718 requested a review from ymuppala October 10, 2026 00:19
A client can poll the parent job status of a version before the push
has created that version in the parent, for example right after it
starts the push job. Store#getVersion returns null for that version,
the same as for a deleted one, so the previous commit reported its job
status as ERROR and the poller failed the push.

Tell the two cases apart with the largest used version number, as the
old version topic cleanup in VeniceHelixAdmin does. A missing version
whose number is not larger than the largest used version number was
deleted and is still reported as KILLED. A larger number has not been
created yet and is reported as NOT_CREATED, which keeps the job status
non-terminal so the poller keeps waiting.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant