// Path-search stages (ANY SHORTEST / ALL SHORTEST): minimum-hop BFS with // parent links, a single-source tree memo for sources shared by many rows, // and the all-shortest backward enumeration over the distance map. Every // form runs on the one spWalk frontier core, so the hop-filter and // depth-bound semantics live in a single place. The weighted form is the // COST clause: MATCH p = ANY SHORTEST <pattern> COST <expr>.
package exec import ( ) // runSPStage binds the path slot to the minimum-hop path(s) between the // bound endpoint slots. The single form binds one path per input row; the // ALL form is row-expanding. A required stage drops rows with no path; an // optional one keeps a single row with the path null. The stage's rel // matcher compiles once so the walks skip per-call type-name resolution. func runSPStage( *eval.Ctx, *plan.SpStage, [][]value.Value) [][]value.Value { var *hopFilter if .RelPred != nil { := map[string]int{.RelPred.Var: 0} = &hopFilter{eval: compileEval(, .RelPred.Pred, ), scope: } } := .G.CompileRelMatcher(.Types) := newSPScratch() var [][]value.Value if .All { for , := range { var []nodesRels if , := [.From].AsNode(); { if , := [.To].AsNode(); { = allShortestPaths(, , , , , , ) } } if len() == 0 { if .Optional { [.PathSlot] = value.Null() = append(, ) } continue } for , := range { := make([]value.Value, len()) copy(, ) [.PathSlot] = value.Path(.nodes, .rels) = append(, ) } } return } // Single shortest path: a source shared by >=2 rows is traversed once // into a parent tree and each target's path is read off it; a // single-row source keeps the early-exit search. := map[graph.NodeID]int{} for , := range { if , := [.From].AsNode(); { if , := [.To].AsNode(); { []++ } } } // The weighted form compiles its per-edge weight once for the stage // and runs the Dijkstra per row (no tree memo: the weighted search // early-exits per target). A constant weight (or one degraded to unit // weights) makes every path's cost proportional to its hop count, so // the minimum-cost path IS a minimum-hop path: those route to the BFS // forms below -- a constant-weight Dijkstra pays a heap push and pop // plus map probes per relationship to rediscover the ordering a BFS // frontier yields for free. Reachability, path length, and the hop cap // are identical; among equal-length paths ANY SHORTEST may bind any. var *pathWeight if .Weight != nil { if = buildPathWeight(, ); .kind == weightConstant || .kind == weightMissing { = nil } } := map[graph.NodeID]*spTree{} // paths memoizes the fully MATERIALIZED path per (source, target) // pair: a stage joined against many rows repeats few distinct pairs, // and re-reading the parent chain plus re-scanning each hop's // adjacency for its relationship position dominates such stages. A // present nil entry records "no path". Path values are immutable, so // rows share the backing arrays. type struct{ , graph.NodeID } := map[]*nodesRels{} // The weighted form batches per SOURCE: one multi-target Dijkstra // settles every distinct target the stage's rows request from that // source (terminating at the farthest requested target), and each row // reads its pair off the memo -- a stage of s sources over s*t rows // runs s searches instead of s*t. var wpScratch if != nil { := map[graph.NodeID][]graph.NodeID{} for , := range { if , := [.From].AsNode(); { if , := [.To].AsNode(); { if , := [{, }]; ! { [{, }] = nil // claimed; filled below [] = append([], ) } } } } for , := range { for , := range weightedShortestPaths(, , , , , , , &) { [{, }] = } } } for , := range { var nodesRels := false if , := [.From].AsNode(); { if , := [.To].AsNode(); { switch { case != nil: if := [{, }]; != nil { , = *, true } case [] >= 2: if , := [{, }]; { if != nil { , = *, true } } else { , := [] if ! { = buildSPTree(, , , , , ) [] = } , = .pathTo(, , , , , ) if { := [{, }] = & } else { [{, }] = nil } } default: , = shortestPath(, , , , , , ) } } } switch { case : [.PathSlot] = value.Path(.nodes, .rels) = append(, ) case .Optional: [.PathSlot] = value.Null() = append(, ) } } return } // nodesRels is one materialized path. type nodesRels struct { nodes []graph.NodeID rels []uint32 } // spScratch is a stage's reusable path-search state: dense node-indexed // search arrays with a generation stamp (a slot belongs to the current // search iff gen[id] carries its stamp, so a run needs no O(n) clear and // no hash per neighbor probe), the double-buffered frontiers of both // search halves, and the batch neighbor buffers. Arrays size by IDSpace // -- sparse CSR slots make NodeCount too small -- growing on first use // per graph. Retained structures (the spTree memo's parent maps, result // node chains) always allocate fresh; scratch never escapes a call. type spScratch struct { gen []uint32 cur uint32 // forward stamp; cur+1 is the backward stamp parent []graph.NodeID dist []uint32 frontier, next []graph.NodeID bFrontier, bNext []graph.NodeID nbNodes []graph.NodeID nbPoss []uint32 // arNodes/arRels are append-only path slabs: a stage emits thousands // of short paths and one make per path dominated its allocations. // Handed-out slices are RETAINED by emitted rows, so slabs are never // reused or reset -- the per-stage-run scratch is simply abandoned, // amortizing allocation count to one per slab. arNodes []graph.NodeID arRels []uint32 // preds memoizes per-node predecessor sequences for one ALL SHORTEST // enumeration (cleared per endpoint pair); see predsOf. preds map[graph.NodeID][]graph.NodeID } func newSPScratch() *spScratch { return &spScratch{} } // nodeSlice hands out a full-capacity n-slice from the node slab. func ( *spScratch) ( int) []graph.NodeID { if cap(.arNodes)-len(.arNodes) < { .arNodes = make([]graph.NodeID, 0, max(4096, )) } := len(.arNodes) .arNodes = .arNodes[:+] return .arNodes[ : + : +] } // relSlice hands out a full-capacity n-slice from the rel slab. func ( *spScratch) ( int) []uint32 { if cap(.arRels)-len(.arRels) < { .arRels = make([]uint32, 0, max(4096, )) } := len(.arRels) .arRels = .arRels[:+] return .arRels[ : + : +] } // begin sizes the dense arrays for the graph's id space and opens a new // generation, returning its forward stamp (backward = stamp+1). Stamp // wraparound clears the gen array once; slot zero never collides because // stamps start at 2. func ( *spScratch) ( int) uint32 { if len(.gen) < { := make([]uint32, ) copy(, .gen) .gen = := make([]graph.NodeID, ) copy(, .parent) .parent = := make([]uint32, ) copy(, .dist) .dist = } if .cur >= ^uint32(0)-2 { clear(.gen) .cur = 0 } .cur += 2 return .cur } // appendHopNeighbors fills scr.nbNodes with node's accepted hop neighbors // through the batch seam (relationship positions are consulted only under // a per-hop predicate), compacting in place -- no per-call iterator // closures. The result is valid until the next scratch use; callers that // nest (the all-shortest enumeration) must not use it. func appendHopNeighbors( *eval.Ctx, *spScratch, graph.NodeID, graph.Direction, *graph.RelMatcher, *hopFilter) []graph.NodeID { if == nil { .nbNodes = .G.AppendNeighborsMatched(.nbNodes[:0], , , ) return .nbNodes } .nbNodes, .nbPoss = .G.AppendRelationshipsMatched(.nbNodes[:0], .nbPoss[:0], , , ) := .nbNodes[:0] for , := range .nbPoss { if .keep(, ) { = append(, .nbNodes[]) } } .nbNodes = return } // filteredNeighbors is the iterator form of the hop's neighbor set, kept // for the recursive all-shortest enumeration whose nesting cannot share // the scratch buffers. func filteredNeighbors( *eval.Ctx, graph.NodeID, graph.Direction, *graph.RelMatcher, *hopFilter, func(graph.NodeID)) { if == nil { for := range .G.NeighborsMatched(, , ) { () } return } for , := range .G.RelationshipsMatched(, , ) { if .keep(, ) { () } } } // spCap is the hop cap (max nil = unbounded). func spCap( *plan.SpStage) uint64 { if .Max != nil { return *.Max } return 1<<63 - 1 } // spWalk is the one bounded minimum-hop frontier walk behind every // path-search form: level-synchronous expansion from a through // filteredNeighbors under the hop cap, calling reach exactly once per // first-reached node with its predecessor and depth. reach returns true to // halt the whole walk immediately (the early-exit single-path search); // levelDone (nil allowed) is consulted after each completed level -- the // all-shortest walk must finish a level so every minimum-hop predecessor // gets a distance before stopping. func spWalk( *eval.Ctx, graph.NodeID, *plan.SpStage, *graph.RelMatcher, *hopFilter, *spScratch, func(, graph.NodeID, uint64) bool, func() bool) { := .begin(int(.G.IDSpace())) .gen[], .dist[] = , 0 := append(.frontier[:0], ) := .next[:0] defer func() { .frontier, .next = , }() for := uint64(0); len() > 0 && < spCap(); ++ { = [:0] for , := range { for , := range appendHopNeighbors(, , , .Dir, , ) { if .gen[] == { continue } .gen[] = if (, , +1) { return } = append(, ) } } if != nil && () { return } , = , } } // pathFromParents reads the minimum-hop node chain a..b off parent links: // a one-node chain when a == b, nil when b was never reached. Two passes // (count, then fill backward) give one exactly-sized allocation. func pathFromParents( *spScratch, map[graph.NodeID]graph.NodeID, , graph.NodeID) []graph.NodeID { if == { := .nodeSlice(1) [0] = return } if , := []; ! { return nil } := 2 for := []; != ; = [] { ++ } := .nodeSlice() := for := - 1; >= 0; -- { [] = = [] } return } // shortestPath is the single-pair minimum-hop search a..b: a bidirectional // BFS growing a frontier from each end (the backward half walking the // reversed direction), always expanding the smaller frontier by one full // level, stopping at the first node stamped by both halves. Cost is edges // touched, not nodes visited: expanding the smaller frontier means a hub // adjacent to one endpoint never has its ply expanded at all. // // Minimality: once the halves have fully explored depths df and db, every // path of length <= df+db crosses a node both halves reached, so its // second stamping would already have been a meet; with none seen, an // undiscovered path is at least df+db+1 long. Expanding a ply can then // only find meets totalling exactly df+db+1 -- every first-ply meet is // optimal, so the search stops at the first one. The same bound makes the // hop cap exact: the loop stops once df+db reaches the cap, and any meet // found before that totals within it. func shortestPath( *eval.Ctx, , graph.NodeID, *plan.SpStage, *graph.RelMatcher, *hopFilter, *spScratch) (nodesRels, bool) { if == { return nodesRels{nodes: []graph.NodeID{}}, true } := .begin(int(.G.IDSpace())) := + 1 .gen[], .dist[], .parent[] = , 0, .gen[], .dist[], .parent[] = , 0, := append(.frontier[:0], ) := .next[:0] := append(.bFrontier[:0], ) := .bNext[:0] defer func() { .frontier, .next = , .bFrontier, .bNext = , }() := flipDir(.Dir) var , graph.NodeID := false , := uint64(0), uint64(0) // The side to expand is chosen by frontier NODE COUNT. The degree-sum // alternative (pending-edge count) was measured deterministically // (sp_frontier_ab_test.go): on realistic moderate-skew graphs it saves // under 1% of edge touches while paying a Degree read per accepted // node -- pure overhead -- and only wins (42% fewer edges) on extreme // synthetic hubs. If a hub-dominated workload ever appears, the // degree-sum form can return behind a skew heuristic; the A/B harness // is the record of the trade. for len() > 0 && len() > 0 && + < spCap() && ! { if len() <= len() { = [:0] for , := range { for , := range appendHopNeighbors(, , , .Dir, , ) { switch .gen[] { case : case : , , = , , true default: .gen[], .parent[], .dist[] = , , uint32(+1) = append(, ) } if { break } } if { break } } , = , ++ } else { = [:0] for , := range { for , := range appendHopNeighbors(, , , , , ) { switch .gen[] { case : case : , , = , , true default: .gen[], .parent[], .dist[] = , , uint32(+1) = append(, ) } if { break } } if { break } } , = , ++ } } if ! { return nodesRels{}, false } := stitchPath(, , , , ) return nodesRels{nodes: , rels: pathRelPositions(, , , .Dir, , )}, true } // stitchPath joins the forward parent chain a..fMeet and the backward // chain bMeet..b into one node sequence, sized exactly. Each source is its // own parent, terminating the chain walks. func stitchPath( *spScratch, , , , graph.NodeID) []graph.NodeID { := 1 for := ; != ; = .parent[] { ++ } := 1 for := ; != ; = .parent[] { ++ } := .nodeSlice( + ) := for := - 1; >= 0; -- { [] = = .parent[] } = for := range { [+] = = .parent[] } return }