Skip to content

Commit 1456340

Browse files
authored
Merge pull request #327 from hjotha/submit/describer-nearest-split-merge
fix: pair microflow splits with nearest merge
2 parents 421c91f + aa262d8 commit 1456340

6 files changed

Lines changed: 644 additions & 99 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- ============================================================================
2+
-- Bug #326: Describer paired splits with downstream merge instead of nearest
3+
-- ============================================================================
4+
--
5+
-- Symptom (before fix):
6+
-- When a microflow had two sequential `if ... end if;` blocks, the
7+
-- describer could pair the FIRST split with the SECOND merge, nesting
8+
-- the continuation between the two ifs (and even the second if) inside
9+
-- the first if's body. A describe → exec → describe cycle then mutated
10+
-- the structure even though the original graph was valid:
11+
-- -- before fix, after first describe:
12+
-- if $Logo != empty then
13+
-- log info node 'App' 'logo';
14+
-- log info node 'App' 'after logo'; -- wrong: was outside first if
15+
-- if $Cover != empty then -- wrong: was sibling, not nested
16+
-- log info node 'App' 'cover';
17+
-- end if;
18+
-- end if;
19+
--
20+
-- Root cause:
21+
-- Split/merge pairing picked any common downstream merge reachable from
22+
-- both branches, not the nearest one. Error-handler flows were included
23+
-- in the search, biasing it further.
24+
--
25+
-- After fix:
26+
-- `findMergeForSplit` now uses shortest-path distances per branch and
27+
-- selects the nearest common merge, ignoring error-handler flows for
28+
-- structural pairing.
29+
--
30+
-- Usage:
31+
-- mxcli exec mdl-examples/bug-tests/326-describer-pair-split-with-nearest-merge.mdl -p app.mpr
32+
-- mxcli -p app.mpr -c "describe microflow BugTest326.MF_SequentialIfs"
33+
-- The describe output must keep the two `if ... end if;` blocks as
34+
-- siblings (with the `log` between them at top level), and the
35+
-- describe → exec → describe cycle must be a fixpoint.
36+
-- ============================================================================
37+
38+
create module BugTest326;
39+
40+
create microflow BugTest326.MF_SequentialIfs (
41+
$Logo: string,
42+
$Cover: string
43+
)
44+
begin
45+
if $Logo != empty then
46+
log info node 'BugTest326' 'logo';
47+
end if;
48+
49+
log info node 'BugTest326' 'after logo';
50+
51+
if $Cover != empty then
52+
log info node 'BugTest326' 'cover';
53+
end if;
54+
end;
55+
/

mdl/executor/cmd_microflows_builder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ type flowBuilder struct {
5353
microflowsCacheLoaded bool
5454
nanoflowsCache []*microflows.Nanoflow
5555
nanoflowsCacheLoaded bool
56-
manualLoopBackTarget model.ID
56+
manualLoopBackTarget model.ID
5757
}
5858

5959
// addError records a validation error during flow building.

mdl/executor/cmd_microflows_format_action.go

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -351,30 +351,30 @@ func formatAction(
351351

352352
stmt := fmt.Sprintf("retrieve $%s from %s", outputVar, entityName)
353353

354-
if dbSource.XPathConstraint != "" {
355-
constraint := strings.TrimSpace(dbSource.XPathConstraint)
356-
// XPath may contain multiple predicates like [a][b] or [a]\n[b].
357-
// Split them and join with MDL 'and' so the parser sees
358-
// separate xpathConstraint nodes.
359-
if strings.HasPrefix(constraint, "[") && strings.HasSuffix(constraint, "]") {
360-
// Split on "][" boundary (possibly separated by \n literals),
361-
// then re-wrap each predicate.
362-
inner := constraint[1 : len(constraint)-1]
363-
// Normalise real newlines between predicates: ]\n[ → ][
364-
inner = strings.ReplaceAll(inner, "]\n[", "][")
365-
parts := strings.Split(inner, "][")
366-
if len(parts) > 1 {
367-
var wrapped []string
368-
for _, p := range parts {
369-
wrapped = append(wrapped, "["+strings.TrimSpace(p)+"]")
354+
if dbSource.XPathConstraint != "" {
355+
constraint := strings.TrimSpace(dbSource.XPathConstraint)
356+
// XPath may contain multiple predicates like [a][b] or [a]\n[b].
357+
// Split them and join with MDL 'and' so the parser sees
358+
// separate xpathConstraint nodes.
359+
if strings.HasPrefix(constraint, "[") && strings.HasSuffix(constraint, "]") {
360+
// Split on "][" boundary (possibly separated by \n literals),
361+
// then re-wrap each predicate.
362+
inner := constraint[1 : len(constraint)-1]
363+
// Normalise real newlines between predicates: ]\n[ → ][
364+
inner = strings.ReplaceAll(inner, "]\n[", "][")
365+
parts := strings.Split(inner, "][")
366+
if len(parts) > 1 {
367+
var wrapped []string
368+
for _, p := range parts {
369+
wrapped = append(wrapped, "["+strings.TrimSpace(p)+"]")
370+
}
371+
constraint = strings.Join(wrapped, "\n ")
372+
} else {
373+
constraint = parts[0]
370374
}
371-
constraint = strings.Join(wrapped, "\n ")
372-
} else {
373-
constraint = parts[0]
374375
}
376+
stmt += fmt.Sprintf("\n where %s", constraint)
375377
}
376-
stmt += fmt.Sprintf("\n where %s", constraint)
377-
}
378378

379379
// Output SORT BY clause if present
380380
if len(dbSource.Sorting) > 0 {

mdl/executor/cmd_microflows_show.go

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -867,60 +867,129 @@ func findSplitMergePoints(
867867
return result
868868
}
869869

870-
// findMergeForSplit finds the ExclusiveMerge where branches from a split converge.
870+
// findMergeForSplit finds the nearest node where branches from a split converge.
871+
// Studio Pro models often converge directly on the next activity instead of an
872+
// explicit ExclusiveMerge, so the join can be any executable microflow object.
871873
func findMergeForSplit(
872874
ctx *ExecContext,
873875
splitID model.ID,
874876
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
875877
activityMap map[model.ID]microflows.MicroflowObject,
876878
) model.ID {
877-
flows := flowsByOrigin[splitID]
879+
flows := findNormalFlows(flowsByOrigin[splitID])
878880
if len(flows) < 2 {
879881
return ""
880882
}
881883

882-
// Follow each branch and collect all reachable nodes
883-
branch0Nodes := collectReachableNodes(ctx, flows[0].DestinationID, flowsByOrigin, activityMap, make(map[model.ID]bool))
884-
branch1Nodes := collectReachableNodes(ctx, flows[1].DestinationID, flowsByOrigin, activityMap, make(map[model.ID]bool))
885-
886-
// Find the first common node that is an ExclusiveMerge
887-
// This is a simplification - we look for the first merge point reachable from both branches
888-
for nodeID := range branch0Nodes {
889-
if branch1Nodes[nodeID] {
890-
if _, ok := activityMap[nodeID].(*microflows.ExclusiveMerge); ok {
891-
return nodeID
892-
}
893-
}
884+
branchDistances := make([]map[model.ID]int, 0, len(flows))
885+
for _, flow := range flows {
886+
branchDistances = append(branchDistances, collectReachableDistances(flow.DestinationID, flowsByOrigin))
894887
}
895888

896-
return ""
889+
return selectNearestCommonJoin(activityMap, branchDistances)
897890
}
898891

899-
// collectReachableNodes collects all nodes reachable from a starting node.
900-
func collectReachableNodes(
901-
ctx *ExecContext,
892+
// collectReachableDistances collects the shortest normal-flow distance from a
893+
// branch start to every reachable node. Error handler flows are excluded because
894+
// they do not participate in split/merge structural pairing.
895+
func collectReachableDistances(
902896
startID model.ID,
903897
flowsByOrigin map[model.ID][]*microflows.SequenceFlow,
898+
) map[model.ID]int {
899+
distances := map[model.ID]int{}
900+
type queueItem struct {
901+
id model.ID
902+
distance int
903+
}
904+
queue := []queueItem{{id: startID}}
905+
906+
for len(queue) > 0 {
907+
item := queue[0]
908+
queue = queue[1:]
909+
910+
if previous, ok := distances[item.id]; ok && previous <= item.distance {
911+
continue
912+
}
913+
distances[item.id] = item.distance
914+
915+
for _, flow := range findNormalFlows(flowsByOrigin[item.id]) {
916+
queue = append(queue, queueItem{
917+
id: flow.DestinationID,
918+
distance: item.distance + 1,
919+
})
920+
}
921+
}
922+
923+
return distances
924+
}
925+
926+
func selectNearestCommonJoin(
904927
activityMap map[model.ID]microflows.MicroflowObject,
905-
visited map[model.ID]bool,
906-
) map[model.ID]bool {
907-
result := make(map[model.ID]bool)
928+
branchDistances []map[model.ID]int,
929+
) model.ID {
930+
if len(branchDistances) < 2 {
931+
return ""
932+
}
908933

909-
var traverse func(id model.ID)
910-
traverse = func(id model.ID) {
911-
if visited[id] {
912-
return
934+
type candidate struct {
935+
id model.ID
936+
maxDistance int
937+
sumDistance int
938+
}
939+
candidates := []candidate{}
940+
941+
for nodeID, firstDistance := range branchDistances[0] {
942+
if !isSplitJoinCandidate(activityMap[nodeID]) {
943+
continue
913944
}
914-
visited[id] = true
915-
result[id] = true
916945

917-
for _, flow := range flowsByOrigin[id] {
918-
traverse(flow.DestinationID)
946+
maxDistance := firstDistance
947+
sumDistance := firstDistance
948+
common := true
949+
for _, distances := range branchDistances[1:] {
950+
distance, ok := distances[nodeID]
951+
if !ok {
952+
common = false
953+
break
954+
}
955+
if distance > maxDistance {
956+
maxDistance = distance
957+
}
958+
sumDistance += distance
959+
}
960+
if common {
961+
candidates = append(candidates, candidate{
962+
id: nodeID,
963+
maxDistance: maxDistance,
964+
sumDistance: sumDistance,
965+
})
919966
}
920967
}
921968

922-
traverse(startID)
923-
return result
969+
if len(candidates) == 0 {
970+
return ""
971+
}
972+
973+
sort.Slice(candidates, func(i, j int) bool {
974+
if candidates[i].maxDistance != candidates[j].maxDistance {
975+
return candidates[i].maxDistance < candidates[j].maxDistance
976+
}
977+
if candidates[i].sumDistance != candidates[j].sumDistance {
978+
return candidates[i].sumDistance < candidates[j].sumDistance
979+
}
980+
return string(candidates[i].id) < string(candidates[j].id)
981+
})
982+
983+
return candidates[0].id
984+
}
985+
986+
func isSplitJoinCandidate(obj microflows.MicroflowObject) bool {
987+
switch obj.(type) {
988+
case nil, *microflows.StartEvent, *microflows.EndEvent:
989+
return false
990+
default:
991+
return true
992+
}
924993
}
925994

926995
// --- Executor method wrappers for callers in unmigrated code ---

0 commit comments

Comments
 (0)