This is an automated email from the ASF dual-hosted git repository.

scottyaslan 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 edbc2ae6ed5 NIFI-16550: Guard Controller Service revision collection 
against reference cycles (#11893)
edbc2ae6ed5 is described below

commit edbc2ae6ed5d035a48a7965725268ac9f1a5f7c5
Author: Rob Fellows <[email protected]>
AuthorDate: Thu Nov 5 22:03:07 2026 -0500

    NIFI-16550: Guard Controller Service revision collection against reference 
cycles (#11893)
    
    getReferencingComponentRevisions recursed into referencingComponents
    without checking referenceCycle. The server currently omits children on
    cycle nodes, so recursion ended only because the missing list was
    treated as empty. Record the revision of each referencing component as
    before, then stop at a cycle node instead of walking its children.
    
    Use the canonical ControllerServiceReferencingComponentEntity type so an
    omitted component or children list is handled explicitly, and key the
    revision map by the entity id. The NiFiCommon empty check is no longer
    needed and is removed.
    
    Add controller-service-state.service.spec.ts covering the service-only
    and schedulable walks, including a cycle node that still carries
    children and checking the revision value recorded for it.
---
 .../controller-service-state.service.spec.ts       | 205 +++++++++++++++++++++
 .../service/controller-service-state.service.ts    |  36 ++--
 2 files changed, 221 insertions(+), 20 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.spec.ts
new file mode 100644
index 00000000000..cdd8ffad771
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.spec.ts
@@ -0,0 +1,205 @@
+/*
+ * 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 { HttpTestingController, provideHttpClientTesting } from 
'@angular/common/http/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { ControllerServiceReferencingComponentEntity } from '@nifi/shared';
+import { ControllerServiceStateService } from 
'./controller-service-state.service';
+import { Client } from './client.service';
+import { ClusterConnectionService } from './cluster-connection.service';
+import { ControllerServiceEntity } from '../state/shared';
+
+describe('ControllerServiceStateService', () => {
+    function createReferencingEntity(
+        id: string,
+        referenceType: string,
+        children: ControllerServiceReferencingComponentEntity[] = [],
+        options: { referenceCycle?: boolean; omitReferencingComponents?: 
boolean; version?: number } = {}
+    ): ControllerServiceReferencingComponentEntity {
+        return {
+            id,
+            revision: { version: options.version ?? 1 },
+            permissions: { canRead: true, canWrite: true },
+            operatePermissions: { canRead: true, canWrite: true },
+            bulletins: [],
+            component: {
+                groupId: 'group-1',
+                id,
+                name: id,
+                type: referenceType,
+                state: 'ENABLED',
+                properties: {},
+                descriptors: {},
+                validationErrors: [],
+                referenceType,
+                referenceCycle: options.referenceCycle ?? false,
+                ...(options.omitReferencingComponents ? {} : { 
referencingComponents: children })
+            }
+        };
+    }
+
+    // the server omits referencingComponents entirely on leaf components
+    function createLeafEntity(id: string, referenceType: string): 
ControllerServiceReferencingComponentEntity {
+        return createReferencingEntity(id, referenceType, [], { 
omitReferencingComponents: true });
+    }
+
+    function createControllerServiceEntity(
+        referencingComponents: ControllerServiceReferencingComponentEntity[]
+    ): ControllerServiceEntity {
+        return {
+            id: 'cs-root',
+            uri: '../nifi-api/controller-services/cs-root',
+            permissions: { canRead: true, canWrite: true },
+            operatePermissions: { canRead: true, canWrite: true },
+            revision: { version: 1 },
+            bulletins: [],
+            status: { runStatus: 'ENABLED' },
+            component: { id: 'cs-root', referencingComponents }
+        };
+    }
+
+    async function setup() {
+        TestBed.configureTestingModule({
+            providers: [
+                ControllerServiceStateService,
+                provideHttpClient(),
+                provideHttpClientTesting(),
+                {
+                    provide: Client,
+                    useValue: {
+                        getRevision: (entity: 
ControllerServiceReferencingComponentEntity) => ({
+                            clientId: 'client-1',
+                            version: entity.revision.version
+                        })
+                    }
+                },
+                { provide: ClusterConnectionService, useValue: { 
isDisconnectionAcknowledged: () => false } }
+            ]
+        });
+
+        const service = TestBed.inject(ControllerServiceStateService);
+        const httpTesting = TestBed.inject(HttpTestingController);
+
+        return { service, httpTesting };
+    }
+
+    afterEach(() => {
+        TestBed.inject(HttpTestingController).verify();
+    });
+
+    describe('updateReferencingServices', () => {
+        it('includes nested Controller Services and omits processors', async 
() => {
+            const { service, httpTesting } = await setup();
+            const controllerService = createControllerServiceEntity([
+                createReferencingEntity('cs-1', 'ControllerService', [
+                    createReferencingEntity('cs-2', 'ControllerService'),
+                    createLeafEntity('proc-1', 'Processor')
+                ])
+            ]);
+
+            service.updateReferencingServices(controllerService, 
true).subscribe();
+
+            const req = 
httpTesting.expectOne('../nifi-api/controller-services/cs-root/references');
+            
expect(Object.keys(req.request.body.referencingComponentRevisions).sort()).toEqual(['cs-1',
 'cs-2']);
+            req.flush({});
+        });
+
+        it('includes a reference cycle node whose children the server omits', 
async () => {
+            const { service, httpTesting } = await setup();
+            const cycleNode = createReferencingEntity('cs-2', 
'ControllerService', [], {
+                referenceCycle: true,
+                omitReferencingComponents: true,
+                version: 7
+            });
+            const controllerService = createControllerServiceEntity([
+                createReferencingEntity('cs-1', 'ControllerService', 
[cycleNode])
+            ]);
+
+            service.updateReferencingServices(controllerService, 
true).subscribe();
+
+            const req = 
httpTesting.expectOne('../nifi-api/controller-services/cs-root/references');
+            
expect(Object.keys(req.request.body.referencingComponentRevisions).sort()).toEqual(['cs-1',
 'cs-2']);
+            
expect(req.request.body.referencingComponentRevisions['cs-2']).toEqual({
+                clientId: 'client-1',
+                version: 7
+            });
+            req.flush({});
+        });
+
+        it('includes a reference cycle node but does not walk children it 
still carries', async () => {
+            const { service, httpTesting } = await setup();
+            const cycleNode = createReferencingEntity(
+                'cs-2',
+                'ControllerService',
+                [createReferencingEntity('cs-3', 'ControllerService'), 
createLeafEntity('proc-1', 'Processor')],
+                { referenceCycle: true, version: 7 }
+            );
+            const controllerService = createControllerServiceEntity([
+                createReferencingEntity('cs-1', 'ControllerService', 
[cycleNode])
+            ]);
+
+            service.updateReferencingServices(controllerService, 
true).subscribe();
+
+            const req = 
httpTesting.expectOne('../nifi-api/controller-services/cs-root/references');
+            
expect(Object.keys(req.request.body.referencingComponentRevisions).sort()).toEqual(['cs-1',
 'cs-2']);
+            
expect(req.request.body.referencingComponentRevisions['cs-2']).toEqual({
+                clientId: 'client-1',
+                version: 7
+            });
+            req.flush({});
+        });
+    });
+
+    describe('updateReferencingSchedulableComponents', () => {
+        it('includes processors and reporting tasks beneath nested Controller 
Services', async () => {
+            const { service, httpTesting } = await setup();
+            const controllerService = createControllerServiceEntity([
+                createReferencingEntity('cs-1', 'ControllerService', [
+                    createReferencingEntity('cs-2', 'ControllerService', 
[createLeafEntity('proc-1', 'Processor')]),
+                    createLeafEntity('rt-1', 'ReportingTask')
+                ])
+            ]);
+
+            service.updateReferencingSchedulableComponents(controllerService, 
false).subscribe();
+
+            const req = 
httpTesting.expectOne('../nifi-api/controller-services/cs-root/references');
+            
expect(Object.keys(req.request.body.referencingComponentRevisions).sort()).toEqual(['proc-1',
 'rt-1']);
+            req.flush({});
+        });
+
+        it('does not walk children a reference cycle node still carries', 
async () => {
+            const { service, httpTesting } = await setup();
+            const cycleNode = createReferencingEntity(
+                'cs-2',
+                'ControllerService',
+                [createLeafEntity('proc-1', 'Processor')],
+                { referenceCycle: true }
+            );
+            const controllerService = createControllerServiceEntity([
+                createReferencingEntity('cs-1', 'ControllerService', 
[cycleNode]),
+                createLeafEntity('proc-2', 'Processor')
+            ]);
+
+            service.updateReferencingSchedulableComponents(controllerService, 
false).subscribe();
+
+            const req = 
httpTesting.expectOne('../nifi-api/controller-services/cs-root/references');
+            
expect(Object.keys(req.request.body.referencingComponentRevisions).sort()).toEqual(['proc-2']);
+            req.flush({});
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.ts
index ddfe97ead3c..62849c1276e 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/controller-service-state.service.ts
@@ -18,19 +18,14 @@
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
 import { HttpClient } from '@angular/common/http';
-import {
-    ControllerServiceEntity,
-    ControllerServiceReferencingComponent,
-    ControllerServiceReferencingComponentEntity
-} from '../state/shared';
+import { ControllerServiceReferencingComponentEntity } from '@nifi/shared';
+import { ControllerServiceEntity } from '../state/shared';
 import { Client } from './client.service';
-import { NiFiCommon } from '@nifi/shared';
 import { ClusterConnectionService } from './cluster-connection.service';
 
 @Injectable({ providedIn: 'root' })
 export class ControllerServiceStateService {
     private httpClient = inject(HttpClient);
-    private nifiCommon = inject(NiFiCommon);
     private client = inject(Client);
     private clusterConnectionService = inject(ClusterConnectionService);
 
@@ -106,36 +101,37 @@ export class ControllerServiceStateService {
      * @param serviceOnly - true includes only services, false includes only 
schedulable components
      */
     private getReferencingComponentRevisions(
-        referencingComponents: ControllerServiceReferencingComponentEntity[],
+        referencingComponents: ControllerServiceReferencingComponentEntity[] | 
undefined,
         referencingComponentRevisions: { [key: string]: any },
         serviceOnly: boolean
     ): void {
-        if (this.nifiCommon.isEmpty(referencingComponents)) {
-            return;
-        }
-
         // include the revision of each referencing component
-        referencingComponents.forEach((referencingComponentEntity) => {
-            const referencingComponent: ControllerServiceReferencingComponent 
= referencingComponentEntity.component;
+        (referencingComponents ?? []).forEach((referencingComponentEntity) => {
+            const referencingComponent = referencingComponentEntity.component;
 
             if (serviceOnly) {
-                if (referencingComponent.referenceType === 
'ControllerService') {
-                    referencingComponentRevisions[referencingComponent.id] =
+                if (referencingComponent?.referenceType === 
'ControllerService') {
+                    
referencingComponentRevisions[referencingComponentEntity.id] =
                         this.client.getRevision(referencingComponentEntity);
                 }
             } else {
                 if (
-                    referencingComponent.referenceType === 'Processor' ||
-                    referencingComponent.referenceType === 'ReportingTask'
+                    referencingComponent?.referenceType === 'Processor' ||
+                    referencingComponent?.referenceType === 'ReportingTask'
                 ) {
-                    referencingComponentRevisions[referencingComponent.id] =
+                    
referencingComponentRevisions[referencingComponentEntity.id] =
                         this.client.getRevision(referencingComponentEntity);
                 }
             }
 
+            // a reference cycle is evaluated where the service was first 
encountered, so its children are not walked again
+            if (referencingComponent?.referenceCycle) {
+                return;
+            }
+
             // recurse
             this.getReferencingComponentRevisions(
-                referencingComponent.referencingComponents,
+                referencingComponent?.referencingComponents,
                 referencingComponentRevisions,
                 serviceOnly
             );

Reply via email to