Back to Hadoop

Adding Validation Rules

hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/docs/development/adding-validation-rules.md

2.0.5-alpha-rc217.9 KB
Original Source
<!-- 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. -->

Adding Validation Rules

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.

Overview

The validation system has two layers:

  1. Schema-level validation (validationRules in property descriptors) - Basic format checks, ranges, and patterns for individual fields
  2. Business validation rules (src/config/validation-rules.ts) - Complex cross-field and cross-queue logic

This guide focuses on the second layer: business validation rules that enforce YARN scheduler constraints.

Key Files

  • src/config/validation-rules.ts - Rule definitions and evaluator functions
  • src/features/validation/service.ts - Orchestration layer that invokes rules
  • src/features/validation/crossQueue.ts - Cross-queue validation logic
  • src/features/validation/ruleCategories.ts - Rule categorization for UI behavior
  • src/features/validation/utils/ - Helper utilities for validation logic
  • src/config/__tests__/validation-rules.test.ts - Rule tests

When to Add a New Rule

Add a business validation rule when:

  • ✅ The constraint involves multiple properties (e.g., max-capacity >= capacity)
  • ✅ The constraint involves multiple queues (e.g., sibling capacities must sum to 100%)
  • ✅ The constraint depends on scheduler state (e.g., legacy mode requirements)
  • ✅ The constraint enforces YARN scheduler semantics (e.g., absolute capacity mode inheritance)
  • ✅ The validation logic is too complex for schema-level rules

Do not add a business rule when:

  • ❌ A simple schema validation rule suffices (use validationRules in property descriptor instead)
  • ❌ The validation is purely formatting/parsing (use Zod schemas in src/config/schemas/validation.ts)

Step-by-Step Guide

1. Define the Rule in validation-rules.ts

Add a new entry to the QUEUE_VALIDATION_RULES array:

typescript
{
  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 constraint
  • level - Default severity ('error' blocks changes, 'warning' allows with notification)
  • triggers - Array of property names that should cause this rule to evaluate
  • evaluate - Function that implements the validation logic

2. Implement the Evaluator Function

Create an evaluator function below the QUEUE_VALIDATION_RULES array:

typescript
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 property
  • config - Full configuration map including staged changes
  • schedulerData - Current scheduler state (queue tree, etc.)
  • stagedChanges - All pending changes
  • legacyModeEnabled - Whether legacy mode is active

ValidationIssue fields:

  • queuePath - Which queue has the issue
  • field - Which property has the issue
  • message - User-facing error message
  • severity - 'error' or 'warning'
  • rule - Lowercase kebab-case rule identifier

3. Add Rule to Categories

Update src/features/validation/ruleCategories.ts to categorize your rule:

typescript
// 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 queues
  • QUEUE_SPECIFIC_RULES - Only validates the queue being edited
  • WARNING_ONLY_RULES - Never blocks applying changes (informational only)

4. Write Tests

Add test cases in src/config/__tests__/validation-rules.test.ts:

typescript
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);
  });
});

5. Update Affected Queues Logic (if needed)

If your rule affects multiple queues, update src/features/validation/utils/affectedQueues.ts:

typescript
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);
}

Common Patterns

Validating Sibling Queues

typescript
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;
}

Validating Parent-Child Relationships

typescript
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 [];
}

Validating Numeric Relationships

typescript
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 [];
}

Handling Staged Changes

typescript
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
  // ...
}

Best Practices

Error Messages

  • ✅ Be specific and actionable: "Child queue capacities must sum to 100% (current: 95%)"
  • ✅ Explain the constraint: "Maximum capacity must be greater than or equal to capacity"
  • ✅ Include context when helpful: "Parent queue uses absolute resources, child queue must also use absolute resources (legacy mode requirement)"
  • ❌ Don't be vague: "Invalid configuration"
  • ❌ Don't use technical jargon: "Constraint violation in capacity vector normalization"

Rule IDs

  • Use kebab-case for rule IDs: parent-child-capacity-mode
  • Make them descriptive: weight-mode-transition-flexible-aqc (not just weight-rule)
  • Keep them consistent with similar rules: max-capacity-minimum, max-capacity-format-match

Performance

  • Return early when validation doesn't apply (template queues, wrong mode, etc.)
  • Cache expensive computations when possible
  • Avoid redundant config lookups - store in variables

Legacy Mode Handling

Many rules only apply in legacy mode. Always check:

typescript
if (!context.legacyModeEnabled) {
  return [];
}

Template Queue Handling

Template queue properties shouldn't trigger most validations:

typescript
if (isTemplateQueuePath(context.queuePath)) {
  return [];
}

Testing Checklist

  • Rule passes when constraint is satisfied
  • Rule returns correct error/warning when violated
  • Rule correctly skips when not applicable (mode, template queues, etc.)
  • Rule handles missing/undefined values gracefully
  • Rule handles edge cases (empty values, special characters, etc.)
  • Error messages are clear and helpful
  • Rule is categorized correctly in ruleCategories.ts
  • Cross-queue rules update affectedQueues.ts if needed
  • All tests pass: npm run test

Example: Complete Implementation

Here's a complete example of adding a new rule:

typescript
// 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 [];
}
typescript
// In src/features/validation/ruleCategories.ts

export const QUEUE_SPECIFIC_RULES = [
  // ... existing rules
  'default-lifetime-constraint',
] as const;
typescript
// 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);
  });
});

Troubleshooting

Rule not triggering

  • Check that the property name is in the triggers array
  • Verify the rule is included in QUEUE_VALIDATION_RULES
  • Ensure early returns aren't skipping the validation logic

Rule showing for wrong queues

  • Update affectedQueues.ts to correctly identify affected queues
  • Check the queuePath in returned ValidationIssue objects
  • Review filtering logic in crossQueue.ts

Rule blocking when it shouldn't

  • Check ruleCategories.ts - add to WARNING_ONLY_RULES if needed
  • Verify severity is set correctly in returned issues
  • Review isBlockingError() logic

Tests failing

  • Ensure test context includes all required config values
  • Check that legacyModeEnabled is set correctly
  • Verify scheduler data structure matches expectations
  • Construct ValidationContext objects with all required fields (see examples above)

Further Reading

  • docs/development/extending-scheduler-properties.md - Adding new properties with schema validation
  • src/features/validation/ - Validation feature implementation (crossQueue.ts, service.ts, ruleCategories.ts)
  • src/features/validation/README.md - Validation feature architecture overview
  • YARN Capacity Scheduler documentation - Official YARN scheduler constraints