This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 0156816b87d NIFI-16543: Wait for every recursive Controller Service
reference (#11887)
0156816b87d is described below
commit 0156816b87d09da101c40a20d5309a3bda983cbf
Author: Rob Fellows <[email protected]>
AuthorDate: Wed Nov 4 16:29:09 2026 -0500
NIFI-16543: Wait for every recursive Controller Service reference (#11887)
Enable and disable could advance after one sibling Controller Service
branch completed. Require every sibling and nested branch to reach the accepted
state, and stop recursion at referenceCycle nodes whose children the server
omits. Add a regression spec covering mixed sibling and nested states, active
threads, empty references, cycles, and unauthorized descendants.
---
.../controller-service-state.effects.spec.ts | 416 +++++++++++++++++++++
.../controller-service-state.effects.ts | 120 +++---
2 files changed, 482 insertions(+), 54 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.spec.ts
new file mode 100644
index 00000000000..d478f674255
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.spec.ts
@@ -0,0 +1,416 @@
+/*
+ * 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 { TestBed } from '@angular/core/testing';
+import { ReplaySubject } from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ControllerServiceReferencingComponentEntity, Permissions } from
'@nifi/shared';
+
+import { ControllerServiceStateEffects } from
'./controller-service-state.effects';
+import { ControllerServiceStateService } from
'../../service/controller-service-state.service';
+import { ErrorHelper } from '../../service/error-helper.service';
+import { ControllerServiceEntity } from '../shared';
+import { SetEnableRequest, SetEnableStep } from './index';
+
+describe('ControllerServiceStateEffects', () => {
+ const authorizedPermissions: Permissions = { canRead: true, canWrite: true
};
+ const unauthorizedPermissions: Permissions = { canRead: true, canWrite:
false };
+
+ function createService(
+ id: string,
+ state: string,
+ children: ControllerServiceReferencingComponentEntity[] = [],
+ options: {
+ referenceCycle?: boolean;
+ permissions?: Permissions;
+ omitReferencingComponents?: boolean;
+ } = {}
+ ): ControllerServiceReferencingComponentEntity {
+ const component:
ControllerServiceReferencingComponentEntity['component'] = {
+ groupId: 'group-1',
+ id,
+ name: id,
+ type: 'org.apache.nifi.TestService',
+ state,
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referenceType: 'ControllerService',
+ referenceCycle: options.referenceCycle ?? false,
+ ...(options.omitReferencingComponents ? {} : {
referencingComponents: children })
+ };
+
+ return {
+ id,
+ revision: { version: 1 },
+ permissions: options.permissions ?? authorizedPermissions,
+ bulletins: [],
+ operatePermissions: authorizedPermissions,
+ component
+ };
+ }
+
+ function createSchedulable(
+ id: string,
+ referenceType: 'Processor' | 'ReportingTask',
+ state: string,
+ activeThreadCount?: number
+ ): ControllerServiceReferencingComponentEntity {
+ return {
+ id,
+ revision: { version: 1 },
+ permissions: authorizedPermissions,
+ bulletins: [],
+ operatePermissions: authorizedPermissions,
+ component: {
+ groupId: 'group-1',
+ id,
+ name: id,
+ type: `org.apache.nifi.${referenceType}`,
+ state,
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referenceType,
+ referenceCycle: false,
+ referencingComponents: [],
+ ...(activeThreadCount === undefined ? {} : { activeThreadCount
})
+ }
+ };
+ }
+
+ function createControllerServiceEntity(
+ referencingComponents: ControllerServiceReferencingComponentEntity[],
+ runStatus = 'ENABLED'
+ ): ControllerServiceEntity {
+ return {
+ id: 'root-service',
+ uri: '/controller-services/root-service',
+ permissions: authorizedPermissions,
+ revision: { version: 1 },
+ bulletins: [],
+ status: { runStatus, validationStatus: 'VALID' },
+ component: {
+ id: 'root-service',
+ name: 'Root Service',
+ type: 'org.apache.nifi.RootService',
+ state: runStatus,
+ referencingComponents
+ }
+ };
+ }
+
+ function createSetEnableRequest(
+ currentStep: SetEnableStep,
+ enable: boolean,
+ scope: SetEnableRequest['scope'] = 'SERVICE_AND_REFERENCING_COMPONENTS'
+ ): SetEnableRequest {
+ return {
+ enable,
+ currentStep,
+ scope
+ };
+ }
+
+ async function setup() {
+ const actions$ = new ReplaySubject<Action>(1);
+
+ await TestBed.configureTestingModule({
+ providers: [
+ ControllerServiceStateEffects,
+ provideMockActions(() => actions$),
+ provideMockStore(),
+ {
+ provide: ControllerServiceStateService,
+ useValue: {
+ setEnable: vi.fn(),
+ getControllerService: vi.fn(),
+ updateReferencingServices: vi.fn(),
+ updateReferencingSchedulableComponents: vi.fn()
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ getErrorString: vi.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: vi.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const effects = TestBed.inject(ControllerServiceStateEffects);
+
+ return { effects, actions$ };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isReferencingServicesActionComplete / getNextStep enable', () =>
{
+ it('stays at EnableReferencingServices when one sibling remains
DISABLED', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'ENABLED'),
+ createService('s2', 'DISABLED')
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.EnableReferencingServices, true),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.EnableReferencingServices);
+ });
+
+ it('advances to StartReferencingComponents when every sibling is
ENABLING or ENABLED', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'ENABLING'),
+ createService('s2', 'ENABLED')
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.EnableReferencingServices, true),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.StartReferencingComponents);
+ });
+
+ it('stays at EnableReferencingServices when a nested service remains
DISABLED', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'ENABLED', [createService('s1a',
'DISABLED')]),
+ createService('s2', 'ENABLED')
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.EnableReferencingServices, true),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.EnableReferencingServices);
+ });
+ });
+
+ describe('isReferencingServicesActionComplete / getNextStep disable', ()
=> {
+ it('stays at DisableReferencingServices when one sibling remains
DISABLING', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'DISABLED'),
+ createService('s2', 'DISABLING')
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.DisableReferencingServices, false),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.DisableReferencingServices);
+ });
+
+ it('advances to DisableService when every sibling and nested service
is DISABLED', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'DISABLED', [createService('s1a',
'DISABLED')]),
+ createService('s2', 'DISABLED')
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.DisableReferencingServices, false),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.DisableService);
+ });
+ });
+
+ describe('areReferencingComponentsStopped / getNextStep stop', () => {
+ it('stays at StopReferencingComponents when one nested branch still
has a RUNNING processor', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'ENABLED', [createSchedulable('p1',
'Processor', 'STOPPED')]),
+ createService('s2', 'ENABLED', [createSchedulable('p2',
'Processor', 'RUNNING')])
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.StopReferencingComponents, false),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.StopReferencingComponents);
+ });
+
+ it('stays at StopReferencingComponents when a nested reporting task
has active threads', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'ENABLED', [createSchedulable('rt1',
'ReportingTask', 'STOPPED', 2)])
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.StopReferencingComponents, false),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.StopReferencingComponents);
+ });
+
+ it('advances to DisableReferencingServices when every nested branch is
stopped with zero threads', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createService('s1', 'ENABLED', [createSchedulable('p1',
'Processor', 'STOPPED', 0)]),
+ createService('s2', 'ENABLED', [createSchedulable('rt1',
'ReportingTask', 'STOPPED')])
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.StopReferencingComponents, false),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.DisableReferencingServices);
+ });
+
+ it('stays at StopReferencingComponents when a direct processor is
RUNNING', async () => {
+ const { effects } = await setup();
+ const controllerService = createControllerServiceEntity([
+ createSchedulable('p1', 'Processor', 'RUNNING'),
+ createService('s1', 'ENABLED', [createSchedulable('p2',
'Processor', 'STOPPED')])
+ ]);
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.StopReferencingComponents, false),
+ controllerService
+ );
+
+ expect(nextStep).toBe(SetEnableStep.StopReferencingComponents);
+ });
+ });
+
+ describe('empty references and reference cycles', () => {
+ it('treats empty referencing components as complete for enable and
stop checks', async () => {
+ const { effects } = await setup();
+
+ expect(effects.isReferencingServicesActionComplete([], ['ENABLED',
'ENABLING'])).toBe(true);
+ expect(effects.isReferencingServicesActionComplete(undefined,
['DISABLED'])).toBe(true);
+ expect(effects.areReferencingComponentsStopped([])).toBe(true);
+
expect(effects.areReferencingComponentsStopped(undefined)).toBe(true);
+ expect(effects.hasUnauthorizedReferences([])).toBe(false);
+ expect(effects.hasUnauthorizedReferences(undefined)).toBe(false);
+ });
+
+ it('treats a leaf service with an empty referencingComponents list as
complete', async () => {
+ const { effects } = await setup();
+ const leaf = [createService('s1', 'ENABLED', [])];
+
+ expect(effects.isReferencingServicesActionComplete(leaf,
['ENABLED', 'ENABLING'])).toBe(true);
+ expect(effects.areReferencingComponentsStopped(leaf)).toBe(true);
+ });
+
+ it('does not throw on cycle nodes and treats an accepted cycle sibling
as complete', async () => {
+ const { effects } = await setup();
+ const referencingComponents = [
+ createService('s1', 'ENABLED'),
+ createService('s-cycle', 'ENABLED', [], {
+ referenceCycle: true,
+ omitReferencingComponents: true
+ })
+ ];
+
+ expect(() =>
+
effects.isReferencingServicesActionComplete(referencingComponents, ['ENABLED',
'ENABLING'])
+ ).not.toThrow();
+
expect(effects.isReferencingServicesActionComplete(referencingComponents,
['ENABLED', 'ENABLING'])).toBe(
+ true
+ );
+
+ expect(() =>
effects.areReferencingComponentsStopped(referencingComponents)).not.toThrow();
+
expect(effects.areReferencingComponentsStopped(referencingComponents)).toBe(true);
+
+ expect(() =>
effects.hasUnauthorizedReferences(referencingComponents)).not.toThrow();
+
expect(effects.hasUnauthorizedReferences(referencingComponents)).toBe(false);
+ });
+
+ it('returns false when a cycle node is outside the accepted state',
async () => {
+ const { effects } = await setup();
+ const referencingComponents = [
+ createService('s1', 'ENABLED'),
+ createService('s-cycle', 'DISABLED', [], {
+ referenceCycle: true,
+ omitReferencingComponents: true
+ })
+ ];
+
+
expect(effects.isReferencingServicesActionComplete(referencingComponents,
['ENABLED', 'ENABLING'])).toBe(
+ false
+ );
+ });
+
+ it('keeps waiting when a service is expanded under one sibling and
flagged under another', async () => {
+ const { effects } = await setup();
+ const expanded = createService('shared', 'ENABLED',
[createService('child', 'DISABLED')]);
+ const flagged = createService('shared', 'ENABLED', [], {
+ referenceCycle: true,
+ omitReferencingComponents: true
+ });
+
+ const nextStep = effects.getNextStep(
+
createSetEnableRequest(SetEnableStep.EnableReferencingServices, true),
+ createControllerServiceEntity([
+ createService('s1', 'ENABLED', [expanded]),
+ createService('s2', 'ENABLED', [flagged])
+ ])
+ );
+
+ expect(nextStep).toBe(SetEnableStep.EnableReferencingServices);
+ });
+
+ it('detects unauthorized permissions without recursing into cycle
nodes', async () => {
+ const { effects } = await setup();
+ const referencingComponents = [
+ createService('s1', 'ENABLED', [], { permissions:
unauthorizedPermissions }),
+ createService('s-cycle', 'ENABLED', [], {
+ referenceCycle: true,
+ omitReferencingComponents: true
+ })
+ ];
+
+ expect(() =>
effects.hasUnauthorizedReferences(referencingComponents)).not.toThrow();
+
expect(effects.hasUnauthorizedReferences(referencingComponents)).toBe(true);
+ });
+
+ it('detects unauthorized permissions in a nested descendant', async ()
=> {
+ const { effects } = await setup();
+ const referencingComponents = [
+ createService('s1', 'ENABLED', [
+ createService('s1a', 'ENABLED', [], { permissions:
unauthorizedPermissions })
+ ])
+ ];
+
+
expect(effects.hasUnauthorizedReferences(referencingComponents)).toBe(true);
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.ts
index 88a940a3a2f..c8391854cdd 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/contoller-service-state/controller-service-state.effects.ts
@@ -26,12 +26,27 @@ import { selectControllerService,
selectControllerServiceSetEnableRequest } from
import { OkDialog } from '../../ui/common/ok-dialog/ok-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { ControllerServiceStateService } from
'../../service/controller-service-state.service';
-import { ControllerServiceEntity, ControllerServiceReferencingComponentEntity
} from '../shared';
+import { ControllerServiceEntity } from '../shared';
import { SetEnableRequest, SetEnableStep } from './index';
-import { isDefinedAndNotNull, MEDIUM_DIALOG } from '@nifi/shared';
+import {
+ ControllerServiceReferencingComponentDTO,
+ ControllerServiceReferencingComponentEntity,
+ isDefinedAndNotNull,
+ MEDIUM_DIALOG
+} from '@nifi/shared';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHelper } from '../../service/error-helper.service';
+type ReferencingControllerServiceEntity =
ControllerServiceReferencingComponentEntity & {
+ component: ControllerServiceReferencingComponentDTO;
+};
+
+function isReferencingControllerService(
+ referencingComponent: ControllerServiceReferencingComponentEntity
+): referencingComponent is ReferencingControllerServiceEntity {
+ return referencingComponent.component?.referenceType ===
'ControllerService';
+}
+
@Injectable()
export class ControllerServiceStateEffects {
private actions$ = inject(Actions);
@@ -332,14 +347,18 @@ export class ControllerServiceStateEffects {
{ dispatch: false }
);
- hasUnauthorizedReferences(referencingComponents:
ControllerServiceReferencingComponentEntity[]): boolean {
+ hasUnauthorizedReferences(
+ referencingComponents: ControllerServiceReferencingComponentEntity[] |
undefined
+ ): boolean {
+ const components = referencingComponents ?? [];
+
// if there are no referencing components there is nothing unauthorized
- if (referencingComponents.length === 0) {
+ if (components.length === 0) {
return false;
}
// identify any unauthorized referencing components
- const unauthorized: boolean =
referencingComponents.some((referencingComponentEntity) => {
+ const unauthorized: boolean =
components.some((referencingComponentEntity) => {
return !referencingComponentEntity.permissions.canRead ||
!referencingComponentEntity.permissions.canWrite;
});
@@ -349,19 +368,17 @@ export class ControllerServiceStateEffects {
}
// consider the components that are referencing the referencingServices
- const referencingServices =
referencingComponents.filter((referencingComponent) => {
- return referencingComponent.component.referenceType ===
'ControllerService';
- });
+ const referencingServices =
components.filter(isReferencingControllerService);
- if (referencingServices.length === 0) {
- // if there are no more nested services all references are
authorized
- return false;
- } else {
- // if there are nested services, check if they have any
referencing components are unauthorized
- return referencingServices.some((referencingService) => {
- return
this.hasUnauthorizedReferences(referencingService.component.referencingComponents);
- });
- }
+ // if there are nested services, check if they have any referencing
components that are unauthorized;
+ // a cycle is evaluated where the service was first encountered
+ return referencingServices.some((referencingService) => {
+ const referencingComponent = referencingService.component;
+ return (
+ !referencingComponent.referenceCycle &&
+
this.hasUnauthorizedReferences(referencingComponent.referencingComponents)
+ );
+ });
}
getNextStep(request: SetEnableRequest, controllerServiceEntity:
ControllerServiceEntity): SetEnableStep {
@@ -407,45 +424,43 @@ export class ControllerServiceStateEffects {
}
isReferencingServicesActionComplete(
- referencingComponents: ControllerServiceReferencingComponentEntity[],
+ referencingComponents: ControllerServiceReferencingComponentEntity[] |
undefined,
acceptedRunStatus: string[]
): boolean {
- const referencingServices =
referencingComponents.filter((referencingComponent) => {
- return referencingComponent.component.referenceType ===
'ControllerService';
- });
-
- if (referencingServices.length === 0) {
- return true;
- }
-
- return referencingServices.some((referencingService) => {
- const isEnabled: boolean =
acceptedRunStatus.includes(referencingService.component.state);
-
- if (isEnabled) {
- // if this service isn't enabled, there is no need to check
further...
- return this.isReferencingServicesActionComplete(
- referencingService.component.referencingComponents,
- acceptedRunStatus
- );
+ const referencingServices = (referencingComponents ??
[]).filter(isReferencingControllerService);
+
+ // every referencing service, and every service beneath it, must reach
an accepted state;
+ // a cycle is evaluated where the service was first encountered
+ return referencingServices.every((referencingService) => {
+ const referencingComponent = referencingService.component;
+ if (referencingComponent.state === undefined ||
!acceptedRunStatus.includes(referencingComponent.state)) {
+ return false;
}
- return isEnabled;
+ return (
+ referencingComponent.referenceCycle ||
+
this.isReferencingServicesActionComplete(referencingComponent.referencingComponents,
acceptedRunStatus)
+ );
});
}
- areReferencingComponentsStopped(referencingComponents:
ControllerServiceReferencingComponentEntity[]): boolean {
+ areReferencingComponentsStopped(
+ referencingComponents: ControllerServiceReferencingComponentEntity[] |
undefined
+ ): boolean {
+ const components = referencingComponents ?? [];
+
// consider the schedulable components in the referencingComponents
- const referencingScheduleableComponents =
referencingComponents.filter((referencingComponent) => {
+ const referencingScheduleableComponents =
components.filter((referencingComponent) => {
return (
- referencingComponent.component.referenceType === 'Processor' ||
- referencingComponent.component.referenceType ===
'ReportingTask'
+ referencingComponent.component?.referenceType === 'Processor'
||
+ referencingComponent.component?.referenceType ===
'ReportingTask'
);
});
const stillRunning: boolean =
referencingScheduleableComponents.some((referencingComponentEntity) => {
const referencingComponent = referencingComponentEntity.component;
return (
- referencingComponent.state === 'RUNNING' ||
- (referencingComponent.activeThreadCount &&
referencingComponent.activeThreadCount > 0)
+ referencingComponent?.state === 'RUNNING' ||
+ (referencingComponent?.activeThreadCount !== undefined &&
referencingComponent.activeThreadCount > 0)
);
});
@@ -455,18 +470,15 @@ export class ControllerServiceStateEffects {
}
// consider the scheduleable components that are referencing the
referencingServices
- const referencingServices =
referencingComponents.filter((referencingComponent) => {
- return referencingComponent.component.referenceType ===
'ControllerService';
- });
+ const referencingServices =
components.filter(isReferencingControllerService);
- if (referencingServices.length === 0) {
- // if there are no more nested services all schedulable components
have stopped
- return true;
- } else {
- // if there are nested services, check if they have any
referencing components that are still running
- return referencingServices.some((referencingService) => {
- return
this.areReferencingComponentsStopped(referencingService.component.referencingComponents);
- });
- }
+ // every nested branch must have stopped; a cycle is evaluated where
the service was first encountered
+ return referencingServices.every((referencingService) => {
+ const referencingComponent = referencingService.component;
+ return (
+ referencingComponent.referenceCycle ||
+
this.areReferencingComponentsStopped(referencingComponent.referencingComponents)
+ );
+ });
}
}