hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/docs/development/adding-validation-rules.md
This guide explains how to add new validation rules to the Capacity Scheduler UI. Validation rules enforce business logic constraints across queue configurations, ensuring that changes are valid before being applied to the YARN cluster. The general idea is to have little frontend validation to rule out the straightforward issues and let YARN validate the more complicated configs.
The validation system has two layers:
validationRules in property descriptors) - Basic format checks, ranges, and patterns for individual fieldssrc/config/validation-rules.ts) - Complex cross-field and cross-queue logicThis guide focuses on the second layer: business validation rules that enforce YARN scheduler constraints.
src/config/validation-rules.ts - Rule definitions and evaluator functionssrc/features/validation/service.ts - Orchestration layer that invokes rulessrc/features/validation/crossQueue.ts - Cross-queue validation logicsrc/features/validation/ruleCategories.ts - Rule categorization for UI behaviorsrc/features/validation/utils/ - Helper utilities for validation logicsrc/config/__tests__/validation-rules.test.ts - Rule testsAdd a business validation rule when:
Do not add a business rule when:
validationRules in property descriptor instead)src/config/schemas/validation.ts)validation-rules.tsAdd a new entry to the QUEUE_VALIDATION_RULES array:
{
id: 'MY_NEW_RULE',
description: 'Brief explanation of what this rule enforces',
level: 'error', // or 'warning'
triggers: ['property-name'], // Fields that trigger this rule
evaluate: (context) => evaluateMyNewRule(context),
}
Field definitions:
id - Uppercase identifier for the rule (used in logs and debugging)description - Human-readable explanation of the constraintlevel - Default severity ('error' blocks changes, 'warning' allows with notification)triggers - Array of property names that should cause this rule to evaluateevaluate - Function that implements the validation logicCreate an evaluator function below the QUEUE_VALIDATION_RULES array:
function evaluateMyNewRule(context: ValidationContext): ValidationIssue[] {
// Skip validation for template queues if not applicable
if (isTemplateQueuePath(context.queuePath)) {
return [];
}
// Skip if rule only applies in legacy mode
if (!context.legacyModeEnabled) {
return [];
}
// Get relevant values from context
const value = context.fieldValue as string;
const relatedKey = buildPropertyKey(context.queuePath, 'related-field');
const relatedValue = context.config.get(relatedKey);
// Perform validation logic
if (/* invalid condition */) {
return [
{
queuePath: context.queuePath,
field: context.fieldName,
message: 'User-friendly error message explaining the problem',
severity: 'error',
rule: 'my-new-rule', // Lowercase kebab-case ID
},
];
}
return []; // No issues
}
ValidationContext fields:
queuePath - The queue being validated (e.g., 'root.production')fieldName - The property being changed (e.g., 'capacity')fieldValue - The new value for the propertyconfig - Full configuration map including staged changesschedulerData - Current scheduler state (queue tree, etc.)stagedChanges - All pending changeslegacyModeEnabled - Whether legacy mode is activeValidationIssue fields:
queuePath - Which queue has the issuefield - Which property has the issuemessage - User-facing error messageseverity - 'error' or 'warning'rule - Lowercase kebab-case rule identifierUpdate src/features/validation/ruleCategories.ts to categorize your rule:
// If the rule affects multiple queues (parent/children/siblings)
export const CROSS_QUEUE_RULES = [
// ... existing rules
'my-new-rule',
] as const;
// If the rule only validates a single queue
export const QUEUE_SPECIFIC_RULES = [
// ... existing rules
'my-new-rule',
] as const;
// If the rule should only produce warnings (never block)
export const WARNING_ONLY_RULES = [
// ... existing rules
'my-new-rule',
] as const;
Rule categories:
CROSS_QUEUE_RULES - Affects multiple queues; shown for relevant queuesQUEUE_SPECIFIC_RULES - Only validates the queue being editedWARNING_ONLY_RULES - Never blocks applying changes (informational only)Add test cases in src/config/__tests__/validation-rules.test.ts:
describe('MY_NEW_RULE', () => {
it('should pass when condition is valid', () => {
const config = new Map([
['yarn.scheduler.capacity.root.test.related-field', 'compatible-value'],
]);
const context: ValidationContext = {
queuePath: 'root.test',
fieldName: 'my-property',
fieldValue: 'valid-value',
config,
schedulerData: mockSchedulerData,
stagedChanges: [],
legacyModeEnabled: true,
};
const issues = runFieldValidation(context);
expect(issues).toHaveLength(0);
});
it('should return error when condition is violated', () => {
const config = new Map([
['yarn.scheduler.capacity.root.test.related-field', 'incompatible-value'],
]);
const context: ValidationContext = {
queuePath: 'root.test',
fieldName: 'my-property',
fieldValue: 'invalid-value',
config,
schedulerData: mockSchedulerData,
stagedChanges: [],
legacyModeEnabled: true,
};
const issues = runFieldValidation(context);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
queuePath: 'root.test',
field: 'my-property',
severity: 'error',
rule: 'my-new-rule',
});
expect(issues[0].message).toContain('expected error message snippet');
});
it('should skip validation when not in legacy mode', () => {
const context: ValidationContext = {
queuePath: 'root.test',
fieldName: 'my-property',
fieldValue: 'invalid-value',
config: new Map(),
schedulerData: mockSchedulerData,
stagedChanges: [],
legacyModeEnabled: false,
};
const issues = runFieldValidation(context);
expect(issues).toHaveLength(0);
});
});
If your rule affects multiple queues, update src/features/validation/utils/affectedQueues.ts:
export function getAffectedQueuesForValidation(
propertyName: string,
queuePath: string,
schedulerData: SchedulerInfo,
stagedChanges: StagedChange[],
): string[] {
const affected = new Set<string>([queuePath]);
// Add logic to determine which other queues are affected
if (propertyName === 'my-property') {
// Example: Validate parent queue when child property changes
const parentPath = getParentPath(queuePath);
if (parentPath) {
affected.add(parentPath);
}
}
return Array.from(affected);
}
function evaluateSiblingRule(context: ValidationContext): ValidationIssue[] {
const siblings = getSiblingQueues(context.schedulerData, context.queuePath);
const issues: ValidationIssue[] = [];
siblings.forEach((sibling) => {
const siblingValue = context.config.get(
buildPropertyKey(sibling.queuePath, 'property-name')
);
if (/* sibling violates constraint */) {
issues.push({
queuePath: context.queuePath,
field: 'property-name',
message: `Sibling queue ${sibling.queueName} has incompatible value`,
severity: 'error',
rule: 'sibling-rule',
});
}
});
return issues;
}
function evaluateParentChildRule(context: ValidationContext): ValidationIssue[] {
const parentPath = getParentPath(context.queuePath);
if (!parentPath) {
return [];
}
const parentValue = context.config.get(
buildPropertyKey(parentPath, 'property-name')
);
const childValue = context.fieldValue as string;
if (/* child incompatible with parent */) {
return [
{
queuePath: context.queuePath,
field: context.fieldName,
message: 'Child must be compatible with parent configuration',
severity: 'error',
rule: 'parent-child-rule',
},
];
}
return [];
}
function evaluateNumericRule(context: ValidationContext): ValidationIssue[] {
const maxKey = buildPropertyKey(context.queuePath, 'maximum-value');
const minValue = parseFloat(context.fieldValue as string);
const maxValue = parseFloat(context.config.get(maxKey) || '');
if (isNaN(minValue) || isNaN(maxValue)) {
return [];
}
if (minValue > maxValue) {
return [
{
queuePath: context.queuePath,
field: context.fieldName,
message: 'Minimum value cannot exceed maximum value',
severity: 'error',
rule: 'numeric-range',
},
];
}
return [];
}
function evaluateWithStagedChanges(context: ValidationContext): ValidationIssue[] {
// Check if there are pending changes that affect validation
const hasRelatedChanges = context.stagedChanges.some(
(change) => change.queuePath === context.queuePath && change.property === 'related-property',
);
// Use merged config to see the final state including staged changes
const finalValue = context.config.get(buildPropertyKey(context.queuePath, 'related-property'));
// Validate against the merged state
// ...
}
parent-child-capacity-modeweight-mode-transition-flexible-aqc (not just weight-rule)max-capacity-minimum, max-capacity-format-matchMany rules only apply in legacy mode. Always check:
if (!context.legacyModeEnabled) {
return [];
}
Template queue properties shouldn't trigger most validations:
if (isTemplateQueuePath(context.queuePath)) {
return [];
}
ruleCategories.tsaffectedQueues.ts if needednpm run testHere's a complete example of adding a new rule:
// In src/config/validation-rules.ts
export const QUEUE_VALIDATION_RULES: ValidationRule[] = [
// ... existing rules
{
id: 'DEFAULT_LIFETIME_CONSTRAINT',
description: 'Ensures default application lifetime does not exceed maximum lifetime',
level: 'error',
triggers: ['default-application-lifetime', 'maximum-application-lifetime'],
evaluate: (context) => evaluateDefaultLifetimeConstraint(context),
},
];
function evaluateDefaultLifetimeConstraint(context: ValidationContext): ValidationIssue[] {
// This rule applies to both legacy and flexible modes
const queuePath = context.queuePath;
const defaultKey = buildPropertyKey(queuePath, 'default-application-lifetime');
const maxKey = buildPropertyKey(queuePath, 'maximum-application-lifetime');
const defaultValue =
context.fieldName === 'default-application-lifetime'
? (context.fieldValue as string)
: context.config.get(defaultKey) || '';
const maxValue =
context.fieldName === 'maximum-application-lifetime'
? (context.fieldValue as string)
: context.config.get(maxKey) || '';
// Skip if either value is not set
if (!defaultValue || !maxValue) {
return [];
}
const defaultSeconds = parseInt(defaultValue, 10);
const maxSeconds = parseInt(maxValue, 10);
// Skip if values are not valid numbers
if (isNaN(defaultSeconds) || isNaN(maxSeconds)) {
return [];
}
if (defaultSeconds > maxSeconds) {
return [
{
queuePath,
field: context.fieldName,
message: `Default application lifetime (${defaultSeconds}s) cannot exceed maximum application lifetime (${maxSeconds}s)`,
severity: 'error',
rule: 'default-lifetime-constraint',
},
];
}
return [];
}
// In src/features/validation/ruleCategories.ts
export const QUEUE_SPECIFIC_RULES = [
// ... existing rules
'default-lifetime-constraint',
] as const;
// In src/config/__tests__/validation-rules.test.ts
describe('DEFAULT_LIFETIME_CONSTRAINT', () => {
it('should pass when default lifetime is less than maximum', () => {
const config = new Map([
['yarn.scheduler.capacity.root.test.maximum-application-lifetime', '7200'],
]);
const context: ValidationContext = {
queuePath: 'root.test',
fieldName: 'default-application-lifetime',
fieldValue: '3600',
config,
schedulerData: mockSchedulerData,
stagedChanges: [],
legacyModeEnabled: true,
};
const issues = runFieldValidation(context);
expect(issues).toHaveLength(0);
});
it('should return error when default exceeds maximum', () => {
const config = new Map([
['yarn.scheduler.capacity.root.test.maximum-application-lifetime', '3600'],
]);
const context: ValidationContext = {
queuePath: 'root.test',
fieldName: 'default-application-lifetime',
fieldValue: '7200',
config,
schedulerData: mockSchedulerData,
stagedChanges: [],
legacyModeEnabled: true,
};
const issues = runFieldValidation(context);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
queuePath: 'root.test',
field: 'default-application-lifetime',
severity: 'error',
rule: 'default-lifetime-constraint',
});
expect(issues[0].message).toContain('cannot exceed maximum');
});
it('should skip validation when maximum is not set', () => {
const context: ValidationContext = {
queuePath: 'root.test',
fieldName: 'default-application-lifetime',
fieldValue: '7200',
config: new Map(),
schedulerData: mockSchedulerData,
stagedChanges: [],
legacyModeEnabled: true,
};
const issues = runFieldValidation(context);
expect(issues).toHaveLength(0);
});
});
triggers arrayQUEUE_VALIDATION_RULESaffectedQueues.ts to correctly identify affected queuesqueuePath in returned ValidationIssue objectscrossQueue.tsruleCategories.ts - add to WARNING_ONLY_RULES if neededseverity is set correctly in returned issuesisBlockingError() logiclegacyModeEnabled is set correctlyValidationContext objects with all required fields (see examples above)docs/development/extending-scheduler-properties.md - Adding new properties with schema validationsrc/features/validation/ - Validation feature implementation (crossQueue.ts, service.ts, ruleCategories.ts)src/features/validation/README.md - Validation feature architecture overview