Enable depguard to keep exactly one MySQL driver linked - #1222
Conversation
Spirit links exactly one MySQL driver, github.com/block/mysql. Nothing about linking a second one fails to compile, and both of the ways it breaks are silent, so the invariant needs an enforcer rather than a convention. The registry half is the one that already bit a consumer. A tls= DSN value is a *name*, and a name only resolves inside the TLS registry of the driver *package* that registered it — registries are per-package globals and nothing about them travels in the DSN. pkg/dbconn registers "rds" into block/mysql and hands that name back in every enhanced DSN, including to consumers that reuse EnhanceDSNWithTLS. A pool dialing through any other MySQL driver cannot resolve the name and fails to open against an RDS host at all. That is host-shaped breakage: invisible to a suite that never points at an *.rds.amazonaws.com address, which is exactly why it went unnoticed in SchemaBot until the driver rename. The error half: two drivers define two field-identical but distinct *mysql.MySQLError types, and errors.AsType against one returns false for the other, so the classifiers in pkg/dbconn, pkg/checkpoint and pkg/throttler keep compiling and silently stop recognizing deadlocks, lock-wait timeouts and unknown-system-variable. The rule is a regression guard, not a cleanup: the tree has no upstream import today. Verified it is not inert by temporarily denying block/mysql instead, which flags 102 of the 103 importers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
depguard is an AST-level check, so it only sees imports written in this
repo's own files. A transitive dependency that imports upstream
go-sql-driver links a second MySQL driver just as effectively and
depguard says nothing — and that is the more likely way this regresses.
It is not hypothetical: squareup/gap reaches upstream today purely
transitively, via blip, ods-rds-connector and go-mysql/errors.
scripts/check-single-mysql-driver.sh asks the package graph instead, and
runs as a step in the lint workflow. `go list -deps -test` is the
authoritative question here; `go mod why` is not, because it reports the
shortest path, so a one-hop first-party import hides the real transitive
cause, and go.mod/go.sum can carry a module nothing links at all.
Most of the script is about not being able to pass silently, which is
the only failure mode that matters for a check like this:
- stderr is kept out of the package list and the exit status is
consulted on its own, because an unresolvable module makes go list
exit non-zero while still printing most of the graph
- a graph under 100 packages fails rather than reporting success
- block/mysql *missing* fails too, since that means the check is no
longer looking at a graph where the invariant means anything
Verified by construction rather than by reading. With a first-party
package importing only github.com/go-mysql/errors — whose own import of
upstream is invisible to depguard, confirmed at 0 issues — this script
fails and names go-mysql/errors as the importer. go.mod and go.sum are
byte-identical to HEAD afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This check went green in CI, and that was luck rather than correctness. The sibling strata change, whose script is the same shape over a larger graph, failed with "github.com/block/mysql is not in the package graph". `printf '%s\n' "$deps" | grep -qx PATTERN` inverts its result whenever the match lands early in a long list. grep -q exits on first match, printf then takes SIGPIPE writing the rest, and under `set -o pipefail` the pipeline's status is the failure, not grep's success. So a match reports as no-match. Spirit's 403-package list fits the pipe buffer, so printf finished first and nothing looked wrong. The false alarm on the block/mysql guard is the harmless half. The same pattern guarded the banned driver, where an early match would have read as "not present" — a silent pass, which is the one outcome this script exists to prevent. A check that is correct only while the dependency graph stays under some unstated size is not a check. Every check now greps a file, so there is no pipe and no SIGPIPE, with a comment saying not to reintroduce one. The importers listing keeps its pipeline: sort consumes all input, so nothing exits early. Reproduced the mechanism directly before and after — with the match on line 1 of a 500k-line list, the pipe form reports no-match and the file form reports a match. Re-verified both directions: clean graph passes at 403 packages, and a first-party package importing only go-mysql/errors fails and names it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Heads-up on a fix that came in via the sibling change (squareup/strata#425), where a reviewer bot raised a P2 that applies here too. The depguard rule alone does not enforce the invariant. It is an AST-level check, so it only sees imports written in this repo's own files. A transitive dependency that imports upstream go-sql-driver links a second driver just as effectively and depguard says nothing — and that is the more likely way this regresses. Not hypothetical: one internal consumer of this library reaches upstream today purely transitively, through three separate dependencies, with no first-party import anywhere. So this PR now also adds One thing worth reading the second commit for. This check went green in CI on the first try, and that was luck. Everything now greps a file. Verified both directions: clean graph passes at 406 packages, and a first-party package importing only |
|
🤖 Review findings - created by Kiran's code review agent - for spirit/pull/1222, 1c38177. Reviewed the full PR, with a focused pass on the delta Verdict: 8 findings — 2 blocking (a build-tag blind spot that both gates share, and private repo names on a public surface), 5 non-blocking, 1 suggestions bucket. The core premise is sound and I proved it: on a tree with a transitive banned import the script fails while Blocking1. The gate runs 2. The header names two private Block repositories, and the delta adds a third reference. Non-blocking3. 4. 5. The delta's justification is wrong for spirit, and its closing claim is wrong for the file. The SIGPIPE inversion is real — I reproduced 6. depguard is silently bypassed for generated files, and spirit has real ones in the linted module. golangci-lint v2 defaults 7. The load-bearing rationale misattributes the breakage, in two files at once. The header at 8. CI-only gate with no local entry point, and two AGENTS.md surfaces go stale on merge. General suggestionsBoth The one thing that could have broken, verifiedThe whole commit rests on depguard being AST-level and unable to see a transitive pull. I did not take it on faith: I built a local module importing the banned driver, wired it in with Verified correct
This review was generated by Claude Code (claude-opus-5). |
|
🤖 Adversarial correctness review — Right pair of instruments, and the second one is the part I would not have thought to ask for. depguard is AST-level, so it reasons only about first-party imports; measured on this branch: The other ~294 are exactly where a transitive second driver arrives, and the script is the only thing that asks about them. I verified both instruments bite rather than trusting their names — planting Clean run is ~0.9s, so this is free in the lint job. Choosing Three findings. One I'd fix before merge; the other two are low.
1 — the header publishes two private repo names (med)Line 28's parenthetical names a Block-internal monorepo and, inside it, one of its private service repos. I'm deliberately not repeating either name here, since this comment lands on a public repo too. Checked rather than assumed:
The other two names in that same parenthetical are fine — one is a published OSS project, the other a public module path — so this is about two of the four. The claim they support is the most valuable sentence in the header, because it turns "a transitive dep could do this" into "a transitive dep does do this, three hops deep, right now." Keep the claim; de-identify the evidence — "a sibling internal monorepo, through three separate transitive dependencies" carries the same weight. The same sentence appears in the sibling change in the internal repo, where it is fine because that repo is not public; the copy across siblings is what makes this easy to miss. 2 — the deny message's packages and its error codes describe different sets (low)The What the named packages actually classify, and what happens when
And where the omitted ones are:
Two things fall out. This matters more than a docs nit because the 3 — a build-tag-gated file evades both instruments (low)Neither instrument passes build tags: Both green. The same import in an untagged Low, because those are test files and nothing in Cheapest fix in keeping with the rest of the script: loop the graph query over the tags ( Verified — the TLS registry claim, the SIGPIPE reasoning, the guards, and the workflowThe registry half of the The Both negative guards are real, not decorative. The depguard covers test files here, so the two instruments overlap where it matters and diverge where it matters. No Workflow placement is right. The step runs in the existing One difference from the sibling worth recording. The header's Local checks. Script passes clean on the branch (403 packages, ~0.9s). This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving — the pair of instruments is right and I verified both bite. Findings are in my review comment; the medium one (two private repo names in a public artifact) is worth fixing before merge, the other two are low.
This stamp was left by Claude Code (claude-opus-5).
…d tags Three findings from the review on #1222. De-identify the transitive-linkage evidence (medium). This repo is public and the header named two private repos to make the "a transitive dep does this today" claim concrete. The claim is the most useful sentence in the header, so it stays; the evidence is now "one internal consumer ... through three separate transitive dependencies", which carries the same weight. The two public names in that parenthetical were fine, but they are not needed without the private one they qualified. SchemaBot elsewhere in the header is a public repo and stays. Correct the error codes in the deny message and header (low). They named pkg/throttler, whose only site decides a log line's wording, and omitted pkg/change, where the silence actually bites. They also claimed unknown-system-variable goes silent; pkg/{migration,move}/check gate on `!ok || Number != 1193`, so !ok takes the error branch and the configuration check refuses to run against MySQL < 8.0.20 — loud, not silent, and the opposite failure direction. Now: dbconn (1205/1213 -> retries stop, and pkg/change stops backing off through dbconn.IsLockContentionError), checkpoint (1146/1054 -> an unusable checkpoint reads as transient), and the config checks called out separately as the loud one. Note pkg/change is a *caller* of dbconn's classifier rather than a classifier itself, so it is described that way rather than added to the list. Cover build-tagged files (low). The script passed no -tags, so a driver behind a tag evaded it: the review's canary in a `singleversion` file reported "OK: 403 packages" and exit 0. Tags are now derived from the repo's own //go:build lines, so a new tag-gated suite is covered the day it lands. That canary now fails and names the .test variant. All constraints here are single bare tags, so enabling them together cannot exclude anything; the derivation hard-fails if a negated constraint ever appears, since that assumption would no longer hold. depguard stays tag-blind on purpose — run.build-tags surfaces 4 pre-existing modernize/noctx issues in those test files, and nothing in CI builds those tags anyway; the header says so rather than leaving it implied. Also fixed the one place that discarded its own errors: the Importers diagnostic swallowed go list's status, so a failure there printed an empty list under an already-correct FAIL. It reports the failure now, via a file rather than a pipe — `grep -v '^$'` exits 1 when every line is blank, which in a pipeline would have reported failure for a go list that worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 All three addressed in dc49c58. Each one held up when I checked it. 1 — private repo names in a public artifact (medium). Fixed, and it was in one more place than the review found. Confirmed the visibilities rather than assuming: The place the review missed: the same two names were in my own comment on this PR, which is public and was already published. I have edited it to match the header. GitHub keeps edit history, so the original is still reachable via the "edited" marker — flagging that rather than treating the edit as a clean removal. The claim itself stays, de-identified to "one internal consumer of this library links upstream today through three separate transitive dependencies". Your read that this is the header's most valuable sentence is why I kept it rather than cutting it. 2 — the deny message's packages and codes describe different sets (low). Correct, and I did not apply the suggested swap verbatim. Verified the rest before rewriting: 3 — tag-gated files evade both instruments (low). Reproduced your canary and fixed the script half. Rather than hardcoding a tag list, it derives tags from the repo's own Two things I checked before enabling every tag at once: no constraint in this repo is negated (all three are bare single tags), so combining them cannot exclude a file the default build would include — and the derivation now hard-fails if a negated constraint ever appears, since that is precisely when the assumption stops holding. I did not add Also fixed, from your note inside the Verified section: the |
* feat(move): add host-aware autoscaling for sharded moves * fix(move): gate queued copy writes and cap host concurrency * refactor(move): rely on per-shard pools and shared load feedback * refactor(move): preserve copier ownership of throttling * refactor: share applier workers and move connection budgeting * fix(move): align progress and isolate reverse worker counts Signed-off-by: Morgan Tocker <mtocker@squareup.com> * refactor: split connection budgets and progress into separate PRs Signed-off-by: Morgan Tocker <mtocker@squareup.com> * fix: use block/mysql in new host and move autoscale files The merge of main brought in the single-driver switch (#1219, #1222) but this branch's new files still imported go-sql-driver/mysql, which no longer has a go.sum entry and is denied by depguard. * review: address adversarial review findings on #1216 1. Pin workerPool's seal-must-not-retire invariant with a test that fails when seal() calls resizeLocked(0). TestWorkerPoolLifecycle cannot see that mutation because it cancels the context immediately after seal. 2. Route every r.throttler read through currentThrottler(). The five direct reads were safe by call-graph ordering, not by anything visible. 3. Split the Aurora probe failure from the plainly-not-Aurora case, so an ordinary MySQL target no longer warns with "error": nil and a genuinely broken probe is distinguishable in the logs. 4. Record the port-or-extract decision for datasync autoscaling in AGENTS.md's drift list (declined, with the reason). 5. Fix the stale Stats() comment describing a deleted counter. --------- Signed-off-by: Morgan Tocker <mtocker@squareup.com>
Spirit links exactly one MySQL driver,
github.com/block/mysql. This addsdepguardto make that an enforced invariant rather than a convention.The reason it needs an enforcer: nothing about linking a second MySQL driver fails to compile, and both of the ways it breaks are silent.
The registry half — this one already bit a consumer. A
tls=DSN value is a name, and a name only resolves inside the TLS registry of the driver package that registered it. Registries are per-package globals; nothing about them travels in the DSN.pkg/dbconnregistersrdsinto block/mysql (conn.go) and hands that name back in every enhanced DSN — including to downstream consumers that reuseEnhanceDSNWithTLS. A pool dialing through any other MySQL driver cannot resolve the name and fails to open against an RDS host at all.That is host-shaped breakage: invisible to a suite that never points at an
*.rds.amazonaws.comaddress, which is exactly why it sat unnoticed in SchemaBot's storage pool until the driver rename surfaced it (block/schemabot#1320).The error half. Two drivers define two field-identical but distinct
*mysql.MySQLErrortypes, anderrors.AsTypeagainst one returnsfalsefor the other — so the classifiers inpkg/dbconn,pkg/checkpointandpkg/throttlerkeep compiling and silently stop recognizing deadlocks, lock-wait timeouts and unknown-system-variable.Not a cleanup
The tree has no upstream import today. This is purely a regression guard.
Verification
golangci-lint run→ 0 issuesgo build ./...cleangithub.com/block/mysqlinstead flags 102 of the 103 importers (the defaultmax-same-issues=3cap hides the rest until you pass--max-same-issues=0, which is worth knowing if you go looking). Restored and diffed byte-identical afterwards.🤖 Generated with Claude Code