Fixes #21960: Allow cable trace to continue through multi-position FrontPorts without position context#22083
Fixes #21960: Allow cable trace to continue through multi-position FrontPorts without position context#22083attoba wants to merge 1 commit into
Conversation
|
@claude /review Only report on issues of potential concern; we don't need commentary on anything that doesn't require further consideration. |
|
Claude finished @jeremystretch's task in 2m 36s —— View job ReviewPotential concern: No test coverage for the fixed scenario The removed code path (multi-position Subtle behavioral concern: all positions pushed when stack is empty After the fix, when port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)This returns all if any(t.positions > 1 for t in terminations):
position_stack.append([mapping.rear_port_position for mapping in port_mappings])All The underlying asymmetry with |
jeremystretch
left a comment
There was a problem hiding this comment.
@attoba could you please include tests to ensure that this change doesn't introduce a breaking change to existing behavior?
|
This PR has been automatically marked as stale because it has not had recent activity. It will be closed automatically if no further action is taken. |
|
@attoba are you still interested in working on this? |
b510532 to
86047b1
Compare
| # When the position stack is empty (e.g. the trace reached this | ||
| # profiled cable after crossing single-position pass-through ports | ||
| # which don't push onto the stack), derive positions from each | ||
| # termination's own cable_positions — which were set by this |
There was a problem hiding this comment.
Removing this branch fails to address the scenario where two front ports map to different remote endpoints:
With the abort gone and the rear ports being single-position, nothing pushes onto
position_stack(line 1056), so the split-detection guard at line 887 (position_stack and …) is skipped on the next hop. If the two positions of the front port lead to different remote endpoints, the trace will now complete with twoconnected_endpointsandis_split = False, rather than being flagged as a split. The issue text explicitly frames the desired behavior as "a path split is only genuinely ambiguous when the branches lead to different endpoints" — but this PR doesn't actually distinguish convergent from divergent; it just stops aborting.
Rather than removing it, consider checking that the mapped ports all share the same parent.
86047b1 to
d961792
Compare
|
@claude /review Only report on issues of potential concern; we don't need commentary on anything that doesn't require further consideration. |
|
Claude finished @jeremystretch's task in 1m 26s —— View job Review
Three issues worth addressing before merge: 1.
rear_ports = [m.rear_port for m in port_mappings]
termination_type = ObjectType.objects.get_for_model(rear_ports[0]) # IndexError if emptyThe guard 2. Module ID / Device ID namespace collision in parent identity check
far_end_parents.add(
getattr(t, 'module_id', None) or getattr(t, 'device_id', None)
)
3. Test 225 topology requires In test 225, |
jeremystretch
left a comment
There was a problem hiding this comment.
@attoba thanks for following up on this. Please see if you can address the concerns raised above.
…i-position FrontPort paths
d961792 to
f15a594
Compare
|
@claude /review Only report on issues of potential concern; we don't need commentary on anything that doesn't require further consideration. |
|
Claude finished @jeremystretch's task in 2m 58s —— View job Review
Two concerns worth addressing before merge: 1. One-hop lookahead does not prevent false "convergent" verdicts in multi-hop topologies
Test 226 covers the straightforward divergent case (two strands directly reaching two different devices), but there is no test for the "same intermediate device, different final endpoints" case. If that topology is considered out of scope for this fix, the condition and its limitation should be documented. 2. Far-end terminations that have neither
far_end_parents.add(
('module', t.module_id) if getattr(t, 'module_id', None)
else ('device', getattr(t, 'device_id', None))
)
|
jeremystretch
left a comment
There was a problem hiding this comment.
Thanks for keeping at this @attoba. Unfortunately this doesn't sufficiently validate that the diverged paths rejoin. I've included some analysis from Claude that breaks down the specific concerns.
1. netbox/dcim/models/cables.py ~1043-1058 — parent-equality is necessary but not sufficient; strands landing on different endpoints of the same device are merged into one wrong path. (CONFIRMED)
The check compares only the immediate far-end termination's parent, never that the strands reconverge on one endpoint. Topology: duplex fp_b (positions=2) → rp_b_1,rp_b_2, cabled to rp_a_1,rp_a_2 on the same device A, which fan out to two different interfaces if_a_1/if_a_2. far_end_parents == {('device', A)} (len 1) → not split → trace continues and terminates at [if_a_1, if_a_2], all same type → is_complete=True, is_split=False. Result: if_b.connected_endpoints == [if_a_1, if_a_2] (two endpoints presented as a complete link), while the reverse trace from either if_a_* returns a single peer if_b — an asymmetric, physically-invalid merge that the removed break prevented. This propagates to connected_endpoints_reachable, destinations, tables, panels, and GraphQL, none of which consult is_split.
2. netbox/dcim/models/cables.py ~1046-1051 — far-end terminations with no device_id/module_id all collapse to ('device', None), so divergent circuits/provider-networks are treated as convergent. (CONFIRMED)
CircuitTermination (and site/provider-network terminations) have neither device_id nor module_id, so ('module', t.module_id) if getattr(t,'module_id',None) else ('device', getattr(t,'device_id',None)) yields ('device', None) for every one. Topology: duplex fp_b → rp_b_1,rp_b_2, cabled to CircuitTerminations on two different circuits. far_end_parents == {('device', None)} (len 1) → not split → the trace merges two unrelated circuits into one path instead of flagging is_split=True.
3. netbox/dcim/models/cables.py ~1057-1058 — the convergent continuation is now reachable with an empty stack and pushes a flat rear_port_position list mixing positions from distinct rear ports. (PLAUSIBLE)
When the converged rear ports themselves have positions > 1, position_stack.append([mapping.rear_port_position for mapping in port_mappings]) pushes one flat list gathered across multiple different rear ports. The next hop applies it uniformly via rear_port_position__in=... to every rear port, so a rear port that legitimately uses only positions 1–2 also matches phantom positions 3–4 from a sibling rear port → extra front ports traced. Under the old code this path aborted as split, so the diff newly exposes this. (Not exercised by the added tests, whose rear ports are all positions=1.)
4. netbox/dcim/models/cables.py ~1046-1051 — the same parent-key expression produces a false split for two components on one device when one is module-mounted and the other is not. (CONFIRMED, low severity)
A module-installed component keys as ('module', module_id) while a directly-mounted one on the same device keys as ('device', device_id), so a genuinely convergent path (both far ends on device X) is flagged is_split=True. This is the conservative direction (matches pre-PR behavior, no new wrong output), but it shows the parent key should resolve to the owning device uniformly rather than device-or-module.
5. netbox/dcim/models/cables.py ~1043 — N+1 query: [m.rear_port for m in port_mappings] loads each RearPort with a separate query. (CONFIRMED)
PortMapping.objects.filter(front_port__in=...) has no select_related('rear_port'), so the new full iteration issues one query per mapping on a hot path run for every FrontPort hop. Add .select_related('rear_port') (also benefits the existing mapping.rear_port use at line 1056).
6. netbox/dcim/models/cables.py ~965-975 — the far-end lookup duplicates the legacy positionless branch at lines 992-1010. (cleanup)
The get_for_model → CableTermination.filter(termination_type, termination_id__in) → cable-end flip → filter(q_filter).prefetch_related('termination') sequence is a near-verbatim copy of lines 992-1010 (the far_end = 'A' if lct.cable_end == 'B' else 'B' block copies 998-1001). Extract a shared "far-end terminations for a set of local terminations" helper and reuse it in both places.
Unfortunately I don't have sufficient time to invest in this right now. You're welcome to continue working at it, but I'm not comfortable altering the tracing logic unless we can verify with sufficient confidence that it won't introduce unintended behavior.
Fixes: #21960
When tracing encounters a multi-position FrontPort with an empty position_stack, instead of aborting unconditionally, perform a one-hop lookahead: inspect what each mapped RearPort is cabled to on the far end and check whether all far-end terminations share the same parent device/module.
The existing abort behavior for multi-position RearPorts is preserved unchanged.
Tests added: