diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/public/mock/ws/v1/cluster/scheduler-conf.json b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/public/mock/ws/v1/cluster/scheduler-conf.json index 6443fc5d72867..d0681b8f5eb91 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/public/mock/ws/v1/cluster/scheduler-conf.json +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/public/mock/ws/v1/cluster/scheduler-conf.json @@ -292,6 +292,26 @@ "name": "yarn.scheduler.capacity.schedule-asynchronously.enable", "value": "true" }, + { + "name": "yarn.scheduler.capacity.minimum-user-limit-percent", + "value": "100" + }, + { + "name": "yarn.scheduler.capacity.user-limit-factor", + "value": "1" + }, + { + "name": "yarn.scheduler.capacity.maximum-am-resource-percent", + "value": "0.1" + }, + { + "name": "yarn.scheduler.capacity.root.production.maximum-applications", + "value": "10000" + }, + { + "name": "yarn.scheduler.capacity.root.production.disable_preemption", + "value": "true" + }, { "name": "yarn.scheduler.capacity.root.marketing.test.auto-create-child-queue.enabled", "value": "true" @@ -343,6 +363,10 @@ { "name": "yarn.scheduler.capacity.queue-mappings", "value": "u:user1:root.default,u:user2:root.production.prod02" + }, + { + "name": "yarn.scheduler.capacity.global-queue-max-application", + "value": "5000" } ] } diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/react-router.config.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/react-router.config.ts index d6eefc38f8a06..7857d821df473 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/react-router.config.ts +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/react-router.config.ts @@ -5,5 +5,5 @@ export default { // Server-side render by default, to enable SPA mode set this to `false` ssr: false, appDirectory: "src/app", - basename: "/scheduler-ui", + basename: "/scheduler-ui/", } satisfies Config; diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/app/entry.client.tsx b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/app/entry.client.tsx index 0640365b6c0d9..81f5876ab2716 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/app/entry.client.tsx +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/app/entry.client.tsx @@ -36,6 +36,9 @@ async function enableMocking() { // Start the worker return worker.start({ onUnhandledRequest: 'bypass', + serviceWorker: { + url: '/scheduler-ui/mockServiceWorker.js', + }, }); } diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/global-properties.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/global-properties.ts index 6a457159df233..6e914e8b955e2 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/global-properties.ts +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/global-properties.ts @@ -74,6 +74,27 @@ export const globalPropertyDefinitions: PropertyDescriptor[] = [ }, ], }, + { + name: 'yarn.scheduler.capacity.global-queue-max-application', + displayName: 'Max Applications per Queue (Global)', + description: + 'Global per-queue maximum applications. When set, each queue is capped at this flat value (no capacity scaling). When unset, per-queue limits are calculated from Maximum Applications scaled by queue capacity.', + type: 'number' as PropertyType, + category: 'application-limits' as PropertyCategory, + defaultValue: '', + required: false, + validationRules: [ + { + type: 'custom', + message: 'Must be a positive integer or empty', + validator: (value: string) => { + if (!value.trim()) return true; + const num = parseFloat(value); + return !isNaN(num) && Number.isInteger(num) && num > 0; + }, + }, + ], + }, { name: 'yarn.scheduler.capacity.application.fail-fast', displayName: 'Application Fail Fast', diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/queue-properties.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/queue-properties.ts index f84cdfac1a11c..6fb1c87c7b663 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/queue-properties.ts +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/config/properties/queue-properties.ts @@ -25,8 +25,14 @@ import type { PropertyType, PropertyCondition, PropertyEvaluationContext, + InheritanceResolverContext, } from '~/types'; import { getCapacityType } from '~/utils/capacityUtils'; +import { + getGlobalValue, + parentChainResolver, + globalOnlyResolver, +} from '~/utils/resolveInheritedValue'; const LEGACY_QUEUE_MODE_PROPERTY = 'yarn.scheduler.capacity.legacy-queue-mode.enabled'; @@ -182,6 +188,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: globalOnlyResolver, validationRules: [ { type: 'range', @@ -201,6 +208,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: globalOnlyResolver, validationRules: [ { type: 'custom', @@ -223,6 +231,19 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: ({ configData, stagedChanges }: InheritanceResolverContext) => { + // YARN checks global-queue-max-application first. + // Only when that is unset does it fall back to maximum-applications (scaled by capacity). + const perQueueGlobal = getGlobalValue('global-queue-max-application', configData, stagedChanges); + if (perQueueGlobal !== undefined) { + return { value: perQueueGlobal, source: 'global' }; + } + const globalMax = getGlobalValue('maximum-applications', configData, stagedChanges); + if (globalMax !== undefined) { + return { value: globalMax, source: 'global', isScaled: true }; + } + return null; + }, validationRules: [ { type: 'custom', @@ -245,6 +266,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: globalOnlyResolver, validationRules: [ { type: 'range', @@ -266,6 +288,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ category: 'application-limits' as PropertyCategory, defaultValue: '', required: false, + inheritanceResolver: globalOnlyResolver, validationRules: [ { type: 'custom', @@ -387,6 +410,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, validationRules: [ { type: 'custom', @@ -404,6 +428,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, validationRules: [ { type: 'custom', @@ -422,6 +447,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, validationRules: [ { type: 'custom', @@ -444,6 +470,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, validationRules: [ { type: 'custom', @@ -466,6 +493,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, }, { name: 'intra-queue-preemption.disable_preemption', @@ -476,6 +504,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, }, { name: 'priority', @@ -557,6 +586,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ defaultValue: '', required: false, templateSupport: true, + inheritanceResolver: parentChainResolver, validationRules: [ { type: 'custom', @@ -584,6 +614,7 @@ export const queuePropertyDefinitions: PropertyDescriptor[] = [ category: 'node-labels' as PropertyCategory, defaultValue: '', required: false, + inheritanceResolver: parentChainResolver, validationRules: [ { type: 'custom', diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.test.tsx b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.test.tsx index 38217b39cdc61..cb239d1680370 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.test.tsx +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.test.tsx @@ -89,6 +89,7 @@ const createMockPropertyEditor = () => ({ handleFieldBlur: vi.fn(), getFieldErrors: vi.fn(() => []), getFieldWarnings: vi.fn(() => []), + getInheritanceInfo: vi.fn(() => null), properties: [ { name: 'capacity', diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.tsx b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.tsx index e99b1c1807bab..e99db039d45a1 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.tsx +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyEditorTab.tsx @@ -107,6 +107,7 @@ export const PropertyEditorTab = ({ getFieldErrors, getFieldWarnings, properties, + getInheritanceInfo, } = usePropertyEditor({ queuePath: queue.queuePath, }); @@ -443,6 +444,7 @@ export const PropertyEditorTab = ({ queueName={queue.queueName} parentQueuePath={parentQueuePath} currentValues={watchedValues} + inheritanceInfo={getInheritanceInfo(prop.originalName || prop.name)} /> {shouldRenderTemplateButton && (
diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFieldHelpers.tsx b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFieldHelpers.tsx index 11d9ffd5ab290..192128c1f2532 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFieldHelpers.tsx +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFieldHelpers.tsx @@ -24,7 +24,8 @@ */ import React from 'react'; -import { Info, AlertTriangle } from 'lucide-react'; +import { Info, AlertTriangle, ArrowDown, ArrowUp } from 'lucide-react'; +import type { InheritedValueInfo } from '~/utils/resolveInheritedValue'; import { Badge } from '~/components/ui/badge'; import { FieldLabel, FieldMessage } from '~/components/ui/field'; import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'; @@ -138,3 +139,56 @@ export const FieldErrorMessage: React.FC = ({ } return {error ? String(error.message ?? '') : inlineBusinessError}; }; + +export interface InheritedValueIndicatorProps { + inheritanceInfo: InheritedValueInfo | null; + hasExplicitValue?: boolean; +} + +export const InheritedValueIndicator: React.FC = ({ + inheritanceInfo, + hasExplicitValue = false, +}) => { + if (!inheritanceInfo) return null; + + const sourceLabel = + inheritanceInfo.source === 'queue' + ? inheritanceInfo.sourcePath + : 'global default'; + + const scaledSuffix = inheritanceInfo.isScaled ? ' (scaled by queue capacity)' : ''; + + if (hasExplicitValue) { + return ( +
+ + + Overrides {sourceLabel}:{' '} + {inheritanceInfo.value} + {scaledSuffix} + +
+ ); + } + + return ( +
+ + + {inheritanceInfo.value} + {' '}— inherited from {sourceLabel} + {scaledSuffix} + +
+ ); +}; diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFormField.tsx b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFormField.tsx index e8f2a8a6d20ed..0022e3bcc2397 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFormField.tsx +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyFormField.tsx @@ -28,6 +28,7 @@ import { FormField } from '~/components/ui/form'; import { Field, FieldControl, FieldDescription } from '~/components/ui/field'; import type { PropertyDescriptor } from '~/types/property-descriptor'; import { SPECIAL_VALUES } from '~/types'; +import type { InheritedValueInfo } from '~/utils/resolveInheritedValue'; import { EnumPropertyField } from './EnumPropertyField'; import { CapacityPropertyField } from './CapacityPropertyField'; import { @@ -35,6 +36,7 @@ import { PropertyWarnings, BusinessErrorsList, FieldErrorMessage, + InheritedValueIndicator, } from './PropertyFieldHelpers'; import { getCommonFieldClassName, parseFieldErrors } from '../utils/fieldHelpers'; @@ -57,6 +59,7 @@ interface PropertyFormFieldProps { parentQueuePath?: string; currentValues?: Partial>; setFormValue?: UseFormSetValue>; + inheritanceInfo?: InheritedValueInfo | null; } export const PropertyFormField: React.FC = ({ @@ -72,6 +75,7 @@ export const PropertyFormField: React.FC = ({ parentQueuePath, currentValues, setFormValue: _setFormValue, + inheritanceInfo, }) => { void _setFormValue; @@ -136,6 +140,9 @@ export const PropertyFormField: React.FC = ({ )} message={error ? String(error.message ?? '') : effectiveInlineError} /> + ); @@ -175,6 +182,7 @@ export const PropertyFormField: React.FC = ({ disabled={!isEnabled} aria-invalid={Boolean(error)} className={commonClassName} + placeholder={inheritanceInfo?.value || undefined} /> {property.displayFormat?.suffix && ( @@ -183,6 +191,10 @@ export const PropertyFormField: React.FC = ({ )}
+ {property.description && ( {property.description} @@ -251,7 +263,7 @@ export const PropertyFormField: React.FC = ({ onBlur?.(property.name, e.target.value); }} rows={2} - placeholder={property.defaultValue || undefined} + placeholder={inheritanceInfo?.value || property.defaultValue || undefined} className={cn( 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', commonClassName, @@ -268,7 +280,7 @@ export const PropertyFormField: React.FC = ({ field.onBlur(); onBlur?.(property.name, e.target.value); }} - placeholder={property.defaultValue || undefined} + placeholder={inheritanceInfo?.value || property.defaultValue || undefined} disabled={!isEnabled} aria-invalid={Boolean(error)} className={commonClassName} @@ -295,6 +307,10 @@ export const PropertyFormField: React.FC = ({ )} )} + {property.description && ( {property.description} diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/__tests__/InheritedValueIndicator.test.tsx b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/__tests__/InheritedValueIndicator.test.tsx new file mode 100644 index 0000000000000..83fc6f1ad26a4 --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/__tests__/InheritedValueIndicator.test.tsx @@ -0,0 +1,77 @@ +/** + * 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 { describe, it, expect } from 'vitest'; +import { render, screen } from '~/testing/setup/setup'; +import { InheritedValueIndicator } from '../PropertyFieldHelpers'; + +describe('InheritedValueIndicator', () => { + it('renders nothing when inheritanceInfo is null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows parent queue source with value', () => { + render( + , + ); + expect(screen.getByText(/inherited from/i)).toBeInTheDocument(); + expect(screen.getByText(/root\.production/)).toBeInTheDocument(); + }); + + it('shows global default source', () => { + render( + , + ); + expect(screen.getByText(/global default/i)).toBeInTheDocument(); + }); + + it('shows "overrides" message when field has explicit value', () => { + render( + , + ); + expect(screen.getByText(/overrides/i)).toBeInTheDocument(); + expect(screen.getByText(/root\.production/)).toBeInTheDocument(); + }); + + it('shows scaled message for scaled-from-global properties', () => { + render( + , + ); + expect(screen.getByText(/root\.production/)).toBeInTheDocument(); + expect(screen.getByText(/scaled by queue capacity/i)).toBeInTheDocument(); + }); + + it('shows scaled override message when explicit and scaled-from-global', () => { + render( + , + ); + expect(screen.getByText(/overrides/i)).toBeInTheDocument(); + expect(screen.getByText(/scaled by queue capacity/i)).toBeInTheDocument(); + }); +}); diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/hooks/usePropertyEditor.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/hooks/usePropertyEditor.ts index 771b793d56fad..08de908fcba2f 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/hooks/usePropertyEditor.ts +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/hooks/usePropertyEditor.ts @@ -73,6 +73,7 @@ function mergeFormAndValidationErrors( import { validatePropertyChange } from '~/features/validation/crossQueue'; import { buildPropertyKey } from '~/utils/propertyUtils'; import { CONFIG_PREFIXES } from '~/types'; +import { resolveInheritedValue, type InheritedValueInfo } from '~/utils/resolveInheritedValue'; function createFormSchema( properties: Array< @@ -249,6 +250,23 @@ export function usePropertyEditor({ return isStaged ? 'modified' : undefined; }; + const getInheritanceInfo = (propertyName: string): InheritedValueInfo | null => { + const property = allProperties.find( + (p) => p.originalName === propertyName || p.name === propertyName, + ); + if (!property?.inheritanceResolver) { + return null; + } + + return resolveInheritedValue({ + queuePath, + propertyName, + configData, + stagedChanges, + inheritanceResolver: property.inheritanceResolver, + }); + }; + const stageChange = ( propertyName: string, value: string, @@ -613,6 +631,7 @@ export function usePropertyEditor({ propertiesByCategory, getStagedStatus, + getInheritanceInfo, properties: allProperties, formState: form.formState, diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts index ebd21700c046c..29c6b5c738c5e 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts @@ -24,7 +24,7 @@ import { READ_ONLY_PROPERTY } from '~/config'; // Base URL pattern that matches the API configuration const { baseUrl, mockMode } = API_CONFIG; -const MOCK_ASSET_BASE = '/mock/ws/v1/cluster'; +const MOCK_ASSET_BASE = `${import.meta.env.BASE_URL}mock/ws/v1/cluster`; const staticHandlers: HttpHandler[] = [ // Scheduler endpoints - serve local mock files diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/types/property-descriptor.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/types/property-descriptor.ts index 9a3c22ebf494a..5e90fb815c4d0 100644 --- a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/types/property-descriptor.ts +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/types/property-descriptor.ts @@ -84,6 +84,22 @@ export type PropertyEvaluationContext = { export type PropertyCondition = (context: PropertyEvaluationContext) => boolean; +export type InheritedValueInfo = { + value: string; + source: 'queue' | 'global'; + sourcePath?: string; + isScaled?: boolean; +}; + +export type InheritanceResolverContext = { + queuePath: string; + propertyName: string; + configData: Map; + stagedChanges?: StagedChange[]; +}; + +export type InheritanceResolver = (context: InheritanceResolverContext) => InheritedValueInfo | null; + export type PropertyDescriptor = { name: string; displayName: string; @@ -103,4 +119,5 @@ export type PropertyDescriptor = { deprecationMessage?: string; formFieldName?: string; // Escaped name for React Hook Form originalName?: string; // Original name before escaping + inheritanceResolver?: InheritanceResolver; }; diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/utils/resolveInheritedValue.test.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/utils/resolveInheritedValue.test.ts new file mode 100644 index 0000000000000..e5d69fe7c08b2 --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/utils/resolveInheritedValue.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + resolveInheritedValue, + parentChainResolver, + globalOnlyResolver, +} from './resolveInheritedValue'; + +function configMap(entries: Record): Map { + return new Map(Object.entries(entries)); +} + +describe('parentChainResolver', () => { + it('resolves value from direct parent', () => { + const result = parentChainResolver({ + queuePath: 'root.production.team1', + propertyName: 'disable_preemption', + configData: configMap({ + 'yarn.scheduler.capacity.root.production.disable_preemption': 'true', + }), + }); + expect(result).toEqual({ + value: 'true', + source: 'queue', + sourcePath: 'root.production', + }); + }); + + it('resolves value from grandparent when parent is also unset', () => { + const result = parentChainResolver({ + queuePath: 'root.production.team1', + propertyName: 'disable_preemption', + configData: configMap({ + 'yarn.scheduler.capacity.root.disable_preemption': 'true', + }), + }); + expect(result).toEqual({ + value: 'true', + source: 'queue', + sourcePath: 'root', + }); + }); + + it('returns null when no ancestor has the value (no global fallback)', () => { + const result = parentChainResolver({ + queuePath: 'root.production.team1', + propertyName: 'disable_preemption', + configData: configMap({}), + }); + expect(result).toBeNull(); + }); + + it('does NOT fall through to global property with same suffix', () => { + const result = parentChainResolver({ + queuePath: 'root.production', + propertyName: 'disable_preemption', + configData: configMap({ + 'yarn.scheduler.capacity.disable_preemption': 'true', + }), + }); + expect(result).toBeNull(); + }); + + it('still returns inherited source when queue has explicit value (for "overrides" display)', () => { + const result = parentChainResolver({ + queuePath: 'root.production.team1', + propertyName: 'disable_preemption', + configData: configMap({ + 'yarn.scheduler.capacity.root.production.team1.disable_preemption': 'false', + 'yarn.scheduler.capacity.root.production.disable_preemption': 'true', + }), + }); + expect(result).toEqual({ + value: 'true', + source: 'queue', + sourcePath: 'root.production', + }); + }); + + it('returns null for root queue (no parent to walk to)', () => { + const result = parentChainResolver({ + queuePath: 'root', + propertyName: 'disable_preemption', + configData: configMap({}), + }); + expect(result).toBeNull(); + }); +}); + +describe('globalOnlyResolver', () => { + it('resolves value from global property (skips parent queues)', () => { + const result = globalOnlyResolver({ + queuePath: 'root.production.team1', + propertyName: 'minimum-user-limit-percent', + configData: configMap({ + 'yarn.scheduler.capacity.minimum-user-limit-percent': '100', + }), + }); + expect(result).toEqual({ + value: '100', + source: 'global', + }); + }); + + it('ignores parent queue values', () => { + const result = globalOnlyResolver({ + queuePath: 'root.production.team1', + propertyName: 'minimum-user-limit-percent', + configData: configMap({ + 'yarn.scheduler.capacity.root.production.minimum-user-limit-percent': '50', + }), + }); + expect(result).toBeNull(); + }); + + it('returns null when global is not set', () => { + const result = globalOnlyResolver({ + queuePath: 'root.production', + propertyName: 'minimum-user-limit-percent', + configData: configMap({}), + }); + expect(result).toBeNull(); + }); + + it('still returns global source when queue has explicit value', () => { + const result = globalOnlyResolver({ + queuePath: 'root.production', + propertyName: 'minimum-user-limit-percent', + configData: configMap({ + 'yarn.scheduler.capacity.root.production.minimum-user-limit-percent': '50', + 'yarn.scheduler.capacity.minimum-user-limit-percent': '100', + }), + }); + expect(result).toEqual({ + value: '100', + source: 'global', + }); + }); +}); + +describe('resolveInheritedValue delegation', () => { + it('returns null when no resolver is provided', () => { + const result = resolveInheritedValue({ + queuePath: 'root.production', + propertyName: 'capacity', + configData: configMap({ + 'yarn.scheduler.capacity.root.capacity': '100', + }), + }); + expect(result).toBeNull(); + }); + + it('delegates to the provided resolver with correct context', () => { + const resolver = vi.fn().mockReturnValue({ value: '500', source: 'global' as const }); + const data = configMap({ 'yarn.scheduler.capacity.maximum-applications': '10000' }); + const staged = [ + { + id: '1', + queuePath: 'root.production', + property: 'capacity', + newValue: '50', + type: 'update' as const, + timestamp: Date.now(), + }, + ]; + + resolveInheritedValue({ + queuePath: 'root.production', + propertyName: 'maximum-applications', + configData: data, + stagedChanges: staged, + inheritanceResolver: resolver, + }); + + expect(resolver).toHaveBeenCalledWith({ + queuePath: 'root.production', + propertyName: 'maximum-applications', + configData: data, + stagedChanges: staged, + }); + }); + + it('returns resolver result unchanged', () => { + const expected = { value: '200', source: 'global' as const, isScaled: true }; + const resolver = vi.fn().mockReturnValue(expected); + + const result = resolveInheritedValue({ + queuePath: 'root.production', + propertyName: 'maximum-applications', + configData: configMap({}), + inheritanceResolver: resolver, + }); + + expect(result).toEqual(expected); + }); + + it('returns null when resolver returns null', () => { + const resolver = vi.fn().mockReturnValue(null); + + const result = resolveInheritedValue({ + queuePath: 'root.production', + propertyName: 'maximum-applications', + configData: configMap({}), + inheritanceResolver: resolver, + }); + + expect(result).toBeNull(); + }); +}); + +describe('staged changes', () => { + it('considers parent staged changes via parentChainResolver', () => { + const result = resolveInheritedValue({ + queuePath: 'root.production.team1', + propertyName: 'disable_preemption', + configData: configMap({}), + inheritanceResolver: parentChainResolver, + stagedChanges: [ + { + id: '1', + queuePath: 'root.production', + property: 'disable_preemption', + newValue: 'true', + type: 'update', + timestamp: Date.now(), + }, + ], + }); + expect(result).toEqual({ + value: 'true', + source: 'queue', + sourcePath: 'root.production', + }); + }); + + it('considers global staged changes via globalOnlyResolver', () => { + const result = resolveInheritedValue({ + queuePath: 'root.production', + propertyName: 'minimum-user-limit-percent', + configData: configMap({}), + inheritanceResolver: globalOnlyResolver, + stagedChanges: [ + { + id: '1', + queuePath: 'global', + property: 'minimum-user-limit-percent', + newValue: '75', + type: 'update', + timestamp: Date.now(), + }, + ], + }); + expect(result).toEqual({ + value: '75', + source: 'global', + }); + }); +}); diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/utils/resolveInheritedValue.ts b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/utils/resolveInheritedValue.ts new file mode 100644 index 0000000000000..8f0803cd94a41 --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/utils/resolveInheritedValue.ts @@ -0,0 +1,123 @@ +/** + * 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 type { InheritanceResolver, InheritedValueInfo } from '~/types/property-descriptor'; +import type { StagedChange } from '~/types'; +import { SPECIAL_VALUES } from '~/types'; +import { buildPropertyKey, buildGlobalPropertyKey, getParentQueuePath } from './propertyUtils'; + +export type { InheritedValueInfo } from '~/types/property-descriptor'; + +interface ResolveOptions { + queuePath: string; + propertyName: string; + configData: Map; + stagedChanges?: StagedChange[]; + inheritanceResolver?: InheritanceResolver; +} + +export function getQueueValue( + queuePath: string, + propertyName: string, + configData: Map, + stagedChanges?: StagedChange[], +): string | undefined { + const staged = stagedChanges?.find( + (c) => c.queuePath === queuePath && c.property === propertyName && c.newValue !== undefined, + ); + if (staged) { + return staged.newValue; + } + + const key = buildPropertyKey(queuePath, propertyName); + const value = configData.get(key); + return value || undefined; +} + +export function getGlobalValue( + propertyName: string, + configData: Map, + stagedChanges?: StagedChange[], +): string | undefined { + const staged = stagedChanges?.find( + (c) => + c.queuePath === SPECIAL_VALUES.GLOBAL_QUEUE_PATH && + c.property === propertyName && + c.newValue !== undefined, + ); + if (staged) { + return staged.newValue; + } + + const key = buildGlobalPropertyKey(propertyName); + const value = configData.get(key); + return value || undefined; +} + +/** + * Walks parent queues to find the nearest ancestor with the property set. + * No global fallback — the global key is often a different property entirely (e.g., preemption). + */ +export const parentChainResolver: InheritanceResolver = ({ + queuePath, + propertyName, + configData, + stagedChanges, +}) => { + let currentPath = getParentQueuePath(queuePath); + while (currentPath) { + const parentValue = getQueueValue(currentPath, propertyName, configData, stagedChanges); + if (parentValue !== undefined) { + return { + value: parentValue, + source: 'queue', + sourcePath: currentPath, + }; + } + currentPath = getParentQueuePath(currentPath); + } + return null; +}; + +/** + * Checks the global yarn.scheduler.capacity. property. Skips parent queues. + */ +export const globalOnlyResolver: InheritanceResolver = ({ + propertyName, + configData, + stagedChanges, +}) => { + const value = getGlobalValue(propertyName, configData, stagedChanges); + if (value !== undefined) { + return { value, source: 'global' }; + } + return null; +}; + +/** + * Resolves the inherited value source for a queue property by delegating + * to the property's inheritanceResolver. Returns null if no resolver is set + * or the resolver finds no inherited value. + */ +export function resolveInheritedValue(options: ResolveOptions): InheritedValueInfo | null { + const { inheritanceResolver, ...context } = options; + if (!inheritanceResolver) { + return null; + } + return inheritanceResolver(context); +}