// Hash-join detection: the cost model that decides where the rewrite in // hashjoin.go applies. hashJoinOnce walks the stage list once, estimating // each stage's input/output cardinality and the cumulative estimate at // which every slot was first bound, then gates pivots on the thresholds // below -- a stage worth protecting from a multiplying re-expansion. All // decisions read estimated cardinality structure, never query identity.
package plan import // The decision thresholds are variables (not consts) so tests can force // the rewrite onto small fixture graphs and compare both execution paths. var ( // HashJoinMinRows is the smallest estimated intermediate worth // protecting from a multiplying re-expansion. HashJoinMinRows = 1 << 16 // HashJoinFanFactor is the minimum estimated output/input multiply of // the pivot stage before the rewrite is considered. HashJoinFanFactor = 64.0 // HashJoinExtDivisor bounds the branch's external references: a slot // qualifies only when it was bound at a cumulative estimate at most // estIn(pivot)/HashJoinExtDivisor, which also bounds the number of // distinct memo tuples (re-builds) to the same fraction. HashJoinExtDivisor = 256.0 // HashJoinMaxBuildRows caps the estimated materialized branch (a // memory bound; the win condition is the pivot gate's multiply). HashJoinMaxBuildRows = 1 << 22 ) // hashJoinStages applies the rewrite repeatedly until no pivot // qualifies -- a segment can hold several independent multiplying // branches (each pass consumes its pivot's ops, so the loop terminates). func hashJoinStages( []Stage, map[string]int, int, graph.Graph) []Stage { for range len() + 1 { := hashJoinOnce(, , , ) if == nil { return } = } return } func hashJoinOnce( []Stage, map[string]int, int, graph.Graph) []Stage { := make(map[int]float64, +8) := make(map[int]int, +8) for := range { [] = 1 [] = -1 } := func(, int, float64) { if < 0 { return } if , := []; ! { [] = [] = } } := 1.0 := make([]float64, len()) := make([]float64, len()) // Slots proven to hold exactly one concrete node (a resolved seek): // later hops from these price at the node's real degree, so the pivot // and build gates decide on fact where the type average would lie. := make(map[int]graph.NodeID) for , := range { [] = switch s := .(type) { case *MatchStage: for := range .Ops { := &.Ops[] if .Kind != OpScan || .Source.Kind == ScanArg { continue } if , := resolveScanNodes(&.Source, .Labels, .Props, ); && len() == 1 { [.Slot] = [0] } } , := matchEstAnchored(, , , ) for := range .Ops { := &.Ops[] if .Kind == OpScan { if .Source.Kind != ScanArg { (.Slot, , float64([])) } } else if !.Rebind { (.To, , float64([])) } if .Kind != OpScan { (.RelSlot, , float64([])) } } if .PathBind != nil { (.PathBind.PathSlot, , ) } = case *UnwindStage: *= unwindFanout (.OutSlot, , ) case *SpStage: (.PathSlot, , ) case *GateStage: (.Sp.PathSlot, , ) for , := range .Derived { (.Slot, , ) } case *CallStage: (.NodeSlot, , ) (.ValueSlot, , ) (.DepthSlot, , ) case *CallSubqueryStage: for , := range .OutSlots { (, , ) } } [] = } for := range { , := [].(*MatchStage) if ! || .Optional || .PathBind != nil { continue } if [] < float64(HashJoinMinRows) || [] < []*HashJoinFanFactor { continue } if := tryHashJoin(, , [], , , , , ); != nil { return } } return nil }