Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure deterministic graph calculation with consistent layer, node, and edge ordering in Kedro-Viz #2185

Merged
merged 15 commits into from
Nov 18, 2024
5 changes: 5 additions & 0 deletions package/kedro_viz/services/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ def find_child_layers(node_id: str) -> Set[str]:
if layer not in layer_dependencies:
layer_dependencies[layer] = set()

# Sort `layer_dependencies` keys for consistent ordering of layers with the same dependencies
layer_dependencies = defaultdict(
set, {k: layer_dependencies[k] for k in sorted(layer_dependencies)}
)

# Use graphlib.TopologicalSorter to sort the layer dependencies.
try:
sorter = TopologicalSorter(layer_dependencies)
Expand Down
28 changes: 27 additions & 1 deletion package/tests/test_services/test_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@
{"node_1": {}, "node_2": {}},
["a", "b"],
),
(
# Case where if two layers e.g. `int` and `primary` layers share the same dependencies, they get sorted alphabetically.
"""
node_1(layer=raw) -> node_3(layer=int)
node_2(layer=raw) -> node_4(layer=primary)
node_3(layer=int) -> node_5(layer=feature)
node_4(layer=primary) -> node_6(layer=feature)
""",
{
"node_1": {"id": "node_1", "layer": "raw"},
"node_2": {"id": "node_2", "layer": "raw"},
"node_3": {"id": "node_3", "layer": "int"},
"node_4": {"id": "node_4", "layer": "primary"},
"node_5": {"id": "node_5", "layer": "feature"},
"node_6": {"id": "node_6", "layer": "feature"},
},
{
"node_1": {"node_3"},
"node_2": {"node_4"},
"node_3": {"node_5"},
"node_4": {"node_6"},
"node_5": set(),
"node_6": set(),
},
["raw", "int", "primary", "feature"],
),
],
)
def test_sort_layers(graph_schema, nodes, node_dependencies, expected):
Expand All @@ -170,7 +196,7 @@ def test_sort_layers(graph_schema, nodes, node_dependencies, expected):
for node_id, node_dict in nodes.items()
}
sorted_layers = sort_layers(nodes, node_dependencies)
assert sorted(sorted_layers) == sorted(expected), graph_schema
assert sorted_layers == expected, graph_schema


def test_sort_layers_should_return_empty_list_on_cyclic_layers(mocker):
Expand Down
78 changes: 61 additions & 17 deletions src/actions/graph.test.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
import { createStore } from 'redux';
import reducer from '../reducers';
import { mockState } from '../utils/state.mock';
import { calculateGraph, updateGraph } from './graph';
import { getGraphInput } from '../selectors/layout';
import { prepareState } from '../utils/state.mock';
import spaceflights from '../utils/data/spaceflights.mock.json';
import spaceflightsReordered from '../utils/data/spaceflights_reordered.mock.json';
import { toggleModularPipelinesExpanded } from '../actions/modular-pipelines';

describe('graph actions', () => {
const getMockState = (data) =>
prepareState({
data,
beforeLayoutActions: [
() =>
toggleModularPipelinesExpanded(['data_science', 'data_processing']),
],
});

describe('calculateGraph', () => {
it('returns updateGraph action if input is falsey', () => {
expect(calculateGraph(null)).toEqual(updateGraph(null));
});

it('sets loading to true immediately', () => {
const store = createStore(reducer, mockState.spaceflights);
const store = createStore(reducer, getMockState(spaceflights));
expect(store.getState().loading.graph).not.toBe(true);
calculateGraph(getGraphInput(mockState.spaceflights))(store.dispatch);
calculateGraph(getGraphInput(getMockState(spaceflights)))(store.dispatch);
expect(store.getState().loading.graph).toBe(true);
});

it('sets loading to false and graph visibility to true after finishing calculation', () => {
const store = createStore(reducer, mockState.spaceflights);
return calculateGraph(getGraphInput(mockState.spaceflights))(
const store = createStore(reducer, getMockState(spaceflights));
return calculateGraph(getGraphInput(getMockState(spaceflights)))(
store.dispatch
).then(() => {
const state = store.getState();
Expand All @@ -29,19 +41,51 @@ describe('graph actions', () => {
});

it('calculates a graph', () => {
const state = Object.assign({}, mockState.spaceflights);
delete state.graph;
const store = createStore(reducer, state);
const initialState = { ...getMockState(spaceflights), graph: {} };
const store = createStore(reducer, initialState);
expect(store.getState().graph).toEqual({});
return calculateGraph(getGraphInput(state))(store.dispatch).then(() => {
expect(store.getState().graph).toEqual(
expect.objectContaining({
nodes: expect.any(Array),
edges: expect.any(Array),
size: expect.any(Object),
})
);
});
return calculateGraph(getGraphInput(initialState))(store.dispatch).then(
() => {
expect(store.getState().graph).toEqual(
expect.objectContaining({
nodes: expect.any(Array),
edges: expect.any(Array),
size: expect.any(Object),
})
);
}
);
});

it('compares deterministic flowchart of two differently ordered same projects', () => {
ravi-kumar-pilla marked this conversation as resolved.
Show resolved Hide resolved
const store1 = createStore(reducer, getMockState(spaceflights));
const store2 = createStore(reducer, getMockState(spaceflightsReordered));

return calculateGraph(getGraphInput(getMockState(spaceflights)))(
store1.dispatch
)
.then(() =>
calculateGraph(getGraphInput(getMockState(spaceflightsReordered)))(
store2.dispatch
)
)
.then(() => {
// Get node coordinates for both graphs
const graph1Coords = store1.getState().graph.nodes.map((node) => ({
id: node.id,
x: node.x,
y: node.y,
}));

const graph2Coords = store2.getState().graph.nodes.map((node) => ({
id: node.id,
x: node.x,
y: node.y,
}));

// Verify coordinates consistency between both graphs
expect(graph1Coords).toEqual(expect.arrayContaining(graph2Coords));
});
});
});
});
2 changes: 1 addition & 1 deletion src/selectors/sliced-pipeline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ describe('Selectors', () => {
const expected = [
'23c94afb',
'47b81aa6',
'90ebe5f3',
'daf35ba0',
'c09084f2',
'0abef172',
'e5a9ec27',
'b7bb7198',
'f192326a',
'90ebe5f3',
'data_processing',
];
const newState = reducer(mockState.spaceflights, {
Expand Down
10 changes: 10 additions & 0 deletions src/store/normalize-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,15 @@ const getNodeTypesFromUrl = (state, typeQueryParams) => {
return state;
};

/**
* Sort the edges, nodes in the state object to ensure deterministic graph layout
* @param {Object} state The state object to sort
*/
const sortNodesEdges = (state) => {
state.edge?.ids?.sort((a, b) => a.localeCompare(b));
state.node?.ids?.sort((a, b) => a.localeCompare(b));
};

/**
* Updates the state with filters from the URL.
* @param {Object} state - State object
Expand Down Expand Up @@ -331,6 +340,7 @@ const normalizeData = (data, expandAllPipelines) => {
data.layers.forEach(addLayer(state));
}

sortNodesEdges(state);
const updatedState = updateStateWithFilters(state, data.tags);
return updatedState;
};
Expand Down
17 changes: 17 additions & 0 deletions src/store/normalize-data.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import normalizeData, { createInitialPipelineState } from './normalize-data';
import spaceflights from '../utils/data/spaceflights.mock.json';
import spaceflightsReordered from '../utils/data/spaceflights_reordered.mock.json';

const initialState = createInitialPipelineState();

Expand Down Expand Up @@ -90,4 +91,20 @@ describe('normalizeData', () => {
expect(node).toHaveProperty('name');
});
});

it('should have identical nodes and edges, in the same order, regardless of the different ordering from the api', () => {
// Normalize both datasets
const initialState = normalizeData(spaceflights, true);
const reorderedState = normalizeData(spaceflightsReordered, true);

// Compare nodes and edges by converting to JSON for deep equality
// Directly compare specific properties of nodes and edges, ensuring order and content
expect(initialState.node.ids).toEqual(reorderedState.node.ids);
expect(initialState.node.name).toEqual(reorderedState.node.name);
expect(initialState.node.type).toEqual(reorderedState.node.type);

expect(initialState.edge.ids).toEqual(reorderedState.edge.ids);
expect(initialState.edge.sources).toEqual(reorderedState.edge.sources);
expect(initialState.edge.targets).toEqual(reorderedState.edge.targets);
});
});
Loading
Loading