explain

package
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package explain renders Cypher execution plans as human-readable text (EXPLAIN mode) and instruments them with per-operator execution statistics (PROFILE mode).

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func FormatPlanTable added in v0.13.0

func FormatPlanTable(rows []PlanRow) string

FormatPlanTable renders rows as a Neo4j-style columnar plan table:

+-----------------------+----------+----------+
| Operator              | Est.Rows | Vars     |
+-----------------------+----------+----------+
| ProduceResults        |      100 | n        |
| └─ NodeByLabelScan    |      100 | n:Person |
+-----------------------+----------+----------+

Each column is as wide as its widest cell (never narrower than its header), measured in RUNES rather than bytes: the tree connectors (└─, ├─, │) are multi-byte UTF-8, so a byte-width measurement pads a deeply indented row short and the right-hand border walks left as the tree descends. The measurement is rune count, which is the correct width for every character this renderer emits (ASCII plus the box-drawing set, all single-width); a caller who puts a double-width character — CJK, an emoji — into a cell will still see that cell render narrow, because no terminal-width table can be correct for every font without measuring the terminal.

The Operator and Vars columns are left-aligned, Est.Rows right-aligned. Output ends with a trailing newline. Rendering is a pure function of rows, so it is safe for concurrent use and deterministic.

func FormatReport

func FormatReport(r ProfileReport) string

FormatReport formats r as a Neo4j-style table:

+--------------------------+--------+---------+-----------+
| Operator                 |   Rows | DbHits  | Time (ms) |
+--------------------------+--------+---------+-----------+
| NodeByLabelScan          |    100 |     100 |     0.012 |
| ProduceResults           |    100 |       0 |     0.001 |
+--------------------------+--------+---------+-----------+
| Total                    |    200 |     100 |     0.013 |
+--------------------------+--------+---------+-----------+

A DbHits cell whose figure was never counted (OperatorStats.DbHitsKnown false) renders as exec.DbHitsUnknown — "?" — and the Total then renders as "x + ?", so a reader can see that the sum is a floor and not the whole cost (rmp #2760):

+--------------------------+--------+---------+-----------+
| NodeByLabelScan          |    100 |     100 |     0.012 |
| └─ Filter                |     42 |       ? |     0.004 |
+--------------------------+--------+---------+-----------+
| Total                    |    142 | 100 + ? |     0.016 |
+--------------------------+--------+---------+-----------+

Both forms come from exec.DbHitsCell and exec.DbHitsTotalCell, which the indented tree renderer uses too, so the two renderings of one run cannot disagree about which figures exist.

The Removed column, present only when something removes rows

When at least one operator reports the rows it discarded (OperatorStats.RowsRemovedByFilterKnown), a fifth column appears between DbHits and Time — PostgreSQL's `Rows Removed by Filter`, abbreviated to fit a fixed-width table (rmp #2764):

+--------------------------+------+--------+---------+-----------+
| Operator                 | Rows | DbHits | Removed | Time (ms) |
+--------------------------+------+--------+---------+-----------+
| Filter                   |    3 |      ? |     997 |     0.412 |
| └─ NodeByLabelScan [P]   | 1000 |   1000 |         |     0.203 |
+--------------------------+------+--------+---------+-----------+
| Total                    | 1003 | 1000 + ? |       |     0.412 |
+--------------------------+------+--------+---------+-----------+

Two omissions in that table are deliberate and mean different things:

  • the SCAN's cell is blank because a scan removes no rows — there is no figure, as against the "?" one column left, which admits a figure exists and was not counted; and
  • the TOTAL's cell is blank because no plan-wide figure is claimed. Db-hits can be totalled because every operator is classified, so the sum is either complete or explicitly incomplete. Rejection is reported by three operator families only, and others discard rows for reasons this figure would misdescribe (SemiApply on an inner plan's emptiness, LIMIT on a count), so a summed cell would be a floor presented as a total. See exec's rowsRemovedCounter.

When NO operator reports the figure the column is not rendered at all and the table is byte-identical to the four-column form above. That is Neo4j's rule for an argument no plan node carries (renderAsTreeTable.scala, 5.26.16).

The Est.Rows column, present only when something was estimated

When at least one operator carries the planner's cardinality estimate (OperatorStats.Est), a column appears IMMEDIATELY LEFT of Rows so the prediction and the measurement can be read against each other on one line (rmp #2765, closing divergence D3):

+--------------------------+----------+------+--------+-----------+
| Operator                 | Est.Rows | Rows | DbHits | Time (ms) |
+--------------------------+----------+------+--------+-----------+
| Filter                   |      ~10 |    3 |      ? |     0.412 |
| └─ NodeByLabelScan [P]   |     1000 | 1000 |   1000 |     0.203 |
+--------------------------+----------+------+--------+-----------+
| Total                    |          | 1003 |   1000 |     0.412 |
+--------------------------+----------+------+--------+-----------+

The adjacency and the column order are Neo4j's, read at 5.26.16: Header.ALL lists ESTIMATED_ROWS immediately before ROWS (renderAsTreeTable.scala:212) and RenderAsTreeTableTest.scala:277 asserts that header verbatim. GoGraph keeps its own shorter spelling, Est.Rows, because that is what [cypher.Engine.ExplainTable] already prints and a reader moving between the two tables should not have to translate.

The cell conventions are exec.EstRowsCell's and are shared with that table: a bare number is an EXACT maintained count, a leading tilde marks an approximation, and "-" means no estimate — either none was derivable for the operator's shape, or the statistic behind it was stale. A fabricated number is never printed, and an estimate of genuine ZERO prints "0" rather than "-".

The Total row's Est.Rows cell is BLANK, for the same reason the Removed total is: only some operators carry an estimate, so a sum would be a floor presented as a total. Unlike db-hits there is not even a well-defined question a plan-wide estimate total would answer — estimates are per-operator predictions, not a cost that accumulates.

When NO operator carries an estimate the column is dropped and the table is byte-identical to the form above without it — Neo4j's rule again.

func TextTree

func TextTree(plan ir.LogicalPlan) string

TextTree renders a logical plan in Neo4j-style columnar text:

+-------------------------+----------+----------+
| Operator                | Est.Rows | Vars     |
+-------------------------+----------+----------+
| ProduceResults          |        - | n        |
| └─ NodeByLabelScan      |        - | n        |
+-------------------------+----------+----------+

It walks plan depth-first into PlanRow values and hands them to FormatPlanTable. Operator names come from ir.OperatorName, the same naming the engine's own plan renderers use, so a plain Apply reads as CartesianProduct and an Expand with a bound destination as ExpandInto exactly as they do in EXPLAIN. A node type ir.OperatorName does not know falls back to its concrete Go type name rather than to "Unknown", which keeps an out-of-tree plan node legible.

Est.Rows comes from the optional [estimator] interface. No in-tree plan node implements it, so every cell of a plan built by this module's planner renders "-"; the engine's cardinality estimates reach a plan table through FormatPlanTable instead. Vars lists the variables returned by ir.LogicalPlan.Vars.

Output is stable across runs: no map iteration order is relied upon. Children appear in ir.LogicalPlan.Children order.

Example

ExampleTextTree renders a logical plan as a columnar text table. The tree follows the plan's child order, so the output is stable across runs.

package main

import (
	"fmt"
	"strings"

	"github.com/FlavioCFOliveira/GoGraph/cypher/explain"
	"github.com/FlavioCFOliveira/GoGraph/cypher/ir"
)

func main() {
	plan := ir.NewProduceResults(
		[]string{"n"},
		ir.NewNodeByLabelScan("n", "Person"),
	)

	out := explain.TextTree(plan)

	// The rendered table is a fixed-width box; assert its structure rather
	// than embedding the exact padding so the example stays readable.
	fmt.Println("has header:", strings.Contains(out, "Operator") && strings.Contains(out, "Est.Rows"))
	fmt.Println("has root:", strings.Contains(out, "ProduceResults"))
	fmt.Println("has child:", strings.Contains(out, "NodeByLabelScan"))
	fmt.Println("child is nested:", strings.Contains(out, "└─ NodeByLabelScan"))
}
Output:
has header: true
has root: true
has child: true
child is nested: true

Types

type DbHitsCounter

type DbHitsCounter struct {
	// contains filtered or unexported fields
}

DbHitsCounter is a per-pipeline counter for logical storage accesses (index lookups, property reads, CSR neighbour scans). It is incremented by instrumented operator wrappers and read by the PROFILE reporter.

DbHitsCounter is safe for concurrent use.

func (*DbHitsCounter) Add

func (c *DbHitsCounter) Add(delta uint64)

Add increments the counter by delta.

func (*DbHitsCounter) Load

func (c *DbHitsCounter) Load() uint64

Load returns the current counter value.

func (*DbHitsCounter) Reset

func (c *DbHitsCounter) Reset()

Reset sets the counter back to zero.

type InstrumentedScan

type InstrumentedScan struct {
	// contains filtered or unexported fields
}

InstrumentedScan is a thin wrapper that counts dbHits per Next call. It adds 1 dbHit per row fetched from the underlying scan operator.

InstrumentedScan is NOT safe for concurrent use.

func NewInstrumentedScan

func NewInstrumentedScan(op exec.Operator, counter *DbHitsCounter) *InstrumentedScan

NewInstrumentedScan wraps op with a dbHit counter. Each successful Next call adds 1 to counter.

func (*InstrumentedScan) Close

func (s *InstrumentedScan) Close() error

Close implements exec.Operator. It delegates to the inner operator.

func (*InstrumentedScan) Init

func (s *InstrumentedScan) Init(ctx context.Context) error

Init implements exec.Operator. It delegates to the inner operator.

func (*InstrumentedScan) Next

func (s *InstrumentedScan) Next(out *exec.Row) (bool, error)

Next implements exec.Operator. It delegates to the inner operator and on a successful (true, nil) return increments the dbHits counter by 1.

type OperatorStats

type OperatorStats struct {
	// Name is the display name assigned when the operator was wrapped.
	Name string
	// Rows is the number of rows produced by successful Next calls.
	Rows uint64
	// DbHits is the number of logical storage accesses (see [DbHitsCounter]).
	// It is meaningful only when DbHitsKnown is true.
	DbHits uint64
	// ElapsedNs is the total nanoseconds spent inside Next across all calls.
	ElapsedNs int64
	// DbHitsKnown reports whether DbHits is a figure at all, mirroring
	// [exec.PlanNode.DbHitsKnown] on the node this row was flattened from.
	//
	// When it is false [FormatReport] prints [exec.DbHitsUnknown] in the cell
	// instead of a number, because an operator whose accesses nobody counted and
	// an operator that genuinely read nothing must not print the same 0
	// (rmp #2760).
	//
	// The zero value is therefore "unknown", which is deliberate: a report
	// assembled without considering the question should not silently assert that
	// every operator's storage cost was measured. A caller building a report by
	// hand from figures it does count sets this true.
	DbHitsKnown bool
	// RowsRemovedByFilter is the candidate rows this operator read and DISCARDED —
	// PostgreSQL's `Rows Removed by Filter`, mirroring
	// [exec.PlanNode.RowsRemovedByFilter] on the node this row was flattened from.
	// It is meaningful only when RowsRemovedByFilterKnown is true.
	RowsRemovedByFilter uint64
	// RowsRemovedByFilterKnown reports whether RowsRemovedByFilter is a figure at
	// all: true only for an operator that removes rows.
	//
	// It governs the report at TWO levels, which is what keeps the column honest
	// AND keeps it out of the way of every plan that has no filter in it:
	//
	//   - per CELL — a false leaves the cell BLANK rather than printing 0, because
	//     "this operator removes no rows" and "this filter removed none" are
	//     different facts and only the second is a measurement (rmp #2764); and
	//   - per COLUMN — [FormatReport] omits the whole Removed column when no
	//     operator in the report reports the figure, so a plan with no filter and
	//     no expansion renders exactly the four columns it always did. That is
	//     Neo4j's rule, which drops a column no plan node carries an argument for
	//     (renderAsTreeTable.scala, 5.26.16), rather than PostgreSQL's, which has no
	//     columns to drop.
	//
	// The zero value is "no figure", which is the correct default for a report
	// assembled by hand: a caller that has not thought about rejection should not
	// have the table assert that its operators rejected nothing.
	RowsRemovedByFilterKnown bool
	// Est is the planner's cardinality ESTIMATE for this operator and that
	// estimate's provenance, mirroring [exec.PlanNode.Est] on the node this row was
	// flattened from (rmp #2765).
	//
	// It is the one field here that is not a measurement, and the table keeps it in
	// its own column — Est.Rows, immediately LEFT of Rows — so the two can be read
	// against each other line by line. That adjacency is the whole point: it is what
	// tells a reader whether the plan they are looking at was chosen on a good guess
	// or a bad one, and it is the arrangement both incumbents chose (citations on
	// [exec.PlanEstimate]).
	//
	// Its zero value is [exec.EstimateAbsent], so a report assembled by a caller that
	// never considered estimates claims none — the same honest default the two
	// *Known flags above have, reached here through the enum rather than a companion
	// bool because provenance is part of the figure.
	Est exec.PlanEstimate
}

OperatorStats accumulates execution statistics for one operator.

The instance a ProfiledOperator embeds is mutated in place by every ProfiledOperator.Next call, without synchronisation, so it is NOT safe for concurrent use while its pipeline is draining: reading it from another goroutine — including through ProfiledOperator.Stats — races the accumulation. ProfiledOperator.Stats returns a copy, so once the pipeline has been drained that snapshot is an ordinary value and may be shared and read freely.

type PlanRow added in v0.13.0

type PlanRow struct {
	// Operator is the operator's display name, prefixed with its tree indent.
	Operator string
	// EstRows is the estimated row count cell, right-aligned when rendered.
	EstRows string
	// Vars is the comma-joined list of variables the operator exposes.
	Vars string
}

PlanRow is one operator line of a rendered plan table.

Operator already carries the tree indentation and connector for the node's depth, so a row is rendered verbatim into the Operator column; EstRows and Vars are the remaining two cells. A caller building rows by hand is free to leave EstRows empty, which renders as an empty cell rather than "-": "-" is the renderer's marker for "this node offers no estimate", and inventing it for a caller who simply did not fill the field would be a fabrication.

Concurrency

A PlanRow is a plain value carrying no shared state and no synchronisation of its own. Distinct rows are safe for concurrent use; a SINGLE row is not — it must not be written by one goroutine while another reads it, exactly as for any struct. FormatPlanTable only reads the rows it is handed, so a slice of rows no longer being mutated is safe for concurrent rendering.

type ProfileReport

type ProfileReport struct {
	// Operators holds per-operator statistics in the order they were added.
	Operators []OperatorStats
	// TotalRows is the sum of all operator row counts.
	TotalRows uint64
	// TotalDbHits is the sum of the operator dbHits that were KNOWN. Operators
	// whose accesses nobody counted contribute nothing to it and set
	// TotalDbHitsUncertain instead.
	TotalDbHits uint64
	// TotalDbHitsUncertain reports whether the sum in TotalDbHits omitted at least
	// one operator, so the query's real cost is TotalDbHits plus an unknown
	// amount. [FormatReport] then renders the Total cell as "x + ?" rather than as
	// a plain number that would read as complete.
	//
	// The name and the four rendered cases are Neo4j's, transcribed from
	// InternalPlanDescription.TotalHits and renderSummary.scala (5.26.16), where
	// an operator carrying no DbHits argument contributes TotalHits(0,
	// uncertain = true) and the flag is OR-ed across the plan.
	//
	// Unlike [OperatorStats.DbHitsKnown] this field's zero value means CERTAIN,
	// because it describes a sum rather than a cell: a report that summed nothing
	// unknown has omitted nothing, and a hand-built report of known figures needs
	// no extra field set to render its total honestly.
	TotalDbHitsUncertain bool
	// ElapsedMs is the total wall-clock time in milliseconds.
	ElapsedMs float64
}

ProfileReport is the textual PROFILE output collected after draining a pipeline instrumented with ProfiledOperator wrappers.

A report is assembled once, after the drain, and nothing here mutates it afterwards — FormatReport takes it by value and only reads it — so a finished report is safe for concurrent reads by any number of goroutines. The one caveat is Operators: every copy of the report shares that single backing array, so a caller must not append to it or overwrite its elements while another goroutine reads the report.

type ProfiledOperator

type ProfiledOperator struct {
	// contains filtered or unexported fields
}

ProfiledOperator wraps an exec.Operator and records per-call statistics. It implements exec.Operator.

ProfiledOperator is NOT safe for concurrent use.

func NewProfiledOperator

func NewProfiledOperator(op exec.Operator, name string) *ProfiledOperator

NewProfiledOperator wraps op, assigning it the display name given by name.

func (*ProfiledOperator) Close

func (p *ProfiledOperator) Close() error

Close implements exec.Operator. It delegates to the inner operator.

func (*ProfiledOperator) Init

func (p *ProfiledOperator) Init(ctx context.Context) error

Init implements exec.Operator. It delegates to the inner operator.

func (*ProfiledOperator) Next

func (p *ProfiledOperator) Next(out *exec.Row) (bool, error)

Next implements exec.Operator. It delegates to the inner operator, incrementing Rows on each (true, nil) return and accumulating elapsed time.

func (*ProfiledOperator) Stats

func (p *ProfiledOperator) Stats() OperatorStats

Stats returns the accumulated statistics for this operator.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL