feat(advise): a Redshift workload adapter — sort and distribution keys, not indexes - #15
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch 3b, completing Batch 3. Adds a Redshift workload adapter:
advise --engine redshiftreads queryhistory 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
CONCURRENTLYescape as Postgres has. The generated script says so far more loudly than thePostgres one does.
SORTKEYfrom hot range/equality predicates (zone maps skip blocks)DISTKEYfrom hot join keys (avoids redistribution)DISTSTYLE ALLfor a small, frequently-joined dimensionVACUUM/ANALYZEfromunsorted/stats_offADV101/102/103 cannot reach HIGH, by construction — verified structurally (no
Confidence.HIGHliteral in any of the three) and by an exhaustive sweep over every column role, cost shares including
inf/nan/negative, fivestats_off/unsortedvalues, nine diststyles, four sortkeys and boundaryrow 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
advisesection ratherthan a footnote.
Verified: the connection path runs live against
postgres:16, because Redshift speaks thePostgreSQL wire protocol — the read-only session (proven read-only in fact: a
CREATE TABLEthroughthe 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
redshiftdialect 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-runprints everystatement 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 sothe 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
(%s IS NULL OR start_time >= %s)fails on the wire when bothbinds are NULL — i.e. any run without
--since._runswallowed it, so the run reported a zero-queryworkload reading as "healthy cluster, no traffic". Found by executing the statement rather than
reading it.
elapsed_timeis microseconds, not milliseconds — a silent 1000× error in every cost figure andtherefore every
cost_share.ALTER DISTKEYandALTER DISTSTYLE ALLon one table: two conflicting hours-long rewrites where following both meansdoing one and undoing it.
DISTSTYLE ALLsubsumes a DISTKEY choice, so ADV102 is now withheld withits reason folded into the survivor.
unsortedandstats_off— same type, so nothing complained — inverts ADV104 and yields the wrong remediation atHIGH for every table. Now pinned by column name, not count.
Redshift's
ALTER TABLEstatements through a generic fallback — but testing that branch for the firsttime revealed it clobbered a proposal's existing note, destroying ADV105's Advisor attribution.
Honest limitations, all documented
SYSLOG ACCESS UNRESTRICTED,sys_query_historysilently shows only the connecting user'sown queries — the workload looks small rather than denied, and there is no error to notice.
--limittruncates executions, not query groups, becausesys_query_historyis per-executionwhere
pg_stat_statementsis pre-aggregated.and shrinking every
cost_share.svv_table_infoabsence cannot distinguish a Spectrum table (which can carry none of these) from anempty 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 Docker23 passedintegration against livepostgres:16main's 725 tests pass against this branch'ssrc/— no Postgres or dbt regressionruff check,ruff format --check,mypy src/sqlqualitycleanFollow-ups, not in this PR
_classify'sDROP INDEXbranch has the same terminal-invisibility shape as the one fixed here andis reachable on Postgres; counting it would change Postgres stderr, so it is left for a separate
branch.
ci.yml).🤖 Generated with Claude Code