// PlanCache: a size-bounded LRU cache of auto-parameterized query plans. // Two layers share one lock. An exact-string fast path (L1) skips parse + // plan on a verbatim repeat; a template index (L2) lets queries differing // only in lifted constants share one compiled plan. L1 entries own the // plans; a template entry dies when its last L1 variant is evicted (the Go // refcount standing in for the Rust engine's Arc/Weak split). // // Both layers hold the TEMPLATE plan -- planned after auto- // parameterization, where every cost probe abstains on parameter slots -- // so a cached plan is value-independent by construction. It may differ in // SHAPE (never in results) from the literal-probed plan an uncached Run // builds for the same text; this mirrors the Rust engine's query_cached // composition, trading the best-effort literal plan for zero double // planning and cross-literal sharing. // // Eviction is least-recently-used past a configurable byte budget. The // byte estimate counts each L1 entry's key, parameters, and a per-plan // estimate; a plan shared by several literal variants is charged once per // variant -- a deliberate over-count, so the true footprint stays under // the bound.
package gql import ( chickpeas ) // DefaultCacheBytes is the default budget: 128 MiB of approximate // resident plan + key + parameter bytes. const DefaultCacheBytes = 128 << 20 // l1Overhead is the per-entry fixed bookkeeping charge. const l1Overhead = 96 // cachedPlan is one template's compiled plan, shared across every L1 // literal variant of that template. type cachedPlan struct { plan *plan.Plan mode ast.QueryMode key string estBytes int // refs counts the L1 variants sharing this plan; the template entry // is dropped when the last one evicts. refs int // flipped marks a template whose blind plan differs structurally from // value-sighted planning (detected once, when the template was first // planned): execution for such a template routes through the sighted // path instead of the cached tree, because a flipped plan's cost can // be arbitrarily wrong (see planflip.go). Conservative: a template // that flipped for its first caller's values re-plans sighted for all. flipped bool } // l1Entry is one verbatim query string's cache slot: the shared plan plus // this string's lifted constant values. type l1Entry struct { plan *cachedPlan params []value.Value bytes int tick uint64 } // PlanCache is a size-bounded cache of auto-parameterized plans, safe for // concurrent use. Hold one per long-lived snapshot: a cached plan's cost // choices reflect the statistics of the snapshot its template was first // planned against (executing against another snapshot is correct, possibly // suboptimal). type PlanCache struct { mu sync.Mutex byQuery map[string]*l1Entry byTemplate map[string]*cachedPlan bytes int tick uint64 maxBytes int hitsL1 uint64 hitsL2 uint64 misses uint64 } // NewPlanCache is an empty cache bounded to maxBytes of approximate // resident memory (LRU eviction past the bound); maxBytes <= 0 means // DefaultCacheBytes. func ( int) *PlanCache { if <= 0 { = DefaultCacheBytes } return &PlanCache{ byQuery: map[string]*l1Entry{}, byTemplate: map[string]*cachedPlan{}, maxBytes: , } } // Run executes a GQL query through the cache. func ( *PlanCache) ( *chickpeas.Snapshot, string) (*Rows, error) { return .RunWithParams(, , nil) } // RunWithParams executes a GQL query through the cache with explicit // $name parameter values. Results are identical to the uncached // RunWithParams. func ( *PlanCache) ( *chickpeas.Snapshot, string, map[string]value.Value) (*Rows, error) { := graph.New() // L1: a verbatim repeat skips parse + plan. if , , := .l1Lookup(); { if .flipped { return RunUncachedWithParams(, , ) } return .execCached(, , , ) } // L2: parse, lift constants, and share a plan across templates. , := parseDesugar() if != nil { return nil, } := semantics.AutoParameterize() := ast.Fingerprint() , := .l2Lookup() if ! { // Plan the template outside the lock; a concurrent duplicate is // reconciled by insert (the first plan wins, the second is // dropped). , := plan.Build(, ) if != nil { return nil, wrapStage() } = &cachedPlan{plan: , mode: .Mode, key: , estBytes: 2048 + len()*8} // Flip detection, once per template: compare the tree the cache // would execute (post adaptive choice for this call's values) // against sighted planning of the literal text. One extra plan // per template miss buys the do-no-harm guarantee. := &eval.Ctx{G: , Params: } .flipped = planFlipped(, chooseAdaptivePlan(, , ), ) } .insert(, , ) if .flipped { return RunUncachedWithParams(, , ) } return .execCached(, , , ) } // execCached executes a cached plan with this call's lifted values and // named parameters (cached EXPLAIN/PROFILE renders show no planning time, // matching the Rust engine). func ( *PlanCache) ( *graph.SnapshotGraph, *cachedPlan, []value.Value, map[string]value.Value) (*Rows, error) { := &eval.Ctx{G: , Params: , Named: , ForceInterp: forceInterp} return execPlan(, chooseAdaptivePlan(.plan, , ), .mode, 0, ) } // chooseAdaptivePlan resolves an auto-parameterized anchor tie now that the // parameters are bound: it scores the primary plan and its flipped sibling by // each anchor's REAL first-hop fan-out and runs whichever seeds from the // lower-degree end. Returns the primary unchanged when there is no sibling, // the query is a UNION, or either anchor cannot be scored cheaply (declining // keeps the per-execution probe bounded on a hot cached query). This is where // the value the planner could not see -- a hub vs a selective seek behind the // same param slot -- finally decides the plan, without baking the value into // the cache key. func chooseAdaptivePlan( *plan.Plan, *eval.Ctx, graph.Graph) *plan.Plan { if .Alt == nil || len(.Branches) != 1 { return } , := anchorFanout(, , ) , := anchorFanout(.Alt, , ) if ! || ! { return } if < { adaptiveAltPicked++ return .Alt } return } // adaptiveAltPicked counts sibling selections, so tests can assert a path // actually CONSULTED the adaptive choice (rows are identical either way, // making the wiring invisible to a result assertion). var adaptiveAltPicked int // anchorFanout is the summed real first-hop degree of a plan's parameter-seek // anchor over the nodes the bound parameter now resolves to. ok is false when // the plan's first stage isn't a property-seek anchor followed by an expand, // or the resolved anchor set exceeds 64 nodes (the probe runs on every // execution of a cached query, so it must stay O(1)-ish). func anchorFanout( *plan.Plan, *eval.Ctx, graph.Graph) (uint64, bool) { := firstMatchStage() if == nil || len(.Ops) < 2 { return 0, false } , := .Ops[0], .Ops[1] if .Kind != plan.OpScan || .Source.Kind != plan.ScanProperty || .Kind != plan.OpExpand { return 0, false } := .NodesWithProperty(.Source.Label, .Source.Key, eval.LitValue(, .Source.Value)) if == nil { return 0, true } if .Len() > 64 { return 0, false } // The real first-hop fan-out is the TYPED degree over the hop's relation // types, not the node's untyped degree -- an anchor can carry many edges // of other types (a Country's IS_LOCATED_IN dwarf its IS_PART_OF) that the // first hop never traverses. Count is capped at a work budget so the // per-execution probe stays bounded even against a hub: reaching the cap // only means "at least this large", which is all the comparison needs. const = 4096 var uint64 for := range .Iter() { for range .NeighborsByType(chickpeas.NodeID(), .Dir, .Types) { ++ if >= { return , true } } } return , true } // firstMatchStage is a plan's first MatchStage in its single branch, or nil. func firstMatchStage( *plan.Plan) *plan.MatchStage { for , := range .Branches[0] { for , := range .Stages { if , := .(*plan.MatchStage); { return } } } return nil } // l1Lookup returns the verbatim entry's shared plan and lifted values, // marking it most-recently-used. func ( *PlanCache) ( string) (*cachedPlan, []value.Value, bool) { .mu.Lock() defer .mu.Unlock() , := .byQuery[] if ! { return nil, nil, false } .tick++ .tick = .tick .hitsL1++ return .plan, .params, true } // l2Lookup returns an existing template's plan, counting the hit. func ( *PlanCache) ( string) (*cachedPlan, bool) { .mu.Lock() defer .mu.Unlock() , := .byTemplate[] if { .hitsL2++ } else { .misses++ } return , } // insert memoizes the exact query string (L1) against the template's // shared plan (L2), reconciling a concurrently inserted duplicate // template, then evicts LRU entries past the budget. func ( *PlanCache) ( string, *cachedPlan, []value.Value) { .mu.Lock() defer .mu.Unlock() if , := .byTemplate[.key]; { = } else { .byTemplate[.key] = } .tick++ := l1Overhead + len() + .estBytes for , := range { += valueBytes() } // Take the new hold BEFORE releasing a replaced entry's: when the old // entry holds the same plan (two racing first-runs of one query // string), deref-first transiently zeroed the refcount and deleted // the template while its L1 holders lived on -- the Len()==0 flake. .refs++ if , := .byQuery[]; { .bytes -= .bytes .deref(.plan) } .byQuery[] = &l1Entry{plan: , params: , bytes: , tick: .tick} .bytes += .evict() } // deref drops one L1 variant's hold; the template entry dies with the // last one. func ( *PlanCache) ( *cachedPlan) { .refs-- if .refs <= 0 { delete(.byTemplate, .key) } } // evict removes least-recently-used L1 entries until resident bytes fall // to 90% of the budget (hysteresis: a steady insert stream doesn't evict // on every call). Caller holds the lock. func ( *PlanCache) () { if .bytes <= .maxBytes { return } := .maxBytes / 10 * 9 type struct { uint64 string } := make([], 0, len(.byQuery)) for , := range .byQuery { = append(, {.tick, }) } sort.Slice(, func(, int) bool { return []. < []. }) for , := range { if .bytes <= { break } := .byQuery[.] delete(.byQuery, .) .bytes -= .bytes .deref(.plan) } } // valueBytes approximates one lifted value's heap footprint. func valueBytes( value.Value) int { switch .Kind() { case value.KindStr: , := .AsStr() return len() + 24 case value.KindList: , := .AsList() := 24 for , := range { += () } return case value.KindPath: , , := .AsPath() return 24 + len()*4 + len()*4 default: return 16 } } // Bytes is the approximate resident bytes currently charged. func ( *PlanCache) () int { .mu.Lock() defer .mu.Unlock() return .bytes } // MaxBytes is the current byte budget. func ( *PlanCache) () int { .mu.Lock() defer .mu.Unlock() return .maxBytes } // SetMaxBytes changes the budget, evicting immediately if over it. func ( *PlanCache) ( int) { .mu.Lock() defer .mu.Unlock() .maxBytes = .evict() } // Len is the number of distinct templates with a live plan. func ( *PlanCache) () int { .mu.Lock() defer .mu.Unlock() return len(.byTemplate) } // IsEmpty reports whether no live template plan is cached. func ( *PlanCache) () bool { return .Len() == 0 } // stats exposes the hit/miss counters to same-package tests. func ( *PlanCache) () (, , uint64) { .mu.Lock() defer .mu.Unlock() return .hitsL1, .hitsL2, .misses }