// Hash-join extraction: when a segment's bind chain re-executes an // independent branch of the pattern per row of a large intermediate (the // branch reads nothing the intermediate bound), nesting multiplies the two // branches' costs. This pass rewrites that shape into a HashJoinStage -- // build the independent branch once, keyed by the variable a connecting // expand joins it back through, and probe per outer row -- purely on // estimated cardinality structure, never on query identity. It runs after // markRelUniqueness so every op keeps the uniqueness flags of the original // nested order; the executor's capture/replay protocol (see exec) makes // the reorganized row multiset exactly the nested one.
package plan import ( ) // tryHashJoin attempts the rewrite at pivot stage k; nil when the shape // does not qualify. func tryHashJoin( []Stage, int, float64, map[int]float64, map[int]int, map[int]graph.NodeID, map[string]int, graph.Graph) []Stage { := / HashJoinExtDivisor := func( int) bool { , := [] return && [] < && <= } := func( int) bool { , := [] return && [] < } // The collection region: consecutive stages from the pivot; OPTIONAL // stages pass through untouched (their bindings and reads never // interact with the branch -- enforced below), any other stage kind or // a path bind is a hard barrier. := for < len() { , := [].(*MatchStage) if ! || .PathBind != nil { break } ++ } // Op-level dependency-driven collection: an op joins the branch when // everything it reads is external-qualified or branch-bound, with // var-expand shapes whose uniqueness participation cannot be captured // as per-row pairs left post-probe (exact there: the full used-pair // stack is live). type struct{ , int } var [] var map[]bool var , map[int]bool // collect runs the op-level dependency-driven collection over stages // [k, lim): an op joins the branch when everything it reads is // external-qualified or branch-bound. Callable twice -- the keyless // (cartesian) fallback re-collects with lim = k+1 so the build is the // pivot stage's own component only, and recursion extracts the rest. := func( int) { = [:0] = make(map[]bool) = make(map[int]bool) = make(map[int]bool) := func( int) bool { if [] { return true } if () { [] = true return true } return false } var []int for := ; < ; ++ { := [].(*MatchStage) if .Optional { continue } for := range .Ops { := &.Ops[] = opReads(, [:0]) := true for , := range { if !() { = false break } } // A tracked var-expand joins the build only in its // per-trail bounded contributing form: a check-only or // dedup'd (reach-shaped) walk's output depends on the // row's LIVE uniqueness pairs, which a keyed build cannot // see, so those decline. The known mechanism that could // admit the check-only reach class -- capture each // trail's pair set at build with an empty exclusion, then // reconstruct the endpoint set per row by emitting an // endpoint iff some captured trail has no scope-live pair // -- is deliberately not built: the sibling engine // measured ~1.05x on its best case, and no workload here // pays for this decline yet. if && .Kind == OpVarExpand && .Uniq != nil && !(.Max != nil && .Uniq.Contribute && !.DedupEndpoints) { = false } if ! { continue } := {, } = append(, ) [] = true if .Kind == OpScan { if .Source.Kind != ScanArg { [.Slot] = true } } else { if !.Rebind { [.To] = true } if .RelSlot != NoSlot { [.RelSlot] = true } } } } } () if len() == 0 { return nil } // Key discovery: the connecting expand is the first non-collected // anonymous rebind expand joining a branch-bound endpoint to a // pre-pivot outer one. Its B-side endpoint keys the table; the probe // runs it from the outer endpoint (direction flipped when the pattern // hop ran from the branch side -- uniqPair is reversal-invariant). var BindOp := false := -1 := {-1, -1} for := ; < && < 0; ++ { := [].(*MatchStage) if .Optional { continue } for := range .Ops { := &.Ops[] if [{, }] || .Kind != OpExpand || !.Rebind || .RelSlot != NoSlot { continue } , := [.From], [.To] switch { case && (.To): = * .From, .To = .To, .From .Dir = revDir(.Dir) = true = .From case && (.From): = * = .To default: continue } .Rebind = false = {, } break } } // Value-key fallback (the disconnected-components join): no expand // connects the branch, but a region WHERE equality whose sides split // cleanly -- one reading only branch/external slots, the other only // pre-pivot outer ones -- keys the table by VALUE. The conjunct is // consumed (it IS the join), never a stray; nulls never match, exactly // the equality's own semantics. var , ast.Expr var ast.Expr if < 0 { for := ; < && == nil; ++ { := [].(*MatchStage) if .Optional || .Where == nil { continue } var []ast.Expr SplitAnd(.Where, &) for , := range { , := .(*ast.Binary) if ! || .Op != ast.OpEq { continue } := func( ast.Expr) (, bool) { := conjSlotRefs(, ) if len() == 0 { return false, false } , = true, true for , := range { if ![] && !() { = false } if !() { = false } } return , } , := (.LHS) , := (.RHS) switch { case && : , = .LHS, .RHS case && : , = .RHS, .LHS default: continue } // The build side's external reads join the memo tuple like // an op's would. for , := range conjSlotRefs(, ) { if ![] { [] = true } } = break } } } // Keyless fallback: nothing relates the branch at all -- a genuine // cartesian product. Build once; the probe emits every build row per // outer row. Output is the same product the nested loop produced; the // win is the branch's scan not re-running per outer row. := false if < 0 && == nil { // Build-once only pays when there IS an outer to repeat against: // a pivot whose input is the single seed row runs its scan once // either way, so wrapping it in a join is a no-op with overhead. if <= 1 { return nil } = true // Narrow the build to the pivot stage's own component: collecting // every satisfiable op would swallow sibling disconnected branches // into ONE build whose internal nesting still re-scans -- each // component gets its own build-once via the recursion instead. ( + 1) if len() == 0 { return nil } } // The materialized branch must fit the memory bound (the win itself // is guaranteed by the pivot gate's multiply). := make([]BindOp, 0, len()) for , := range { = append(, [.].(*MatchStage).Ops[.]) } := &MatchStage{Ops: , Scope: [].(*MatchStage).Scope} if , := matchEstAnchored(, 1, , ); > float64(HashJoinMaxBuildRows) { return nil } // Split each region stage's WHERE: a conjunct evaluates inside the // build only when it reads nothing beyond the branch and the external // slots the collected OPS already require -- a conjunct must never // admit a new external slot, because every external slot joins the // memo tuple and multiplies rebuilds (ops are structural anchors; // conjuncts are movable filters that evaluate post-probe for free). // The rest ("strays") are re-placed post-probe below. type struct { ast.Expr []int } var [] var []ast.Expr for := ; < ; ++ { := [].(*MatchStage) if .Optional || .Where == nil { continue } var []ast.Expr SplitAnd(.Where, &) for , := range { if == { continue // the value join's key equality, not a filter } := conjSlotRefs(, ) := true for , := range { if ![] && ![] { = false break } } if { = append(, ) } else { = append(, {: , : }) } } } .Where = andJoin() := make([]int, 0, len()) for := range { = append(, ) } slices.Sort() := make([]int, 0, len()) for := range { = append(, ) } slices.Sort() := &HashJoinStage{ Build: []*MatchStage{}, ExtSlots: , KeySlot: , PayloadSlots: , Probe: , Reversed: , KeyBuild: , KeyProbe: , Cartesian: , } // Reassemble: pivot-region stages keep their leftover ops in place // (dependencies were never reordered), the consumed connecting expand // and collected ops leave, empty stages vanish. newBinder records // which surviving region stage binds each remaining fresh slot, for // stray placement. := make([]Stage, 0, len()+2) = append(, [:]...) = append(, ) := make(map[int]int) := make(map[int]bool) for := ; < ; ++ { := [].(*MatchStage) if .Optional { for := range .Ops { := &.Ops[] if := freshBind(); >= 0 { [] = len() [len()] = true } if .Kind != OpScan && .RelSlot != NoSlot { [.RelSlot] = len() [len()] = true } } = append(, ) continue } var []BindOp for := range .Ops { := {, } if [] || == { continue } := .Ops[] if := freshBind(&); >= 0 { [] = len() } if .Kind != OpScan && .RelSlot != NoSlot { [.RelSlot] = len() } = append(, ) } if len() == 0 { continue } = append(, &MatchStage{Ops: , Optional: false, Scope: .Scope}) } = append(, [:]...) // Stray placement: each cross-branch conjunct attaches at its latest // reference's binder -- the join stage when every reference is outer- // bound or payload-bound, else the surviving region stage that binds // the last reference. Attaching to an OPTIONAL stage would change its // left-join condition, and a reference bound past the region cannot // occur (a stage WHERE's references are bound by its own stage), so // either voids the rewrite. for , := range { := -1 for , := range . { if [] || () { continue } , := [] if ! { return nil } = max(, ) } switch { case < 0: .Where = andWith(.Where, .) case []: return nil default: := [].(*MatchStage) .Where = andWith(.Where, .) } } return } // freshBind is the slot an op newly binds (-1 when it only re-anchors or // joins into an already-bound slot). func freshBind( *BindOp) int { if .Kind == OpScan { if .Source.Kind == ScanArg { return -1 } return .Slot } if .Rebind { return -1 } return .To } // andWith conjoins extra onto base (base may be nil). func andWith(, ast.Expr) ast.Expr { if == nil { return } return &ast.Binary{Op: ast.OpAnd, LHS: , RHS: } } // opReads appends the slots an op reads before binding anything. func opReads( *BindOp, []int) []int { switch .Kind { case OpScan: switch .Source.Kind { case ScanArg, ScanNodeIDVar: = append(, .Source.Slot) case ScanExistsSeed: for := range .Source.Seeds { = append(, .Source.Seeds[].AnchorSlot) } } default: = append(, .From) if .Rebind { = append(, .To) } } return } // conjSlotRefs is the segment slots a conjunct references (names not in // the slot map -- e.g. an EXISTS pattern's local variables -- are ignored; // a collision with an outer name only delays placement, never advances // it). func conjSlotRefs( ast.Expr, map[string]int) []int { := map[string]bool{} collectAllVars(, ) := make([]int, 0, len()) for := range { if , := []; { = append(, ) } } slices.Sort() return } // andJoin folds conjuncts back into one AND tree (nil for none). func andJoin( []ast.Expr) ast.Expr { var ast.Expr for , := range { if == nil { = } else { = &ast.Binary{Op: ast.OpAnd, LHS: , RHS: } } } return }