This is an automated email from the ASF dual-hosted git repository.
rfellows 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 f3a773b2136 NIFI-16489: Reconcile connector canvas after cluster
connection changes (#11842)
f3a773b2136 is described below
commit f3a773b2136e885be0230153137190b577481f1f
Author: Matt Gilman <[email protected]>
AuthorDate: Tue Oct 27 16:20:44 2026 -0400
NIFI-16489: Reconcile connector canvas after cluster connection changes
(#11842)
Preserve authoritative snapshot reconciliation across failed or cancelled
loads while retaining optimistic in-flight mutations.
This closes #11842
---
.../connector-canvas/connector-canvas.actions.ts | 9 +-
.../connector-canvas.effects.spec.ts | 120 +++++++++++-
.../connector-canvas/connector-canvas.effects.ts | 145 ++++++++------
.../connector-canvas.reducer.spec.ts | 209 +++++++++++++++++++--
.../connector-canvas/connector-canvas.reducer.ts | 95 +++++++---
.../connector-canvas/connector-canvas.selectors.ts | 10 +
.../connectors/state/connector-canvas/index.ts | 2 +
.../connector-canvas.component.spec.ts | 4 +-
8 files changed, 489 insertions(+), 105 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
index 20bb8331aac..9d99bc65eb5 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
@@ -69,6 +69,8 @@ export const loadConnectorFlow = createAction(
props<{ connectorId: string; processGroupId: string }>()
);
+export const markAuthoritativeSnapshotPending = createAction('[Connector
Canvas] Mark Authoritative Snapshot Pending');
+
export const loadConnectorFlowSuccess = createAction(
'[Connector Canvas] Load Connector Flow Success',
props<{
@@ -84,12 +86,17 @@ export const loadConnectorFlowSuccess = createAction(
processGroups: ProcessGroupEntity[];
processors: ProcessorEntity[];
connections: ConnectionEntity[];
+ reconciliationSnapshot: {
+ addedCache: string[];
+ removedCache: string[];
+ };
+ connectedStateChanged: boolean;
}>()
);
export const loadConnectorFlowFailure = createAction(
'[Connector Canvas] Load Connector Flow Failure',
- props<{ errorContext: ErrorContext; isRefresh: boolean }>()
+ props<{ errorContext: ErrorContext; isRefresh: boolean;
connectedStateChanged: boolean }>()
);
export const loadConnectorFlowComplete = createAction('[Connector Canvas] Load
Connector Flow Complete');
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
index 64561fb26b7..d3faa13eeaf 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
@@ -66,6 +66,7 @@ import {
loadConnectorFlowComplete,
loadConnectorFlowFailure,
loadConnectorFlowSuccess,
+ markAuthoritativeSnapshotPending,
loadConnectorParameterContext,
loadConnectorParameterContextFailure,
loadConnectorParameterContextSuccess,
@@ -107,7 +108,9 @@ import {
selectHasLoadedFlow,
selectLoadingStatus,
selectMaxConnectionZIndex,
+ selectOptimisticCacheSnapshot,
selectParentProcessGroupId,
+ selectPendingAuthoritativeSnapshot,
selectProcessGroupId,
selectProcessGroupIdFromRoute,
selectSaving
@@ -117,6 +120,8 @@ import { DocumentVisibility } from
'../../../../state/document-visibility';
import type { Mock } from 'vitest';
import { selectPrioritizerTypes } from
'../../../../state/extension-types/extension-types.selectors';
import { EditConnectionComponent } from
'../../../../ui/common/component-dialogs/edit-connection/edit-connection.component';
+import { resetConnectedStateChanged } from
'../../../../state/cluster-summary/cluster-summary.actions';
+import { selectConnectedStateChanged } from
'../../../../state/cluster-summary/cluster-summary.selectors';
describe('ConnectorCanvasEffects', () => {
// Setup function following SIFERS pattern
@@ -129,6 +134,10 @@ describe('ConnectorCanvasEffects', () => {
documentVisibility?: DocumentVisibility;
loadingStatus?: 'pending' | 'loading' | 'success' |
'refresh-error' | 'error';
hasLoadedFlow?: boolean;
+ connectedStateChanged?: boolean;
+ pendingAuthoritativeSnapshot?: boolean;
+ addedCache?: string[];
+ removedCache?: string[];
parameterContext?: ParameterContextEntity | null;
parameterContextResponse?: ParameterContextEntity | null;
parameterContextError?: unknown;
@@ -217,6 +226,18 @@ describe('ConnectorCanvasEffects', () => {
{ selector: selectMaxConnectionZIndex, value: 4 },
{ selector: selectLoadingStatus, value: loadingStatus
},
{ selector: selectHasLoadedFlow, value:
options.hasLoadedFlow ?? false },
+ { selector: selectConnectedStateChanged, value:
options.connectedStateChanged ?? false },
+ {
+ selector: selectPendingAuthoritativeSnapshot,
+ value: options.pendingAuthoritativeSnapshot ??
false
+ },
+ {
+ selector: selectOptimisticCacheSnapshot,
+ value: {
+ addedCache: options.addedCache ?? [],
+ removedCache: options.removedCache ?? []
+ }
+ },
{
selector: selectDocumentVisibilityState,
value: { documentVisibility, changedTimestamp: 0 }
@@ -549,7 +570,10 @@ describe('ConnectorCanvasEffects', () => {
describe('loadConnectorFlow$', () => {
it('should call getConnectorFlow and dispatch
loadConnectorFlowSuccess', async () => {
- const { effects, actions$, mockConnectorService } = await setup();
+ const { effects, actions$, mockConnectorService, store } = await
setup({
+ connectedStateChanged: true
+ });
+ const dispatch = vi.spyOn(store, 'dispatch');
const label = makeLabel('l1', { position: { x: 10, y: 20 } });
const flowResponse = {
processGroupFlow: {
@@ -593,9 +617,14 @@ describe('ConnectorCanvasEffects', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: true
})
);
+
expect(dispatch).toHaveBeenCalledWith(markAuthoritativeSnapshotPending());
+
expect(dispatch).toHaveBeenCalledWith(resetConnectedStateChanged());
+ expect(dispatch).toHaveBeenCalledTimes(2);
});
it('should default flow collections when processGroupFlow.flow is
missing', async () => {
@@ -631,7 +660,73 @@ describe('ConnectorCanvasEffects', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: false
+ })
+ );
+ });
+
+ it('should preserve a pending authoritative snapshot after the cluster
signal has been reset', async () => {
+ const { effects, actions$, mockConnectorService } = await setup({
+ pendingAuthoritativeSnapshot: true
+ });
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of({
+ processGroupFlow: {
+ id: 'pg-123',
+ parentGroupId: null,
+ breadcrumb: null
+ }
+ })
+ );
+ actions$(of(loadConnectorFlow({ connectorId: 'conn-a',
processGroupId: 'pg-in' })));
+
+ const action = await firstValueFrom(effects.loadConnectorFlow$);
+
+ expect(action).toEqual(expect.objectContaining({
connectedStateChanged: true }));
+ });
+
+ it('should mark the authoritative snapshot pending before the request
completes', async () => {
+ const { effects, actions$, mockConnectorService, store } = await
setup({
+ connectedStateChanged: true
+ });
+ const dispatch = vi.spyOn(store, 'dispatch');
+ const response$ = new Subject<ProcessGroupFlowEntity>();
+ (mockConnectorService.getConnectorFlow as
Mock).mockReturnValue(response$);
+ actions$(of(loadConnectorFlow({ connectorId: 'conn-a',
processGroupId: 'pg-in' })));
+
+ const subscription = effects.loadConnectorFlow$.subscribe();
+
+
expect(dispatch).toHaveBeenCalledWith(markAuthoritativeSnapshotPending());
+
expect(dispatch).toHaveBeenCalledWith(resetConnectedStateChanged());
+ subscription.unsubscribe();
+ });
+
+ it('should capture optimistic caches when the request begins', async
() => {
+ const { effects, actions$, mockConnectorService } = await setup({
+ addedCache: ['proc-created'],
+ removedCache: ['proc-deleted']
+ });
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of({
+ processGroupFlow: {
+ id: 'pg-123',
+ parentGroupId: null,
+ breadcrumb: null
+ }
+ })
+ );
+ actions$(of(loadConnectorFlow({ connectorId: 'conn-a',
processGroupId: 'pg-in' })));
+
+ const action = await firstValueFrom(effects.loadConnectorFlow$);
+
+ expect(action).toEqual(
+ expect.objectContaining({
+ reconciliationSnapshot: {
+ addedCache: ['proc-created'],
+ removedCache: ['proc-deleted']
+ }
})
);
});
@@ -657,13 +752,17 @@ describe('ConnectorCanvasEffects', () => {
errors: ['Error message'],
context: ErrorContextKey.CONNECTORS
},
- isRefresh: false
+ isRefresh: false,
+ connectedStateChanged: false
})
);
});
it('should dispatch a recoverable failure and banner when a refresh
fails', async () => {
- const { effects, actions$, mockConnectorService } = await setup({
hasLoadedFlow: true });
+ const { effects, actions$, mockConnectorService } = await setup({
+ hasLoadedFlow: true,
+ connectedStateChanged: true
+ });
const errorResponse = new HttpErrorResponse({ error: 'Failed',
status: 503, statusText: 'Unavailable' });
(mockConnectorService.getConnectorFlow as
Mock).mockReturnValue(throwError(() => errorResponse));
actions$(
@@ -680,7 +779,8 @@ describe('ConnectorCanvasEffects', () => {
expect(emitted[0]).toEqual(
expect.objectContaining({
type: loadConnectorFlowFailure.type,
- isRefresh: true
+ isRefresh: true,
+ connectedStateChanged: true
})
);
expect(emitted[1]).toEqual(
@@ -709,7 +809,9 @@ describe('ConnectorCanvasEffects', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [],
removedCache: [] },
+ connectedStateChanged: false
})
)
);
@@ -1667,7 +1769,9 @@ describe('ConnectorCanvasEffects', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: [] },
+ connectedStateChanged: false
});
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
index af00b696be2..1d94424a93a 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
@@ -91,8 +91,10 @@ import {
selectInputPort,
selectLoadingStatus,
selectMaxConnectionZIndex,
+ selectOptimisticCacheSnapshot,
selectOutputPort,
selectParentProcessGroupId,
+ selectPendingAuthoritativeSnapshot,
selectProcessGroup,
selectProcessGroupId,
selectProcessGroupIdFromRoute,
@@ -106,6 +108,8 @@ import type { ConnectorCanvasComponentEntity } from
'./index';
import { ClusterConnectionService } from
'../../../../service/cluster-connection.service';
import { ConnectorEditService } from '../../service/connector-edit.service';
import { HttpErrorResponse } from '@angular/common/http';
+import { resetConnectedStateChanged } from
'../../../../state/cluster-summary/cluster-summary.actions';
+import { selectConnectedStateChanged } from
'../../../../state/cluster-summary/cluster-summary.selectors';
/**
* Shallow-copies `entity` with `permissions.canWrite` forced to false.
@@ -173,68 +177,91 @@ export class ConnectorCanvasEffects {
loadConnectorFlow$ = createEffect(() =>
this.actions$.pipe(
ofType(ConnectorCanvasActions.loadConnectorFlow),
- concatLatestFrom(() => this.store.select(selectHasLoadedFlow)),
- switchMap(([action, hasLoadedFlow]) =>
- this.connectorService.getConnectorFlow(action.connectorId,
action.processGroupId).pipe(
- map((flowResponse) => {
- const processGroupFlow = flowResponse.processGroupFlow;
- const flow = processGroupFlow?.flow;
- const {
- labels,
- funnels,
- inputPorts,
- outputPorts,
- remoteProcessGroups,
- processGroups,
- processors,
- connections
- } = this.sanitizeConnectorFlowPositions(flow);
+ concatLatestFrom(() => [
+ this.store.select(selectHasLoadedFlow),
+ this.store.select(selectConnectedStateChanged),
+ this.store.select(selectPendingAuthoritativeSnapshot),
+ this.store.select(selectOptimisticCacheSnapshot)
+ ]),
+ tap(([, , clusterConnectedStateChanged]) => {
+ if (clusterConnectedStateChanged) {
+
this.store.dispatch(ConnectorCanvasActions.markAuthoritativeSnapshotPending());
+ }
+ this.store.dispatch(resetConnectedStateChanged());
+ }),
+ switchMap(
+ ([
+ action,
+ hasLoadedFlow,
+ clusterConnectedStateChanged,
+ pendingAuthoritativeSnapshot,
+ reconciliationSnapshot
+ ]) => {
+ const connectedStateChanged = clusterConnectedStateChanged
|| pendingAuthoritativeSnapshot;
+ return
this.connectorService.getConnectorFlow(action.connectorId,
action.processGroupId).pipe(
+ map((flowResponse) => {
+ const processGroupFlow =
flowResponse.processGroupFlow;
+ const flow = processGroupFlow?.flow;
+ const {
+ labels,
+ funnels,
+ inputPorts,
+ outputPorts,
+ remoteProcessGroups,
+ processGroups,
+ processors,
+ connections
+ } = this.sanitizeConnectorFlowPositions(flow);
- // Extract process group IDs for navigation
- const processGroupId = processGroupFlow?.id ?? null;
- const parentProcessGroupId =
processGroupFlow?.parentGroupId ?? null;
+ // Extract process group IDs for navigation
+ const processGroupId = processGroupFlow?.id ??
null;
+ const parentProcessGroupId =
processGroupFlow?.parentGroupId ?? null;
- // Extract breadcrumb for navigation trail
- const breadcrumb = processGroupFlow?.breadcrumb ??
null;
+ // Extract breadcrumb for navigation trail
+ const breadcrumb = processGroupFlow?.breadcrumb ??
null;
- return
ConnectorCanvasActions.loadConnectorFlowSuccess({
- connectorId: action.connectorId,
- processGroupId,
- parentProcessGroupId,
- breadcrumb,
- labels,
- funnels,
- inputPorts,
- outputPorts,
- remoteProcessGroups,
- processGroups,
- processors,
- connections
- });
- }),
- catchError((error) => {
- const message = this.errorHelper.getErrorString(error);
- const errorContext = {
- errors: [message],
- context: ErrorContextKey.CONNECTORS
- };
- const failure =
ConnectorCanvasActions.loadConnectorFlowFailure({
- errorContext,
- isRefresh: hasLoadedFlow
- });
- return hasLoadedFlow
- ? from([
- failure,
- ErrorActions.addBannerError({
- errorContext: {
- errors: [message],
- context:
ErrorContextKey.CONNECTOR_CANVAS
- }
- })
- ])
- : of(failure);
- })
- )
+ return
ConnectorCanvasActions.loadConnectorFlowSuccess({
+ connectorId: action.connectorId,
+ processGroupId,
+ parentProcessGroupId,
+ breadcrumb,
+ labels,
+ funnels,
+ inputPorts,
+ outputPorts,
+ remoteProcessGroups,
+ processGroups,
+ processors,
+ connections,
+ reconciliationSnapshot,
+ connectedStateChanged
+ });
+ }),
+ catchError((error) => {
+ const message =
this.errorHelper.getErrorString(error);
+ const errorContext = {
+ errors: [message],
+ context: ErrorContextKey.CONNECTORS
+ };
+ const failure =
ConnectorCanvasActions.loadConnectorFlowFailure({
+ errorContext,
+ isRefresh: hasLoadedFlow,
+ connectedStateChanged
+ });
+ return hasLoadedFlow
+ ? from([
+ failure,
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors: [message],
+ context:
ErrorContextKey.CONNECTOR_CANVAS
+ }
+ })
+ ])
+ : of(failure);
+ })
+ );
+ }
)
)
);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
index fdb59d7771d..6cacb00179e 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
@@ -67,7 +67,12 @@ describe('connectorCanvasReducer', () => {
describe('loadConnectorFlow', () => {
it('should clear component data when navigating to a different process
group', () => {
- const previousState = createPopulatedState({ processGroupId:
'pg-current' });
+ const previousState = createPopulatedState({
+ processGroupId: 'pg-current',
+ addedCache: ['proc-added'],
+ removedCache: ['proc-removed'],
+ pendingAuthoritativeSnapshot: true
+ });
const result = connectorCanvasReducer(
previousState,
@@ -86,10 +91,16 @@ describe('connectorCanvasReducer', () => {
expect(result.processGroups).toEqual([]);
expect(result.processors).toEqual([]);
expect(result.connections).toEqual([]);
+ expect(result.addedCache).toEqual([]);
+ expect(result.removedCache).toEqual([]);
+ expect(result.pendingAuthoritativeSnapshot).toBe(false);
});
it('should preserve component data when refreshing the same process
group', () => {
- const previousState = createPopulatedState({ processGroupId:
'pg-current' });
+ const previousState = createPopulatedState({
+ processGroupId: 'pg-current',
+ pendingAuthoritativeSnapshot: true
+ });
const result = connectorCanvasReducer(
previousState,
@@ -107,12 +118,16 @@ describe('connectorCanvasReducer', () => {
expect(result.outputPorts).toEqual(previousState.outputPorts);
expect(result.remoteProcessGroups).toEqual(previousState.remoteProcessGroups);
expect(result.processGroups).toEqual(previousState.processGroups);
+ expect(result.pendingAuthoritativeSnapshot).toBe(true);
});
it('should clear component data when navigating to a different
connector with the same process group id', () => {
const previousState = createPopulatedState({
connectorId: 'connector-1',
- processGroupId: 'pg-current'
+ processGroupId: 'pg-current',
+ addedCache: ['proc-added'],
+ removedCache: ['proc-removed'],
+ pendingAuthoritativeSnapshot: true
});
const result = connectorCanvasReducer(
@@ -131,6 +146,9 @@ describe('connectorCanvasReducer', () => {
expect(result.processors).toEqual([]);
expect(result.connections).toEqual([]);
expect(result.parameterContext).toBeNull();
+ expect(result.addedCache).toEqual([]);
+ expect(result.removedCache).toEqual([]);
+ expect(result.pendingAuthoritativeSnapshot).toBe(false);
});
});
@@ -154,7 +172,9 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors,
- connections
+ connections,
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: false
})
);
@@ -186,7 +206,9 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [stale],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: false
})
);
@@ -213,7 +235,9 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [connection.id],
removedCache: [] },
+ connectedStateChanged: false
});
const afterFirstStaleLoad = connectorCanvasReducer(state,
staleAction);
const afterSecondStaleLoad =
connectorCanvasReducer(afterFirstStaleLoad, staleAction);
@@ -250,7 +274,9 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [staleProcessor],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache:
['proc-removed'] },
+ connectedStateChanged: false
});
const afterFirstStaleLoad = connectorCanvasReducer(state,
staleAction);
@@ -294,13 +320,134 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [makeProcessor('proc-other')],
- connections: [connection]
+ connections: [connection],
+ reconciliationSnapshot: { addedCache: [], removedCache:
['proc-removed'] },
+ connectedStateChanged: false
})
);
expect(result.connections).toEqual([]);
expect(result.removedCache).toContain('proc-removed');
});
+
+ it('should accept a lower-revision entity from an authoritative
snapshot', () => {
+ const current = makeProcessor('proc-1', { revision: { version: 2 }
});
+ const authoritative = makeProcessor('proc-1', { revision: {
version: 1 } });
+
+ const result = connectorCanvasReducer(
+ createPopulatedState({ processors: [current] }),
+ ConnectorCanvasActions.loadConnectorFlowSuccess({
+ connectorId: 'connector-1',
+ processGroupId: 'pg-current',
+ parentProcessGroupId: 'pg-parent',
+ breadcrumb: null,
+ labels: [],
+ funnels: [],
+ inputPorts: [],
+ outputPorts: [],
+ remoteProcessGroups: [],
+ processGroups: [],
+ processors: [authoritative],
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: true
+ })
+ );
+
+ expect(result.processors).toEqual([authoritative]);
+ });
+
+ it('should ignore both caches and use the authoritative component and
connection sets', () => {
+ const source = makeProcessor('proc-restored');
+ const destination = makeProcessor('proc-destination');
+ const connection = makeConnection('conn-restored', {
+ sourceId: source.id,
+ destinationId: destination.id
+ });
+
+ const result = connectorCanvasReducer(
+ createPopulatedState({
+ processors: [makeProcessor('proc-local')],
+ connections: [],
+ addedCache: ['proc-local'],
+ removedCache: [source.id],
+ pendingAuthoritativeSnapshot: true
+ }),
+ ConnectorCanvasActions.loadConnectorFlowSuccess({
+ connectorId: 'connector-1',
+ processGroupId: 'pg-current',
+ parentProcessGroupId: 'pg-parent',
+ breadcrumb: null,
+ labels: [],
+ funnels: [],
+ inputPorts: [],
+ outputPorts: [],
+ remoteProcessGroups: [],
+ processGroups: [],
+ processors: [source, destination],
+ connections: [connection],
+ reconciliationSnapshot: {
+ addedCache: ['proc-local'],
+ removedCache: [source.id]
+ },
+ connectedStateChanged: true
+ })
+ );
+
+ expect(result.processors).toEqual([source, destination]);
+ expect(result.connections).toEqual([connection]);
+ expect(result.addedCache).toEqual([]);
+ expect(result.removedCache).toEqual([]);
+ expect(result.pendingAuthoritativeSnapshot).toBe(false);
+ });
+
+ it('should preserve optimistic mutations completed while an
authoritative request is in flight', () => {
+ const local = makeProcessor('proc-local');
+ const removed = makeProcessor('proc-removed');
+ const destination = makeProcessor('proc-destination');
+ const connection = makeConnection('conn-removed', {
+ sourceId: removed.id,
+ destinationId: destination.id
+ });
+
+ const result = connectorCanvasReducer(
+ createPopulatedState({
+ processors: [local, destination],
+ addedCache: [local.id],
+ removedCache: [removed.id]
+ }),
+ ConnectorCanvasActions.loadConnectorFlowSuccess({
+ connectorId: 'connector-1',
+ processGroupId: 'pg-current',
+ parentProcessGroupId: 'pg-parent',
+ breadcrumb: null,
+ labels: [],
+ funnels: [],
+ inputPorts: [],
+ outputPorts: [],
+ remoteProcessGroups: [],
+ processGroups: [],
+ processors: [removed, destination],
+ connections: [connection],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: true
+ })
+ );
+
+ expect(result.processors).toEqual([destination, local]);
+ expect(result.connections).toEqual([]);
+ expect(result.addedCache).toEqual([local.id]);
+ expect(result.removedCache).toEqual([removed.id]);
+ });
+ });
+
+ it('should retain the authoritative requirement as soon as the cluster
signal is consumed', () => {
+ const result = connectorCanvasReducer(
+ createPopulatedState({ pendingAuthoritativeSnapshot: false }),
+ ConnectorCanvasActions.markAuthoritativeSnapshotPending()
+ );
+
+ expect(result.pendingAuthoritativeSnapshot).toBe(true);
});
describe('loadConnectorFlowFailure', () => {
@@ -314,7 +461,8 @@ describe('connectorCanvasReducer', () => {
context: ErrorContextKey.CONNECTORS,
errors: ['Connection refused']
},
- isRefresh: false
+ isRefresh: false,
+ connectedStateChanged: false
})
);
@@ -336,7 +484,8 @@ describe('connectorCanvasReducer', () => {
context: ErrorContextKey.CONNECTORS,
errors: ['Service unavailable']
},
- isRefresh: true
+ isRefresh: true,
+ connectedStateChanged: false
})
);
@@ -344,6 +493,38 @@ describe('connectorCanvasReducer', () => {
expect(result.hasLoadedFlow).toBe(true);
expect(result.processors).toEqual(previousState.processors);
});
+
+ it('should retain an authoritative snapshot requirement after a
refresh fails', () => {
+ const result = connectorCanvasReducer(
+ createPopulatedState({ loadingStatus: 'loading' }),
+ ConnectorCanvasActions.loadConnectorFlowFailure({
+ errorContext: {
+ context: ErrorContextKey.CONNECTORS,
+ errors: ['Service unavailable']
+ },
+ isRefresh: true,
+ connectedStateChanged: true
+ })
+ );
+
+ expect(result.pendingAuthoritativeSnapshot).toBe(true);
+ });
+
+ it('should preserve an existing authoritative requirement after a
later ordinary failure', () => {
+ const result = connectorCanvasReducer(
+ createPopulatedState({ loadingStatus: 'loading',
pendingAuthoritativeSnapshot: true }),
+ ConnectorCanvasActions.loadConnectorFlowFailure({
+ errorContext: {
+ context: ErrorContextKey.CONNECTORS,
+ errors: ['Service unavailable']
+ },
+ isRefresh: true,
+ connectedStateChanged: false
+ })
+ );
+
+ expect(result.pendingAuthoritativeSnapshot).toBe(true);
+ });
});
describe('createConnection', () => {
@@ -618,7 +799,9 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [makeProcessor('proc-1')],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache:
['proc-1'] },
+ connectedStateChanged: false
});
state = connectorCanvasReducer(state, staleLoad);
@@ -679,7 +862,9 @@ describe('connectorCanvasReducer', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [makeProcessor('proc-1')],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: false
})
);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts
index 59516c33ba1..418db460622 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts
@@ -48,7 +48,8 @@ function processComponentCollection<E extends { id: string;
revision: Revision }
proposedComponents: E[],
currentComponents: E[],
addedCache: string[],
- removedCache: string[]
+ removedCache: string[],
+ connectedStateChanged: boolean
): E[] {
const currentById = new Map(currentComponents.map((component) =>
[component.id, component]));
const proposedIds = new Set(proposedComponents.map((component) =>
component.id));
@@ -56,7 +57,9 @@ function processComponentCollection<E extends { id: string;
revision: Revision }
.filter((component) => !removedCache.includes(component.id))
.map((proposedComponent) => {
const currentComponent = currentById.get(proposedComponent.id);
- return currentComponent &&
!hasCurrentOrNewerRevision(currentComponent.revision,
proposedComponent.revision)
+ return currentComponent &&
+ !connectedStateChanged &&
+ !hasCurrentOrNewerRevision(currentComponent.revision,
proposedComponent.revision)
? currentComponent
: proposedComponent;
});
@@ -91,6 +94,7 @@ export const connectorCanvasReducer = createReducer(
connections: [],
addedCache: [],
removedCache: [],
+ pendingAuthoritativeSnapshot: false,
hasLoadedFlow: false,
parameterContext: null
}
@@ -117,9 +121,19 @@ export const connectorCanvasReducer = createReducer(
remoteProcessGroups,
processGroups,
processors,
- connections
+ connections,
+ reconciliationSnapshot,
+ connectedStateChanged
}
) => {
+ const requestedAddedIds = new
Set(reconciliationSnapshot.addedCache);
+ const requestedRemovedIds = new
Set(reconciliationSnapshot.removedCache);
+ const addedCache = connectedStateChanged
+ ? state.addedCache.filter((id) => !requestedAddedIds.has(id))
+ : state.addedCache;
+ const removedCache = connectedStateChanged
+ ? state.removedCache.filter((id) =>
!requestedRemovedIds.has(id))
+ : state.removedCache;
const snapshotIds = new Set(
[
...labels,
@@ -138,8 +152,7 @@ export const connectorCanvasReducer = createReducer(
]);
const reconciledConnections = connections.filter(
(connection) =>
- !state.removedCache.includes(connection.sourceId) &&
- !state.removedCache.includes(connection.destinationId)
+ !removedCache.includes(connection.sourceId) &&
!removedCache.includes(connection.destinationId)
);
return {
...state,
@@ -147,46 +160,71 @@ export const connectorCanvasReducer = createReducer(
processGroupId,
parentProcessGroupId,
breadcrumb,
- labels: processComponentCollection(labels, state.labels,
state.addedCache, state.removedCache),
- funnels: processComponentCollection(funnels, state.funnels,
state.addedCache, state.removedCache),
+ labels: processComponentCollection(
+ labels,
+ state.labels,
+ addedCache,
+ removedCache,
+ connectedStateChanged
+ ),
+ funnels: processComponentCollection(
+ funnels,
+ state.funnels,
+ addedCache,
+ removedCache,
+ connectedStateChanged
+ ),
inputPorts: processComponentCollection(
inputPorts,
state.inputPorts,
- state.addedCache,
- state.removedCache
+ addedCache,
+ removedCache,
+ connectedStateChanged
),
outputPorts: processComponentCollection(
outputPorts,
state.outputPorts,
- state.addedCache,
- state.removedCache
+ addedCache,
+ removedCache,
+ connectedStateChanged
),
remoteProcessGroups: processComponentCollection(
remoteProcessGroups,
state.remoteProcessGroups,
- state.addedCache,
- state.removedCache
+ addedCache,
+ removedCache,
+ connectedStateChanged
),
processGroups: processComponentCollection(
processGroups,
state.processGroups,
- state.addedCache,
- state.removedCache
+ addedCache,
+ removedCache,
+ connectedStateChanged
),
processors: processComponentCollection(
processors,
state.processors,
- state.addedCache,
- state.removedCache
+ addedCache,
+ removedCache,
+ connectedStateChanged
),
connections: processComponentCollection(
reconciledConnections,
state.connections,
- state.addedCache,
- state.removedCache
+ addedCache,
+ removedCache,
+ connectedStateChanged
),
- addedCache: state.addedCache.filter((id) =>
!snapshotIds.has(id)),
- removedCache: state.removedCache.filter((id) =>
snapshotIdsOrConnectionEndpoints.has(id)),
+ addedCache: connectedStateChanged
+ ? addedCache
+ : state.addedCache.filter((id) =>
!requestedAddedIds.has(id) || !snapshotIds.has(id)),
+ removedCache: connectedStateChanged
+ ? removedCache
+ : state.removedCache.filter(
+ (id) => !requestedRemovedIds.has(id) ||
snapshotIdsOrConnectionEndpoints.has(id)
+ ),
+ pendingAuthoritativeSnapshot: false,
hasLoadedFlow: true,
loadingStatus: 'success' as const,
error: null
@@ -194,12 +232,21 @@ export const connectorCanvasReducer = createReducer(
}
),
- on(ConnectorCanvasActions.loadConnectorFlowFailure, (state, {
errorContext, isRefresh }) => ({
+ on(ConnectorCanvasActions.markAuthoritativeSnapshotPending, (state) => ({
...state,
- loadingStatus: isRefresh ? ('refresh-error' as const) : ('error' as
const),
- error: errorContext.errors.join(', ')
+ pendingAuthoritativeSnapshot: true
})),
+ on(
+ ConnectorCanvasActions.loadConnectorFlowFailure,
+ (state, { errorContext, isRefresh, connectedStateChanged }) => ({
+ ...state,
+ loadingStatus: isRefresh ? ('refresh-error' as const) : ('error'
as const),
+ error: errorContext.errors.join(', '),
+ pendingAuthoritativeSnapshot: state.pendingAuthoritativeSnapshot
|| connectedStateChanged
+ })
+ ),
+
on(ConnectorCanvasActions.navigateWithoutTransform, (state) => ({
...state,
skipTransform: true
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
index fcd9506a1b1..cd77ab1f07b 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
@@ -78,6 +78,16 @@ export const selectLoadingStatus =
createSelector(selectConnectorCanvasState, (s
export const selectHasLoadedFlow = createSelector(selectConnectorCanvasState,
(state) => state.hasLoadedFlow);
+export const selectOptimisticCacheSnapshot =
createSelector(selectConnectorCanvasState, (state) => ({
+ addedCache: state.addedCache,
+ removedCache: state.removedCache
+}));
+
+export const selectPendingAuthoritativeSnapshot = createSelector(
+ selectConnectorCanvasState,
+ (state) => state.pendingAuthoritativeSnapshot
+);
+
export const selectError = createSelector(selectConnectorCanvasState, (state)
=> state.error);
export const selectSaving = createSelector(selectConnectorCanvasState, (state)
=> state.saving);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
index cbc023bd183..3040e176748 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
@@ -55,6 +55,7 @@ export interface ConnectorCanvasState {
connections: ConnectionEntity[];
addedCache: string[];
removedCache: string[];
+ pendingAuthoritativeSnapshot: boolean;
registryClients: RegistryClientEntity[];
skipTransform: boolean;
hasLoadedFlow: boolean;
@@ -79,6 +80,7 @@ export const initialConnectorCanvasState:
ConnectorCanvasState = {
connections: [],
addedCache: [],
removedCache: [],
+ pendingAuthoritativeSnapshot: false,
registryClients: [],
skipTransform: false,
hasLoadedFlow: false,
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
index 6e2ac199874..1463d219cdd 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
@@ -2460,7 +2460,9 @@ describe('ConnectorCanvasComponent', () => {
remoteProcessGroups: [],
processGroups: [],
processors: [],
- connections: []
+ connections: [],
+ reconciliationSnapshot: { addedCache: [], removedCache: []
},
+ connectedStateChanged: false
})
);