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 ¶
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 )
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.
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.
const MaxTop = sqlrows.DigestTextFetchLimit
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 ¶
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 ¶
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 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 ¶
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.