Skip to content

feat(advise): a Redshift workload adapter — sort and distribution keys, not indexes - #15

Merged
hanslemm merged 15 commits into
mainfrom
feat/advise-redshift
Jul 31, 2026
Merged

feat(advise): a Redshift workload adapter — sort and distribution keys, not indexes#15
hanslemm merged 15 commits into
mainfrom
feat/advise-redshift

Conversation

@hanslemm

Copy link
Copy Markdown
Owner

Batch 3b, completing Batch 3. Adds a Redshift workload adapter: advise --engine redshift reads query
history and catalog metadata over a read-only session and proposes distribution and sort key
changes.

Redshift has no indexes, so none of ADV001–ADV008 apply

Its physical-design levers are different, and so is the blast radius. Three of the five new rules
recommend DDL that rewrites the entire table
— a lock, a full disk copy, hours on a large table, and
no CONCURRENTLY escape as Postgres has. The generated script says so far more loudly than the
Postgres one does.

rule proposes rewrites the table?
ADV101 SORTKEY from hot range/equality predicates (zone maps skip blocks) yes
ADV102 DISTKEY from hot join keys (avoids redistribution) yes
ADV103 DISTSTYLE ALL for a small, frequently-joined dimension yes
ADV104 VACUUM / ANALYZE from unsorted / stats_off no
ADV105 surfaces Redshift Advisor's own recommendations Advisor's DDL, attributed

ADV101/102/103 cannot reach HIGH, by construction — verified structurally (no Confidence.HIGH
literal in any of the three) and by an exhaustive sweep over every column role, cost shares including
inf/nan/negative, five stats_off/unsorted values, nine diststyles, four sortkeys and boundary
row counts. The reason is specific: Redshift exposes no per-column NDV, so distribution skew — what
makes a DISTKEY choice good or catastrophic — cannot be predicted
, and a SORTKEY only repays its
rewrite if the predicate is selective, which is what NDV would measure. Claiming HIGH would assert
something about data distribution the tool cannot see, while recommending a full table rewrite. ADV104
is the exception and may reach HIGH, because it doesn't rewrite and its inputs are direct measurements.

ADV105 stays the engine's opinion, never ours. It is the only signal in this adapter that comes
from the cluster rather than our inference, so its provenance is its entire value: attributed in the
title, rationale, evidence and in the DDL file itself, never merged into one of our proposals. Where
Advisor and one of our rules agree, the report says so — that agreement is the strongest evidence this
adapter can produce.

What is verified, and what is not

This is the honest core of the PR, and it is stated prominently in the README's advise section rather
than a footnote.

Verified: the connection path runs live against postgres:16, because Redshift speaks the
PostgreSQL wire protocol — the read-only session (proven read-only in fact: a CREATE TABLE through
the adapter's own querier is refused), the clamped statement timeout, credential scrubbing including a
percent-encoded DSN password, and per-capability degradation. Every statement is syntax-checked with
sqlglot's redshift dialect and proven bindable against a real server.

Not verified: the column names and proposal semantics come from AWS documentation and have never
been executed against a live Redshift cluster
. advise --engine redshift --dry-run prints every
statement for a user to run by hand, and the README invites exactly that — the first user with a
cluster is part of the verification loop, not a consumer of a finished feature.

The bindability check is worth describing, because the obvious version of it doesn't work. Running each
statement and accepting "relation does not exist" discriminates nothing: Postgres resolves table
references before parameter types, so a missing relation always masks a parameter bug — reintroducing
a real one still produced only UndefinedTable. The tests instead create same-shaped stand-in tables so
the analyzer resolves every parameter, at which point a bindable statement succeeds outright and an
unbindable one still fails. Those stand-ins also pin the adapter's column-shape assumptions in
executable form.

Bugs found along the way

  • Every default run was broken. (%s IS NULL OR start_time >= %s) fails on the wire when both
    binds are NULL — i.e. any run without --since. _run swallowed it, so the run reported a zero-query
    workload reading as "healthy cluster, no traffic". Found by executing the statement rather than
    reading it.
  • elapsed_time is microseconds, not milliseconds — a silent 1000× error in every cost figure and
    therefore every cost_share.
  • ADV102 and ADV103 both fired for a small hot-join dimension, recommending ALTER DISTKEY and
    ALTER DISTSTYLE ALL on one table: two conflicting hours-long rewrites where following both means
    doing one and undoing it. DISTSTYLE ALL subsumes a DISTKEY choice, so ADV102 is now withheld with
    its reason folded into the survivor.
  • Select-list column order was unverified in all four statements. Swapping unsorted and
    stats_off — same type, so nothing complained — inverts ADV104 and yields the wrong remediation at
    HIGH for every table. Now pinned by column name, not count.
  • A dbt seam that had never been exercised. Batch 3a's enrichment turned out to already cover
    Redshift's ALTER TABLE statements through a generic fallback — but testing that branch for the first
    time revealed it clobbered a proposal's existing note, destroying ADV105's Advisor attribution.

Honest limitations, all documented

  • Column names and semantics unverified against a real cluster (above).
  • Without SYSLOG ACCESS UNRESTRICTED, sys_query_history silently shows only the connecting user's
    own queries — the workload looks small rather than denied, and there is no error to notice.
  • --limit truncates executions, not query groups, because sys_query_history is per-execution
    where pg_stat_statements is pre-aggregated.
  • Identifier case and comments split one statement into several fingerprints, inflating the group count
    and shrinking every cost_share.
  • svv_table_info absence cannot distinguish a Spectrum table (which can carry none of these) from an
    empty local one, so those relations are declined — and the count is now disclosed rather than
    vanishing silently.

Verification

  • 927 passed, 23 deselected — zero skips, no extras, no Docker
  • 23 passed integration against live postgres:16
  • main's 725 tests pass against this branch's src/ — no Postgres or dbt regression
  • ruff check, ruff format --check, mypy src/sqlquality clean

Follow-ups, not in this PR

  • _classify's DROP INDEX branch has the same terminal-invisibility shape as the one fixed here and
    is reachable on Postgres; counting it would change Postgres stderr, so it is left for a separate
    branch.
  • CI still runs no integration tests (no Postgres service in ci.yml).
  • Snowflake remains deferred pending an account to verify against.

🤖 Generated with Claude Code

hanslemm and others added 15 commits July 28, 2026 15:53
…d statements

Adds RedshiftWorkloadAdapter (engine="redshift") with four capabilities —
workload, schema, table_facts, advisor — sourced from sys_query_history,
svv_columns, svv_table_info and svv_alter_table_recommendations. Column
names come from AWS documentation and are unconfirmed against a live
cluster, so the module docstring and per-statement comments say so, and
every SQL string is syntax-checked with sqlglot's redshift dialect.

Deliberately no CAP_NDV or CAP_INDEXES: Redshift has neither an NDV
equivalent nor indexes. connect()/fetch_workload/fetch_schema/
fetch_table_facts/propose/render_ddl all raise NotImplementedError rather
than returning empty results, which would look like a healthy, idle
cluster. Registered in workload/__init__._ADAPTERS so --dry-run and
get_workload_adapter("redshift") work; later tasks fill in the fetchers
and add ADV101-105 for SORTKEY/DISTKEY/DISTSTYLE and VACUUM/ANALYZE.
Task 1 leaves six `WorkloadAdapter` methods raising `NotImplementedError`, on the
grounds that a `fetch_*` returning empty is indistinguishable from a healthy
cluster running no workload — the worst failure mode this command has. Only
`fetch_schema` was pinned, so a later task could implement `fetch_workload` and
silently leave `fetch_table_facts` returning `{}`: the run would then report a
cluster with a workload and no catalog facts rather than an unfinished adapter.

Each method is now covered by its own parametrised case, with the call that
reaches it. Named explicitly rather than discovered by reflection, so a task that
implements one must delete its entry — a visible, reviewable edit — where a
reflective sweep would silently stop covering whatever got implemented.

Verified: replacing each of the five single-line raises with a benign empty return
fails exactly its own case (1 failed / 5 passed, five times), from purged caches,
with the source restored byte-identical after each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tocol

Redshift speaks libpq through psycopg, so connect() is the one adapter method
genuinely verifiable without a live cluster. Extracts the shared psycopg
session-setup mechanism (driver import with install hint, conninfo build
inside the scrubbing envelope, statement timeout clamp, secret scrubbing with
the __context__ severance) out of PostgresWorkloadAdapter.connect() into a new
workload/session.py helper that both adapters call, so the credential-handling
path has one place to audit rather than two that can drift.

The one behavioral difference is preserved deliberately: Redshift does not
accept SET default_transaction_read_only in every configuration, so a refusal
there is recorded as a degradation ("could not be proven read-only") rather
than aborting the connection, whereas the same refusal on Postgres still
aborts like any other setup failure.

Verified live against a throwaway postgres:16 container (dp-pg-test on 55432
left untouched), and by mutation-testing each pinned line (scrub removal,
__context__ chaining, and both read-only branches) to confirm the
corresponding tests actually fail.
…raction

- Pin that the read-only degradation message is scrubbed: a fake driver
  refusal quoting a password, mutation-tested by removing scrub() on that
  path.
- Pin that Postgres's connect() aborts (not silently degrades) when its
  own read-only statement is refused, exercised through the adapter itself
  rather than only through the shared helper, since no existing Postgres
  test forces that statement to fail.
- Finish the field-translation extraction: LIBPQ_FIELD_MAP and
  LIBPQ_PASSTHROUGH_FIELDS now live once in session.py; postgres.py and
  redshift.py both call translate_libpq_fields/dropped_libpq_fields against
  the same table instead of each carrying an identical copy.
- Pin the Redshift field table against the actual conninfo content
  (aliases, TLS passthrough), mirroring Postgres's equivalent test.
- Pin import_psycopg's engine label at the Redshift call site.
- Derive the live wrong-password DSN by parsing and re-encoding rather than
  a literal string substitution, which was a silent no-op under any custom
  SQLQUALITY_TEST_DSN not using the default password.

Every fix mutation-tested RED and restored; the four protected Postgres
test files remain zero-diff against ba416a0.
Task 2 deduplicated the libpq field translation into one `LIBPQ_FIELD_MAP` that
both adapters share. Probing it found single-adapter coverage: scrambling the
`dbname`/`database` entries left every Postgres test green and only Redshift's
noticed, because Postgres's conninfo test passes neither key — it covers the TLS
passthrough group but not the aliasing half of the table.

That is a pre-existing hole rather than a regression, and it was invisible while
the two adapters each had their own copy of the map. Sharing the table is what
made it matter: one edit now reaches both engines, so both should notice.

`database` is dbt's spelling of libpq's `dbname`, so the test now passes that and
asserts both the translation and that the untranslated key is not forwarded.

Verified: mutating the `dbname`/`database` entries in the shared map now fails two
tests where it previously failed one, and the TLS-passthrough mutation continues
to fail two. Source restored byte-identical after each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sys_query_history carries a per-execution start_time, unlike pg_stat_statements,
so --since is genuinely honoured here (with an honest window_description either
way) and fetch_workload emits one RawQueryRow per execution (calls=1), leaving
the collapse into per-fingerprint QueryStats to the engine-agnostic ingest() —
confirmed by a test with two executions of the same statement rather than
assumed. fetch_schema mirrors PostgresWorkloadAdapter's nested schema map and
notes that svv_columns carries external (Spectrum) tables that svv_table_info
(fetch_table_facts, still unbuilt) will not.

Also confirms a carried-forward item: cli.py calls fetch_workload() right after
connect(), and until now that call raised NotImplementedError, so a connect()-
time read-only degradation could never survive to reach the operator. It now
does — pinned by a new test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds unsorted, stats_off, diststyle, sortkey1 and skew_rows to CAP_TABLE_FACTS
(reserved-word quoting on "schema"/"table" preserved) — the evidence base a
later task needs for ADV103 (DISTSTYLE ALL) and ADV104 (VACUUM/ANALYZE).
Redshift's own version of pg_class.reltuples = -1 lives in stats_off: at 100,
tbl_rows/size reflect statistics never refreshed by ANALYZE and are translated
to unknown (None) at the boundary rather than read as small-table facts, with
a test per sentinel and a control proving a merely-unknown stats_off does not
also trigger it.

TableFacts stays engine-neutral; the Redshift-specific columns live in a new
adapter-local RedshiftTableFacts, keyed by Relation like postgres.py's PgIndex,
stashed on RedshiftWorkloadAdapter.physical_facts for a later task. Because
svv_columns (fetch_schema) sees external Spectrum tables that svv_table_info
does not, fetch_table_facts deliberately omits such a relation from its result
entirely rather than filling it with None fields, so its absence — not a
sentinel value — is what a later SORTKEY/DISTKEY rule must check for.

Also pins each of the four capabilities' SELECT-list arity against what its
real consumer unpacks (a dynamically-sized fixture, not a hand-picked one),
closing the same column-count-mismatch class Batch 2 shipped undetected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Critical: CAP_WORKLOAD's (%s IS NULL OR start_time >= %s) failed to bind on
every default (--since-less) run -- reproduced live as psycopg's
IndeterminateDatatype -- which _run then swallowed into a silent zero-query
workload. Fixed with CAST(%s AS timestamptz), and added a bindability check
executing all four statements against throwaway stand-in tables in postgres:16
(a plain "run it and expect UndefinedTable" version does not discriminate: the
missing-relation error masks a parameter bug identically either way, so this
creates same-named, same-shaped tables instead so the analyzer actually
resolves each parameter's type).

Important: inverted the stats_off sentinel. AWS documents tbl_rows/size as
physical facts, not ANALYZE output, and stats_off as a staleness percentage,
not a never-analyzed flag -- so the previous gate discarded accurate facts for
a merely-stale table. Dropped the gate; stats_off is still carried as evidence
on RedshiftTableFacts for a later rule to disclose as a caveat.

Also: window_description now names the ORDER BY ... LIMIT n truncation
instead of implying full coverage since --since; documented (and pinned with a
test) that identifier-case/comment variance in sys_query_history's verbatim
text can still split one statement into two QueryStats, with its cost_share
consequence spelled out; a NULL elapsed_time no longer crashes the whole run;
CAP_TABLE_FACTS's hint now says svv_table_info is superuser-only; and
_schema_cache's one-fetch-per-schema-tuple claim is now pinned by a test.

Recorded two carry-forwards for Tasks 5-6: absence from svv_table_info alone
cannot identify a Spectrum table (AWS also omits empty tables), and the
connect()-time read_only degradation still cannot reach cli.py's stderr until
propose() exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both propose a single hot column from the workload's RANGE/EQUALITY or
JOIN usage, suppress when the table is already sorted/distributed on it,
and cap at MEDIUM with no HIGH branch: Redshift exposes no per-column
NDV, so the skew/selectivity that would justify HIGH cannot be measured,
while the DDL (ALTER SORTKEY / ALTER DISTKEY) rewrites the whole table.
A relation absent from svv_table_info (Spectrum table or empty table,
indistinguishable from here) gets no proposal rather than a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADV103 proposes DISTSTYLE ALL for a small, frequently-joined dimension,
gated on a row-count ceiling (the inverse of Postgres's index floor) and
capped at MEDIUM for the same no-NDV reason as ADV101/102. ADV104 flags
VACUUM/ANALYZE from svv_table_info's unsorted/stats_off measurements —
the one rule whose remediation doesn't rewrite the table, so it is also
the only one allowed to reach HIGH. ADV105 relays Amazon Redshift
Advisor's own svv_alter_table_recommendations rows verbatim, clearly
attributed as the engine's opinion rather than sqlquality's; where
Advisor agrees with one of our own proposals on the same relation, that
agreement is disclosed as a sentence rather than merged into one object.

propose() now wires all five rules together and is removed from
UNIMPLEMENTED, which lets the read-only degradation connect() records
finally reach a user end to end (see the new CLI regression test) —
carried since Task 2's connect() implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Suppress ADV102 (DISTKEY) when ADV103 (DISTSTYLE ALL) fires for the
  same relation: applying both means one full-table rewrite undoing
  another, since DISTSTYLE ALL strictly subsumes any single-column
  DISTKEY choice. The withheld ADV102's distinguishing rationale is
  folded into the ADV103 survivor rather than silently dropped.
- Count relations skipped by ADV101/102/103 because they are absent
  from svv_table_info (the Spectrum/empty-table ambiguity) and disclose
  the count through the same `self.degraded` channel a denied
  capability uses, instead of letting them vanish with no trace.
- Pin propose()'s wiring for every one of ADV101-105 individually (only
  ADV101, ADV104 and ADV105 were exercised through the dispatcher
  before), and pin ADV105's attribution in title, rationale and a new
  evidence["source"] field so it cannot be rewritten to read as
  sqlquality's own conclusion.
- Pin the substance of each "capped at MEDIUM because ..." sentence for
  ADV101/102/103, not just the confidence value.
- Four minors: cli.py's --min-cost-share help text now names ADV101-105;
  fixed a deterministic-order test that fed already-sorted input;
  pinned re.IGNORECASE and the "no KEY column" conjunct in the
  diststyle parsers; pinned _quote_ident's quote-doubling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it exposed

render_ddl's header is deliberately far louder than Postgres's: ADV101 (ALTER
SORTKEY), ADV102 (ALTER DISTKEY) and ADV103 (ALTER DISTSTYLE ALL) each rewrite
the whole table with no CONCURRENTLY escape, so the header says plainly they
must be scheduled rather than run ad hoc, and names ADV104 (VACUUM/ANALYZE) as
the one statement that is not a rewrite. ADV105 (Redshift Advisor's own DDL)
gets a per-statement "(Amazon Redshift Advisor -- not sqlquality)" marker in
its header line, on top of its existing note, so a reader skimming only
headers still cannot mistake it for sqlquality's own inference. Reuses
`cost_share_of` and `_is_fully_commented` rather than reimplementing either.

Checked the dbt enrichment interaction empirically rather than assuming: Batch
3a's `enrich_proposals` already has a generic fallback (in `_classify`, built
for "some other statement... no rule emits one today") that recognises any
DDL that is neither CREATE INDEX nor DROP INDEX and warns that a dbt-managed
relation's statement is not expressed as config and may not survive a
rebuild -- so Redshift's ADV101-105 are covered with no extension needed. That
branch was untested until now, and testing it surfaced a real bug: it built
`note` as only the dbt warning, silently discarding whatever note the
proposal already carried. For ADV105 that note is the Advisor attribution
sentence -- the one place besides the per-statement header marker that says
"Redshift generated this, not sqlquality" -- so a dbt-managed relation with an
Advisor recommendation would have shipped a DDL script missing it. Fixed with
`_prepend_note`, which appends the dbt warning after the existing note instead
of replacing it, for both the generic fallback and the DROP INDEX branch.

Removed `tests/test_workload_redshift.py`'s now-empty `UNIMPLEMENTED` map and
its parametrised test now that render_ddl is the last method it covered.

All four gates green (904 passed, 22 deselected, 0 skips) plus `pytest -m
integration` (22 passed). Postgres's test files carry no diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the live proof Task 8 called for and documents it prominently rather
than in a footnote:

- A new live test proves the statement-timeout clamp actually reaches the
  server (not just a fake driver's execute() log), by querying
  current_setting('statement_timeout') after connecting with an out-of-range
  value. connect(), the read-only session and secret scrubbing were already
  covered live from Task 2.
- New unit tests prove `advise --engine redshift --dry-run` prints all four
  capabilities and needs no credentials, mirroring the existing Postgres
  dry-run tests.

README: the `advise` section now states, prominently and up front rather
than in Limitations, which parts of the Redshift adapter are proven (the
connection path, live; every statement's syntax and bindability, live
against stand-in tables) and which are not (column names and the resulting
proposals' semantics, sourced from AWS docs and never executed against a
live cluster) -- and invites a user with a real cluster to report back. Adds
a Redshift rule table (ADV101-105) with the table-rewrite warning attached
to ADV101/102/103, ADV104 marked as the safe exception, and ADV105
attributed to Amazon Redshift Advisor. Documents the dbt interaction
decision from the previous commit and where a user sees it. `--min-cost-share`
help text was already accurate for ADV101-105; no change needed there.

CHANGELOG gets an [Unreleased] entry for the whole engine. The design spec
gets a new "Deviations from the spec (Batch 3b: Redshift adapter)" section
recording: the rule renumbering and content differences from the original
spec table; no CAP_NDV/CAP_INDEXES and why; why HIGH is structurally
unreachable for ADV101-103; that the connection path is verified live while
the catalog path is not (the central risk this batch accepts); and that
`svv_table_info` absence cannot distinguish a Spectrum table from a
genuinely empty one.

All four gates green (904 passed, 23 deselected, 0 skips) plus `pytest -m
integration` (23 passed). Verified against a no-extras sync too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… text

Every survivor of the final review was one family: a claim made about statement
text, pass ordering or terminal disclosure that the suite never correlated with
behaviour. Each is now pinned by a test that fails without the fix.

- select-list column ORDER for all four Redshift statements, not just arity: the
  fixture row is built by looking a canned value up by column name and placing it
  where the SQL puts that name, then run through the real consumer, so swapping
  two same-typed columns (unsorted/stats_off inverts ADV104's remediation at HIGH)
  reddens its own parametrised case. Closes the stale CAP_ADVISOR arity pin.
- CAP_WORKLOAD's database scope and success filter, the guards Postgres carries
  and this engine did not.
- propose()'s collapse-before-agreement order, which reversed makes ADV103 claim
  an Advisor agreement Advisor never made.
- dbt enrichment now discloses itself in the terminal on Redshift too:
  describe_rewrites counted only ADV302's config path, which no Redshift proposal
  reaches, so an enriched row was byte-identical to a dbt-free run. Pinned end to
  end through cli.py.
- the collapse's fold of a withheld ADV102's distinguishing sentences.
- the privilege hint inside a `degraded` entry, on both adapters.
- _prepend_note's order, and it is now idempotent.
- ADV105's scope widened to match ADV104's, so a relation reached only by SELECT *
  can still get Advisor's own opinion.
- README: the silent SYSLOG ACCESS UNRESTRICTED partial-workload trap, --limit
  meaning executions on Redshift, and the fingerprint split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hanslemm
hanslemm merged commit dda5986 into main Jul 31, 2026
6 checks passed
@hanslemm
hanslemm deleted the feat/advise-redshift branch July 31, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant