queryplan

package
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package queryplan runs EXPLAIN against the statements that dominated a benchmark run and publishes the resulting plans.

Where the statement text comes from

The proxy driver never sees an interpolated statement — it is handed the query and its arguments separately — so there is no raw SQL to keep. The text explained here is MySQL's own QUERY_SAMPLE_TEXT, one recorded example per digest, read back at the end of the run. That text carries literals, and therefore never leaves the callback that read it: nothing on Plan, on Section, or on any error this package builds can hold it. Plan.Query is the normalized DIGEST_TEXT sqlrows already published, and a failed EXPLAIN is reduced to a closed classification plus the driver's numeric error code (see PlanError). A driver message is neither stored nor logged, because MySQL's 1064 quotes a fragment of the statement back at the caller.

Credential

EXPLAIN runs on the least-privilege PurposeExplain credential only. When a target has none, the target is skipped and the reason is recorded; the application's own credential is never used as a substitute, because EXPLAIN SELECT can still have side effects through a stored function and the restricted user is what rules that out. The privileges of that user are verified on the very connection the EXPLAIN runs on — roles are neutralised and expanded, and the effective grants are checked against an allowlist — rather than trusted from configuration.

When it runs

Capture belongs in runctl's Enrich hook, which runs once per run after the interval has been collected and before the snapshot is published, inside runctl.EnrichBudget. It must not be wired to a GET or to a non-terminal flush: those have no interval to rank digests by, and would put EXPLAIN statements on the measured database every time someone opens the dashboard.

Index

Constants

View Source
const (
	// Name is the snapshot section key.
	Name = "queryplan"

	// EnvFlag is the master flag, and it is off unless it is explicitly
	// turned on: EXPLAIN issues extra statements against the measured
	// database, so it is opt-in rather than opt-out.
	EnvFlag = "ISUTOOLS_EXPLAIN"

	// EnvTop overrides how many digests of one target are explained.
	EnvTop = "ISUTOOLS_EXPLAIN_TOP"

	// DefaultTop is how many SELECT digests per target are selected when
	// EnvTop is unset. It is a selection ceiling, not a promise: the digest
	// loop stops as soon as the per-target budget can no longer fit another
	// EXPLAIN.
	DefaultTop = 10
)
View Source
const (
	// SessionBudget bounds session establishment: role neutralisation, grant
	// verification, de-instrumentation, capability probing and USE.
	SessionBudget = 300 * time.Millisecond
	// SampleBudget bounds the single statement that reads every sample.
	SampleBudget = 100 * time.Millisecond
	// PerDigestBudget bounds one EXPLAIN. It is a cutoff, not a reservation:
	// a real EXPLAIN takes single-digit milliseconds, and reserving ten of
	// these would exceed the whole enrich budget on the first target.
	PerDigestBudget = 250 * time.Millisecond
)

Budgets carved out of runctl.PerTargetBudget.

runctl owns the hierarchy — this package cites PerTargetBudget (1s) and EnrichBudget (2s) and never redefines them — but how one target's second is divided is this package's business. The three numbers below add up to 650ms, which leaves the digest loop the remainder of a target's second after session establishment and the sample read.

View Source
const (
	// CodePurposeUnregistered means the target has no PurposeExplain
	// credential. It is never treated as a reason to use another one.
	CodePurposeUnregistered = "explain-purpose-unregistered"
	// CodeUnknownTarget means the registry does not know the target ID the
	// interval was published under.
	CodeUnknownTarget = "explain-unknown-target"
	// CodeNoSchema means there is no schema name that can be bound to
	// WHERE SCHEMA_NAME = ? and quoted into USE.
	CodeNoSchema = "explain-no-schema"
	// CodeUnsupported means the server has no QUERY_SAMPLE_TEXT column, i.e.
	// it is older than MySQL 8.0.17 or is MariaDB.
	CodeUnsupported = "explain-unsupported"
	// CodeRolesActive means active roles could neither be neutralised nor
	// enumerated, so the effective privileges are unknown.
	CodeRolesActive = "explain-roles-active"
	// CodeGrantsTooBroad means the effective privileges include something
	// outside the allowlist.
	CodeGrantsTooBroad = "explain-grants-too-broad"
	// CodeGrantsUnverifiable means SHOW GRANTS could not be read or could not
	// be parsed. An unknown grant line is treated as a dangerous one.
	CodeGrantsUnverifiable = "explain-grants-unverifiable"
	// CodeSessionInstrumented means the session could not be proven
	// uninstrumented, so its own statements would land in the interval the
	// next run measures.
	CodeSessionInstrumented = "explain-session-instrumented"
	// CodeBudgetExhausted means the enrich budget ran out before this
	// target's wave could start. Recorded rather than dropped.
	CodeBudgetExhausted = "explain-budget-exhausted"
	// CodeTargetTimeout means the target's session did start but had not
	// returned when the enrich budget expired, so the capture stopped waiting
	// for it. Unlike CodeBudgetExhausted it points at one connection rather
	// than at the fan-out: statements were issued, and something — a driver
	// that ignores its context, a connection stuck in a syscall, a proxy that
	// never answered — did not come back inside the budget.
	CodeTargetTimeout = "explain-target-timeout"
	// CodeQueryError means a statement of the session sequence failed for a
	// reason none of the codes above describes.
	CodeQueryError = "explain-query-error"
	// CodeNoInterval means sqlrows published no usable interval for the
	// target, so there is nothing to rank digests by.
	CodeNoInterval = "explain-no-interval"
	// CodeNoDigests means the interval holds no SELECT digest worth
	// explaining.
	CodeNoDigests = "explain-no-digests"
	// CodeNoDefaultDatabase means the server answered EXPLAIN with 1046 "No
	// database selected", i.e. the USE step did not take effect. That is a
	// fault of this package rather than of the statement, so it is reported in
	// health as well as on the plan.
	CodeNoDefaultDatabase = "explain-no-default-database"
)

Reason IDs for a target that produced no plans. They are stable identifiers: the dashboard maps them to labels and the ABBA gate greps for them, so a new condition gets a new ID rather than a reworded message.

MaxTop bounds EnvTop. It is sqlrows' own row limit rather than a new number, because a digest outside that limit has no interval row to attach a plan to.

Variables

View Source
var ErrNoInterval = errors.New("queryplan: no sqlrows section to enrich")

ErrNoInterval reports that Capture was called without an interval to enrich. It is the one error Capture returns: everything else is a reason recorded on a target, because enrichment must never fail a run.

Functions

func Enabled

func Enabled() bool

Enabled reports whether EXPLAIN capture should be wired in at all.

Capture itself does not consult the environment: the flag is the integration point's gate, so that a test can drive Capture without setting process-wide state, and so that "the feature is off" means "no statement was ever issued" rather than "the statements were issued and discarded".

func TopN

func TopN() int

TopN reports the configured selection ceiling, falling back to DefaultTop for an unset, unparseable, non-positive or oversized value. A misspelt number degrades to the default instead of disabling the feature, because a silently empty section is the harder failure to notice.

Types

type FreshReason

type FreshReason string

FreshReason is the closed set of reasons behind a FreshnessState.

const (
	// FreshInInterval: the sample time is inside the conservative window.
	FreshInInterval FreshReason = "in_interval"
	// FreshBeforeInterval: it precedes the window, so the plan describes an
	// execution from before this run.
	FreshBeforeInterval FreshReason = "before_interval"
	// FreshAfterInterval: it follows the window.
	FreshAfterInterval FreshReason = "after_interval"
	// FreshClockAnomaly: sqlrows reported a non-monotonic database clock.
	FreshClockAnomaly FreshReason = "db_clock_anomaly"
	// FreshClockMissing: the target carries no database clock at all.
	FreshClockMissing FreshReason = "db_clock_missing"
	// FreshRunPartial: the run's interval is partial, so the window it would
	// be judged against is not the run.
	FreshRunPartial FreshReason = "run_partial"
	// FreshIntervalShort: the window closed after narrowing, i.e. the run was
	// shorter than the rounding this package applies.
	FreshIntervalShort FreshReason = "interval_too_short"
)

type FreshnessState

type FreshnessState string

FreshnessState says whether a sample can be trusted to describe this run.

Three values rather than two: when the database's clock is not trustworthy, "stale" would be a claim the evidence does not support. Only fresh plans are advisor input; stale and unknown are displayed greyed out with their reason.

const (
	// FreshnessFresh means the sample was recorded inside the measured
	// interval.
	FreshnessFresh FreshnessState = "fresh"
	// FreshnessStale means it was recorded outside it.
	FreshnessStale FreshnessState = "stale"
	// FreshnessUnknown means the question could not be answered.
	FreshnessUnknown FreshnessState = "unknown"
)

type HealthNote

type HealthNote struct {
	Key     string `json:"key"`
	Message string `json:"message"`
}

HealthNote is one grouped degradation message. Key is the reason ID.

type Input

type Input struct {
	// Rows is the interval sqlrows published for the run being enriched.
	Rows *sqlrows.Section
	// Top bounds how many SELECT digests of one target are explained. Zero
	// means TopN(), i.e. ISUTOOLS_EXPLAIN_TOP or DefaultTop.
	Top int
	// Inspect defaults to sqlstats.Inspect. It is always called with
	// PurposeExplain; a target without that credential is skipped.
	Inspect InspectFunc
	// Now defaults to time.Now. It is only ever used to measure the remaining
	// budget — never to judge a sample's freshness, which is decided on the
	// database's own clock.
	Now func() time.Time
	// Concurrency defaults to runctl.BaselineConcurrency.
	Concurrency int
}

Input is everything Capture needs.

The interval comes in rather than being fetched, because ranking digests and judging freshness are the same decisions sqlrows already made: the digests to explain are its top rows, and the window a sample must fall into is its DBClock. Re-reading either from the database would produce a second opinion that could disagree with the numbers shown next to the plans.

type InspectFunc

type InspectFunc func(ctx context.Context, id string, purpose sqlstats.Purpose, fn func(context.Context, sqlstats.Querier) error) error

InspectFunc is the registry entry point this package reaches targets through. It matches sqlstats.Inspect and exists as a named type so tests can drive a capture without a database.

type Plan

type Plan struct {
	Digest string `json:"digest"`
	// Query is sqlrows' truncated DIGEST_TEXT — normalized, literal-free.
	Query string `json:"query"`
	// SampleSeen is QUERY_SAMPLE_SEEN, the database's own clock reading of
	// when the explained example ran.
	SampleSeen time.Time `json:"sample_seen"`
	// Freshness says whether that reading falls inside the measured interval.
	Freshness FreshnessState `json:"freshness"`
	// FreshReason is the closed reason enum behind Freshness.
	FreshReason FreshReason `json:"fresh_reason,omitempty"`
	// Rows is the EXPLAIN output, empty when Err says why there is none.
	Rows []PlanRow  `json:"rows,omitempty"`
	Err  *PlanError `json:"err,omitempty"`
}

Plan is one digest's execution plan.

There is deliberately no field a sample's text could be stored in. Query is the normalized DIGEST_TEXT sqlrows published, and Err is a classification rather than a message.

type PlanError

type PlanError struct {
	Class PlanErrorClass `json:"class"`
	// Errno is the driver's numeric error code, 0 when there was none.
	Errno uint16 `json:"errno,omitempty"`
	// SQLState is the five-character SQLSTATE, empty unless it matches
	// ^[0-9A-Z]{5}$ exactly.
	SQLState string `json:"sqlstate,omitempty"`
}

PlanError is the whole of what a failed EXPLAIN contributes to a snapshot.

The driver's own message is deliberately absent. MySQL's 1064 quotes the offending fragment of the statement back at the caller ("near '...'"), and the statement here is a sample containing literals, so any design that keeps the message and tries to scrub it has already lost: truncation, escaping and partial quoting all defeat a substring check. Instead the message is mapped to a class inside the callback that produced it and then dropped.

The type is the guarantee. It has exactly two string-shaped fields, both drawn from closed vocabularies — a class constant and a validated SQLSTATE — and TestPlanErrorHasNoFreeText fails if a third is ever added.

type PlanErrorClass

type PlanErrorClass string

PlanErrorClass is the closed set of reasons a digest has no plan.

const (
	// PlanErrTimeout: the statement ran out of its slice of the budget.
	PlanErrTimeout PlanErrorClass = "timeout"
	// PlanErrBudgetExhausted: it was never issued, because the remaining
	// budget could not fit it.
	PlanErrBudgetExhausted PlanErrorClass = "budget_exhausted"
	// PlanErrPermission: the least-privilege user may not read something the
	// statement touches. Expected, and worth showing.
	PlanErrPermission PlanErrorClass = "permission_denied"
	// PlanErrSyntax: the server rejected the sample, usually because the
	// recorded text was cut short.
	PlanErrSyntax PlanErrorClass = "syntax_or_truncated"
	// PlanErrObjectMissing: a table, column or database named by the sample
	// does not exist any more.
	PlanErrObjectMissing PlanErrorClass = "object_missing"
	// PlanErrSampleUnavail: performance_schema kept no sample for the digest.
	PlanErrSampleUnavail PlanErrorClass = "sample_unavailable"
	// PlanErrSampleTruncated: the sample is as long as
	// performance_schema_max_sql_text_length, so it may end mid-statement and
	// is not sent to the server at all.
	PlanErrSampleTruncated PlanErrorClass = "sample_possibly_truncated"
	// PlanErrConnection: the connection was lost.
	PlanErrConnection PlanErrorClass = "connection_error"
	// PlanErrOther: anything else. The errno is still recorded.
	PlanErrOther PlanErrorClass = "other"
)

type PlanRow

type PlanRow struct {
	SelectType   *string `json:"select_type,omitempty"`
	Table        *string `json:"table,omitempty"`
	Type         *string `json:"type,omitempty"`
	Key          *string `json:"key,omitempty"`
	PossibleKeys *string `json:"possible_keys,omitempty"`
	Rows         *int64  `json:"rows,omitempty"`
	Extra        *string `json:"extra,omitempty"`
}

PlanRow is one row of EXPLAIN output.

Every column is a pointer because every column of MySQL's EXPLAIN can be NULL — an impossible WHERE produces a row that is almost entirely NULL — and a missing column has to render as an empty cell rather than as a parse failure.

type Section

type Section struct {
	// Targets is ordered by TargetID so two snapshots diff cleanly.
	Targets []TargetSection `json:"targets"`
	// Health carries this section's degradation notes, grouped by reason.
	Health []HealthNote `json:"health,omitempty"`
	// Top is the selection ceiling this run used, recorded so a short plan
	// list can be told from a truncated one.
	Top int `json:"top"`
}

Section is the snapshot section this package contributes.

It carries no Validity: EXPLAIN capture is optional enrichment, and the run's verdict is sqlrows' to lower. A target that produced nothing says so with a reason ID instead of degrading the run.

func Capture

func Capture(ctx context.Context, in Input) (*Section, error)

Capture explains the statements that dominated the run and returns the section to publish.

It is shaped for runctl's Enrich hook: called once per run, after the interval exists and before the snapshot is published, with the enrich budget's context. The returned error is only ever ErrNoInterval — a target that could not be explained comes back as a reason ID on that target, since a run must not be degraded by an optional extra. The returned Section is always non-nil.

sec, err := queryplan.Capture(ctx, queryplan.Input{Rows: rowsSection})
if err == nil {
    snap.Sections[queryplan.Name] = sec
}

type TargetSection

type TargetSection struct {
	TargetID string `json:"target_id"`
	Schema   string `json:"schema,omitempty"`
	// Explained reports that a session was established and the plans below
	// were attempted on the database. It is false both for a skipped target
	// and for one whose freshness verdict was decided without connecting.
	Explained bool `json:"explained"`
	// Code and Reason explain a target that produced no plans. Reason is
	// always one of the fixed sentences in reasons.
	Code   string `json:"code,omitempty"`
	Reason string `json:"reason,omitempty"`
	// Plans keeps the interval's ranking order: highest total time first.
	Plans []Plan `json:"plans,omitempty"`
}

TargetSection is one target's plans.

Jump to

Keyboard shortcuts

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