-
Notifications
You must be signed in to change notification settings - Fork 12
Surface per-task status summary in Helix JobContext #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
5d3303d
95f5fd2
cf33c5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,22 +20,30 @@ | |
| */ | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.TreeMap; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.google.common.collect.Lists; | ||
| import com.google.common.collect.Maps; | ||
| import com.google.common.collect.Sets; | ||
| import org.apache.helix.HelixProperty; | ||
| import org.apache.helix.zookeeper.datamodel.ZNRecord; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| /** | ||
| * Provides a typed interface to the context information stored by {@link TaskRebalancer} in the | ||
| * Helix property store. | ||
| */ | ||
| public class JobContext extends HelixProperty { | ||
| private static final Logger LOG = LoggerFactory.getLogger(JobContext.class); | ||
| private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); | ||
|
|
||
| private enum ContextProperties { | ||
| START_TIME, // Time at which this JobContext was created | ||
| STATE, | ||
|
|
@@ -48,6 +56,9 @@ private enum ContextProperties { | |
| INFO, | ||
| NAME, | ||
| EXECUTION_START_TIME, // Time at which the first task of this job got scheduled | ||
| TASK_STATUS_SUMMARY, // Aggregated per-task terminal status summary (JSON), set when the job | ||
| // reaches a terminal state. Surfaces partial failures even when the job | ||
| // itself is COMPLETED (e.g. FailureThreshold set high so all tasks run). | ||
| } | ||
|
|
||
| // Note: This field needs to be set if any of the job context fields have been changed. | ||
|
|
@@ -337,6 +348,73 @@ public long getExecutionStartTime() { | |
| return Long.parseLong(tStr); | ||
| } | ||
|
|
||
| /** | ||
| * Task partition states that represent a terminal failure (the task was given up and will not be | ||
| * retried, or it errored/timed out). Used to compute the aggregated task status summary. | ||
| */ | ||
| private static boolean isFailedState(TaskPartitionState state) { | ||
| return state == TaskPartitionState.TASK_ERROR || state == TaskPartitionState.TASK_ABORTED | ||
| || state == TaskPartitionState.TIMED_OUT || state == TaskPartitionState.ERROR; | ||
| } | ||
|
|
||
| /** | ||
| * Aggregates the state of every scheduled task in this job into a compact, job-level summary and | ||
| * stores it as a simple field (JSON). This lets operators observe partial failures even when the | ||
| * job's own status flag is COMPLETED, which happens when FailureThreshold is set high so that all | ||
| * partition tasks are allowed to run to completion. Intended to be called by the controller when | ||
| * the job reaches a terminal state (completed / failed / timed out). | ||
| * The summary shape is: | ||
| * {@code {"total":N,"completed":X,"failed":Y,"other":Z,"byState":{...},"failedTasks":[...]}}. | ||
| */ | ||
| public void updateTaskStatusSummary() { | ||
| Set<Integer> partitions = getPartitionSet(); | ||
| Map<String, Integer> byState = new TreeMap<>(); | ||
| List<Integer> failedTasks = Lists.newArrayList(); | ||
| int completed = 0; | ||
| int failed = 0; | ||
| for (int p : partitions) { | ||
| TaskPartitionState state = getPartitionState(p); | ||
| String key = (state == null) ? "UNSCHEDULED" : state.name(); | ||
| byState.merge(key, 1, Integer::sum); | ||
| if (state == TaskPartitionState.COMPLETED) { | ||
| completed++; | ||
| } else if (isFailedState(state)) { | ||
| failed++; | ||
| failedTasks.add(p); | ||
| } | ||
| } | ||
| failedTasks.sort(null); | ||
|
|
||
| Map<String, Object> summary = new LinkedHashMap<>(); | ||
| summary.put("total", partitions.size()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd recommend using string constants instead of hard coding these keys in the map
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah makes sense. pulled all the summary keys (total, completed, failed, timedOut and the Tasks arrays) plus UNSCHEDULED into named SUMMARY_KEY_ and SUMMARY_STATE_UNSCHEDULED constants, and the map builds off those now instead of inline literals. |
||
| summary.put("completed", completed); | ||
| summary.put("failed", failed); | ||
| summary.put("other", partitions.size() - completed - failed); | ||
| summary.put("byState", byState); | ||
| summary.put("failedTasks", failedTasks); | ||
|
|
||
| try { | ||
| String json = OBJECT_MAPPER.writeValueAsString(summary); | ||
| if (!json.equals(getTaskStatusSummary())) { | ||
| _record.setSimpleField(ContextProperties.TASK_STATUS_SUMMARY.name(), json); | ||
| markJobContextAsModified(); | ||
| } | ||
| } catch (JsonProcessingException e) { | ||
| // The summary is a best-effort convenience field; never let it disrupt the job status | ||
| // update path. | ||
| LOG.warn("Failed to serialize task status summary for job {}", getName(), e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @return the aggregated per-task status summary as a JSON string, or null if it has not been | ||
| * computed yet (i.e. the job has not reached a terminal state). See | ||
| * {@link #updateTaskStatusSummary()} for the shape. | ||
| */ | ||
| public String getTaskStatusSummary() { | ||
| return _record.getSimpleField(ContextProperties.TASK_STATUS_SUMMARY.name()); | ||
| } | ||
|
|
||
| /** | ||
| * Get MapField for the given partition. | ||
| * @param p | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| package org.apache.helix.integration.task; | ||
|
|
||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, | ||
| * software distributed under the License is distributed on an | ||
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| * KIND, either express or implied. See the License for the | ||
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| import org.apache.helix.HelixManager; | ||
| import org.apache.helix.HelixManagerFactory; | ||
| import org.apache.helix.InstanceType; | ||
| import org.apache.helix.integration.manager.ClusterControllerManager; | ||
| import org.apache.helix.integration.manager.MockParticipantManager; | ||
| import org.apache.helix.task.JobConfig; | ||
| import org.apache.helix.task.JobContext; | ||
| import org.apache.helix.task.TaskConfig; | ||
| import org.apache.helix.task.TaskConstants; | ||
| import org.apache.helix.task.TaskDriver; | ||
| import org.apache.helix.task.TaskFactory; | ||
| import org.apache.helix.task.TaskPartitionState; | ||
| import org.apache.helix.task.TaskResult; | ||
| import org.apache.helix.task.TaskState; | ||
| import org.apache.helix.task.TaskStateModelFactory; | ||
| import org.apache.helix.task.TaskUtil; | ||
| import org.apache.helix.task.Workflow; | ||
| import org.apache.helix.tools.ClusterSetup; | ||
|
|
||
| /** | ||
| * Standalone end-to-end driver for the task status summary against a REAL, already | ||
| * running ZooKeeper (default localhost:2191). It spins up an actual Helix cluster in-process | ||
| * (controller + participants running the Task state model), submits one job with a mix of | ||
| * completing and failing tasks and a high FailureThreshold, waits for the job to reach COMPLETED, | ||
| * and prints / verifies the aggregated task status summary stored in the JobContext. | ||
| * | ||
| * Run with the helix-core test classpath, e.g.: | ||
| * <pre> | ||
| * java -cp "$(cat /tmp/helix-cp.txt):helix-core/target/classes:helix-core/target/test-classes" \ | ||
| * org.apache.helix.integration.task.JobTaskSummaryDriver localhost:2191 | ||
| * </pre> | ||
| * Exits 0 on success, 1 on failure. | ||
| */ | ||
| public class JobTaskSummaryDriver { | ||
| private static final String CLUSTER_NAME = "TASK_STATUS_SUMMARY_CLUSTER"; | ||
| private static final int NUM_NODES = 3; | ||
| private static final int START_PORT = 13900; | ||
| private static final int NUM_TASKS = 6; | ||
| private static final int FATAL_TASK = 1; // FATAL_FAILED -> TASK_ABORTED | ||
| private static final int EXCEPTION_TASK = 3; // throws -> retried to exhaustion -> TASK_ERROR | ||
| private static final int EXPECTED_FAILED = 2; | ||
| private static final int EXPECTED_COMPLETED = NUM_TASKS - EXPECTED_FAILED; | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| String zkAddr = args.length > 0 ? args[0] : "localhost:2191"; | ||
| System.out.println("=== Task status summary e2e driver against real ZK " + zkAddr + " ==="); | ||
|
|
||
| ClusterSetup setupTool = new ClusterSetup(zkAddr); | ||
| MockParticipantManager[] participants = new MockParticipantManager[NUM_NODES]; | ||
| ClusterControllerManager controller = null; | ||
| HelixManager manager = null; | ||
| boolean ok = false; | ||
|
|
||
| try { | ||
| // 1. (Re)create the cluster and register participants. | ||
| setupTool.addCluster(CLUSTER_NAME, true); | ||
| for (int i = 0; i < NUM_NODES; i++) { | ||
| setupTool.addInstanceToCluster(CLUSTER_NAME, instanceName(i)); | ||
| } | ||
|
|
||
| // 2. Start participants running the Task state model backed by MockTask. | ||
| for (int i = 0; i < NUM_NODES; i++) { | ||
| participants[i] = new MockParticipantManager(zkAddr, CLUSTER_NAME, instanceName(i)); | ||
| Map<String, TaskFactory> taskFactoryReg = new HashMap<>(); | ||
| taskFactoryReg.put(MockTask.TASK_COMMAND, MockTask::new); | ||
| participants[i].getStateMachineEngine().registerStateModelFactory( | ||
| TaskConstants.STATE_MODEL_NAME, | ||
| new TaskStateModelFactory(participants[i], taskFactoryReg)); | ||
| participants[i].syncStart(); | ||
| } | ||
|
|
||
| // 3. Start the controller. | ||
| controller = new ClusterControllerManager(zkAddr, CLUSTER_NAME, "controller_0"); | ||
| controller.syncStart(); | ||
|
|
||
| // 4. Admin manager + TaskDriver. | ||
| manager = HelixManagerFactory.getZKHelixManager(CLUSTER_NAME, "Admin", | ||
| InstanceType.ADMINISTRATOR, zkAddr); | ||
| manager.connect(); | ||
| TaskDriver driver = new TaskDriver(manager); | ||
|
|
||
| // 5. Build one job with NUM_TASKS tasks; some fail terminally. FailureThreshold is set high | ||
| // (a common operator workaround) so all tasks run and the job still ends COMPLETED. | ||
| String jobResource = "dataValidationJob"; | ||
| JobConfig.Builder jobBuilder = new JobConfig.Builder().setCommand(MockTask.TASK_COMMAND) | ||
| .setTimeoutPerTask(10000).setMaxAttemptsPerTask(2).setFailureThreshold(Integer.MAX_VALUE); | ||
|
|
||
| List<TaskConfig> taskConfigs = new ArrayList<>(); | ||
| for (int j = 0; j < NUM_TASKS; j++) { | ||
| TaskConfig.Builder cb = new TaskConfig.Builder().setTaskId("task_" + j); | ||
| if (j == FATAL_TASK) { | ||
| cb.addConfig(MockTask.TASK_RESULT_STATUS, TaskResult.Status.FATAL_FAILED.name()); | ||
| } else if (j == EXCEPTION_TASK) { | ||
| cb.addConfig(MockTask.THROW_EXCEPTION, Boolean.TRUE.toString()); | ||
| } | ||
| cb.setTargetPartition(String.valueOf(j)); | ||
| taskConfigs.add(cb.build()); | ||
| } | ||
| jobBuilder.addTaskConfigs(taskConfigs); | ||
|
|
||
| Workflow flow = | ||
| WorkflowGenerator.generateSingleJobWorkflowBuilder(jobResource, jobBuilder).build(); | ||
| System.out.println("Submitting workflow '" + jobResource + "' with " + NUM_TASKS | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we know need these print statements in tests anymore?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice catch, not needed. that was a standalone main() driver, not a TestNG test, and it needed an external ZK plus it just duplicated TestJobTaskStatusSummary. dropped the whole file so the prints go with it. coverage stays via that integration test plus a new deterministic TestJobContextTaskStatusSummary unit test. |
||
| + " tasks (" + EXPECTED_FAILED + " designed to fail) ..."); | ||
| driver.start(flow); | ||
|
|
||
| // 6. Wait for terminal COMPLETED state (job flag hides the failures). | ||
| TaskState finalState = driver.pollForWorkflowState(jobResource, TaskState.COMPLETED); | ||
| System.out.println("Workflow reached state: " + finalState); | ||
|
|
||
| String namespacedJob = TaskUtil.getNamespacedJobName(jobResource); | ||
| JobContext ctx = driver.getJobContext(namespacedJob); | ||
|
|
||
| System.out.println("\nPer-partition states:"); | ||
| for (int pId : ctx.getPartitionSet()) { | ||
| System.out.printf(" partition %d (%s) -> %s%n", pId, ctx.getTaskIdForPartition(pId), | ||
| ctx.getPartitionState(pId)); | ||
| } | ||
|
|
||
| String summary = ctx.getTaskStatusSummary(); | ||
| System.out.println("\n>>> JobContext TASK_STATUS_SUMMARY:\n " + summary); | ||
|
|
||
| ok = verify(finalState, ctx, summary); | ||
| System.out.println("\n=== RESULT: " + (ok ? "PASS" : "FAIL") + " ==="); | ||
| } finally { | ||
| if (manager != null && manager.isConnected()) { | ||
| manager.disconnect(); | ||
| } | ||
| if (controller != null && controller.isConnected()) { | ||
| controller.syncStop(); | ||
| } | ||
| for (MockParticipantManager p : participants) { | ||
| if (p != null && p.isConnected()) { | ||
| p.syncStop(); | ||
| } | ||
| } | ||
| try { | ||
| setupTool.deleteCluster(CLUSTER_NAME); | ||
| } catch (Exception e) { | ||
| System.out.println("Cleanup: could not delete cluster: " + e.getMessage()); | ||
| } | ||
| } | ||
| System.exit(ok ? 0 : 1); | ||
| } | ||
|
|
||
| private static boolean verify(TaskState finalState, JobContext ctx, String summary) { | ||
| boolean ok = true; | ||
| if (finalState != TaskState.COMPLETED) { | ||
| System.out.println("ASSERT FAIL: workflow state expected COMPLETED but was " + finalState); | ||
| ok = false; | ||
| } | ||
| if (summary == null) { | ||
| System.out.println("ASSERT FAIL: task status summary is null"); | ||
| return false; | ||
| } | ||
| int completed = 0; | ||
| int failed = 0; | ||
| for (int pId : ctx.getPartitionSet()) { | ||
| TaskPartitionState s = ctx.getPartitionState(pId); | ||
| if (s == TaskPartitionState.COMPLETED) { | ||
| completed++; | ||
| } else if (s == TaskPartitionState.TASK_ABORTED || s == TaskPartitionState.TASK_ERROR | ||
| || s == TaskPartitionState.TIMED_OUT || s == TaskPartitionState.ERROR) { | ||
| failed++; | ||
| } | ||
| } | ||
| ok &= expect("ground-truth completed", completed, EXPECTED_COMPLETED); | ||
| ok &= expect("ground-truth failed", failed, EXPECTED_FAILED); | ||
| ok &= expect("summary contains completed count", | ||
| summary.contains("\"completed\":" + EXPECTED_COMPLETED) ? 1 : 0, 1); | ||
| ok &= expect("summary contains failed count", | ||
| summary.contains("\"failed\":" + EXPECTED_FAILED) ? 1 : 0, 1); | ||
| ok &= expect("summary contains total count", | ||
| summary.contains("\"total\":" + NUM_TASKS) ? 1 : 0, 1); | ||
| return ok; | ||
| } | ||
|
|
||
| private static boolean expect(String what, int actual, int expected) { | ||
| boolean pass = actual == expected; | ||
| if (!pass) { | ||
| System.out.printf("ASSERT FAIL: %s expected %d but was %d%n", what, expected, actual); | ||
| } | ||
| return pass; | ||
| } | ||
|
|
||
| private static String instanceName(int i) { | ||
| return "localhost_" + (START_PORT + i); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is
byState?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good one, byState is just the raw per-state histogram behind the coarse counts. each TaskPartitionState name (plus UNSCHEDULED when a partition has no state yet) maps to how many tasks are in that state. added a javadoc para on updateTaskStatusSummary() spelling it out.