fix: remove excess calls to prepareSQLFilter for workspace and template endpoints - #27248
Conversation
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 2 | Last posted: Round 2, 11 findings (1 P2, 1 P3, 5 Nit, 4 Note), COMMENT. Review Finding inventoryFinding inventory, PR #27248Findings
Round logRound 1Netero first pass (Nit + Note, P3-and-below) then 18-reviewer panel. Reviewed against f84801e..e010e3d. Round 2 updateChurn guard: PROCEED (5 addressed, 2 silent Notes, 0 contested). Author reworked against f84801e..f399563. Round 2 (panel)16-reviewer panel + Netero. Reviewed against f84801e..f399563. CRF-1 verified fixed (revert checks). CRF-5 fixed. CRF-6 closed (layering acceptable). CRF-3 dropped (codebase convention). CRF-2, CRF-4 re-raised on new file. CRF-7 strengthened, needs human decision. New: CRF-8 (P3 comment narration), CRF-9 (Nit commit subject), CRF-10 (Note latent flake), CRF-11 (Nit missing interface assertion), CRF-12 (Note provisioner vs dbfake). No P0/P1. Event COMMENT. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Round 1 review. The production change is small, correct, and well-scoped: it removes a genuinely redundant handler-level AuthorizeSQLFilter prepare on the workspaces and templates list paths. The panel verified the safety independently across many reviewers: api.Database is always the dbauthz-wrapped store (coderd.go:403), and GetAuthorizedWorkspaces/GetAuthorizedTemplates (dbauthz.go:9188, 9159) discard their PreparedAuthorized argument and re-derive the SQL filter from the context actor via prepareSQLFilter. So the removed prepare was dead work built and thrown away every request; authorization scoping is unchanged and there is no security regression. The bug class is fully addressed for the list paths, and the one remaining AuthorizeSQLFilter caller (workspaceagents.go:2273, ActionSSH) actually consumes its result and was correctly left alone. The comments added at the call sites are model why-not-what notes. The perf win is real: authCache.Prepare (authz.go:805) is a documented pass-through with no caching, so dropping one of two full OPA prepares halves per-request prepare cost.
One P2, four Nits, two Notes. No P0/P1, so this is a COMMENT, not a change request.
The P2 is the benchmark, and it is worth taking seriously despite being test-only. The committed BenchmarkDBAuthzGetWorkspaces calls dbauthz.GetWorkspaces directly, a method this PR does not touch, so it performs exactly one prepare on both base and HEAD. Three reviewers ran it on both commits and got identical numbers matching the PR body's "after" column, so the committed artifact cannot reproduce the before/after table it is presented as proving, and it will stay green if the exact regression this PR fixes is reintroduced. The benchmark is a legitimate characterization of single-prepare OPA cost scaling with org count (useful for #21890); the ask is to stop presenting it as before/after proof, or to make it guard the handler path.
As Hisoka put it: "I came to fight this diff. It didn't fight back." The change is sound; the evidence artifact just measures a layer below the change.
Process note: thank you for disclosing that the benchmark is AI-written and for scoping the PR as a partial fix for #21890. That honesty is the right call and is exactly why the benchmark's aim is worth correcting rather than trusting.
coderd/database/dbauthz/dbauthz.go:9188
Note [CRF-7] The GetAuthorized* wrappers accept a PreparedAuthorized they silently discard, which is the trap that produced this redundancy (Ryosuke, Meruem).
GetAuthorizedWorkspaces(ctx, arg, _ rbac.PreparedAuthorized) (and GetAuthorizedTemplates at :9159) invite callers to compute and pass a prepared filter that is thrown away and re-derived internally. Both already carry // TODO Delete this function. This PR removes the two live misuses, but until those wrappers are deleted and the interface entries removed, a future caller can reintroduce the exact double-prepare and the type system will not object. Out of this PR's scope, but that is where the class actually lives.
🤖
🤖 This review was automatically generated with Coder Agents.
The GET /api/v2/workspaces handler called AuthorizeSQLFilter to build a prepared ResourceWorkspace authorizer and passed it to Database.GetAuthorizedWorkspaces. The dbauthz wrapper ignores that argument and re-prepares its own SQL filter inside GetWorkspaces, so the handler-level prepare was dead work: every request ran OPA partial evaluation twice. Partial evaluation cost scales with the number of organization-scoped roles the subject carries, so for users in many organizations this doubled an already expensive operation (see #21890). Call GetWorkspaces directly, which authorizes the query itself with a single prepare.
Same redundant double OPA partial evaluation as the workspaces list: the GET /api/v2/templates handler called AuthorizeSQLFilter to build a prepared ResourceTemplate authorizer, but the dbauthz GetAuthorizedTemplates wrapper ignores it and re-prepares its own SQL filter inside GetTemplatesWithFilter. Call GetTemplatesWithFilter directly, which authorizes the query itself with a single prepare. Like the workspaces change, this halves the OPA partial-evaluation cost of the templates list for users in many organizations (see #21890).
e010e3d to
f399563
Compare
|
/coder-agents-review |
There was a problem hiding this comment.
Round 2. The R1 P2 is fixed, and fixed well. The author replaced the misaimed benchmark with a handler-level BenchmarkWorkspacesHandler that runs the real api.workspaces, and added two regression tests (TestWorkspacesListSingleAuthorizePrepare, TestTemplatesListSingleAuthorizePrepare) plus a PrepareCountingAuthorizer helper that asserts exactly one OPA prepare per list request. The panel verified these are genuine guards, not assumptions: reverting the handler to base makes the tests fail with PrepareCount==2 (Netero on workspaces, Bisky on templates), and the assertion also fails at 0, so it pins exactly-once in both directions. Determinism was checked under -count=20 -race (Komugi) and -count=5 (Hisoka, Mafuuu), all green. The PrepareCountingAuthorizer does not duplicate RecordingAuthorizer (that records Authorize; this records Prepare, which the caching authorizer does not dedupe). Strong response to the round 1 feedback.
As Hisoka put it: "I came to fight this change and it fought back. Round 2, and the opponent got stronger."
No P0/P1 this round, so this stays a COMMENT. New findings are one P3 and a few Nits/Notes, all minor.
Dispositions on prior findings: CRF-1 verified fixed (resolved). CRF-5 fixed (old comments deleted with the file). CRF-6 (prepare-failure error text) is closed: Leorio, Chopper, Mafuuu, and Pariston all confirmed the Detail field preserves the underlying OPA cause, so the coarser top-line Message is acceptable, not a lost diagnosis. CRF-3 (for b.Loop()) is dropped: both Netero and ging-go note the codebase overwhelmingly uses the b.ResetTimer()+b.N form (17 files vs 1) and it is not lint-enforced, so the current code matches the prevailing convention.
CRF-7 needs a human decision, not another review round. This PR removed the last production callers of the dbauthz GetAuthorizedWorkspaces/GetAuthorizedTemplates wrappers, so those methods (which accept a PreparedAuthorized they discard) are now dead outside tests and mocks, and their // TODO Delete this function is unblocked. dbauthz.go is out of this PR's scope, so either file a follow-up ticket to delete the wrappers and their interface entries, or explicitly accept keeping the misleading signature. A silent deferral is a drop.
Process notes: the ground-up benchmark rewrite reintroduced the three R1 loop/idiom nits verbatim (CRF-2, CRF-4; CRF-3 dropped per above), so prior-round feedback did not fold into the rewrite. Separately, the test commit subject test(coderd): guard workspaces/templates list against double authorize prepare is 78 characters, past the 72-char limit, so git log --oneline clips "prepare"; something like test(coderd): guard list handlers against double authorize prepare (66) keeps it intact.
🤖 This review was automatically generated with Coder Agents.
| // to a growing number of organizations. | ||
| // | ||
| // This benchmark exercises the handler body itself, not dbauthz.GetWorkspaces | ||
| // directly, because the double-prepare fix changed only the handler: the |
There was a problem hiding this comment.
P3 [CRF-8] Benchmark and setup comments describe the PR's fix/commit workflow instead of the code's behavior (Gon P2).
The doc paragraph explains the benchmark via "the double-prepare fix changed only the handler", "The fix calls Database.GetWorkspaces directly", and "a benchstat comparison across the fix commit" / "on both revisions"; the HTTPAuth comment (:159) reads "used by the pre-fix handler path ... on both the pre-fix and post-fix revisions"; and the captureAuthedContext comment (:168) references "AuthorizeSQLFilter in the pre-fix handler". After merge there is no "fix commit" or "pre-fix handler" in the tree, so these dangle. AGENTS.md asks comments to "describe the behaviour of the code, not the reasoning the agent used to produce the change."
Orchestrator judgment: P3, not P2. This is comment quality in a test file with no behavioral impact, and Meruem confirmed the HTTPAuth field itself is legitimately kept so the benchmark can be checked out and run against the base revision for benchstat. The ask is narrow: keep the field, but phrase the rationale in durable present tense (the per-request Prepare cost lives in the handler, so the benchmark drives the handler) rather than in terms of a fix/commit comparison that vanishes on merge.
🤖
| orgCounts := []int{1, 10, 50, 100, 200} | ||
|
|
||
| for _, n := range orgCounts { | ||
| n := n |
There was a problem hiding this comment.
Nit [CRF-2] Redundant n := n loop-variable copy, dead since Go 1.22 (ging-go, Netero, and re-verified by Bisky, Hisoka, Mafu-san, Razor, Meruem).
This is CRF-2 from round 1 recurring verbatim at a new location after the rewrite. go.mod declares go 1.26.4, so each for _, n := range orgCounts iteration already binds a fresh n and the b.Run closure captures it correctly. Delete the line.
🤖
| // real request produces (fully expanded roles, cached AST value). | ||
| roleNames := make([]string, 0, n+1) | ||
| roleNames = append(roleNames, rbac.RoleMember().String()) | ||
| for i := 0; i < n; i++ { |
There was a problem hiding this comment.
Nit [CRF-4] for i := 0; i < n; i++ with an unused index; use for range n (ging-go, Netero).
CRF-4 from round 1, recurring at a new location. The body uses uuid.New() and never reads i, so for range n (the range-over-int form the rest of the codebase uses) says exactly what the loop does.
🤖
| // the counts reflect every partial evaluation performed. Counts are keyed by | ||
| // subject ID so a test can isolate the prepares made on behalf of a specific | ||
| // user and ignore background work performed under system subjects. | ||
| type PrepareCountingAuthorizer struct { |
There was a problem hiding this comment.
Nit [CRF-11] PrepareCountingAuthorizer is the only authorizer double in this file without a compile-time interface assertion (Robin).
Its siblings carry one: var _ rbac.Authorizer = (*RecordingAuthorizer)(nil) (:154) and var _ rbac.Authorizer = (*FakeAuthorizer)(nil) (:374). The new type satisfies the interface only through the embedded rbac.Authorizer, so a method-set mistake would compile silently; the assertion documents intent and matches the local convention. Add var _ rbac.Authorizer = (*PrepareCountingAuthorizer)(nil).
🤖
| authz.Reset() | ||
| res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) | ||
| require.NoError(t, err) | ||
| require.Len(t, res.Workspaces, 1) |
There was a problem hiding this comment.
Note [CRF-10] The exact PrepareCount == 1 assertion holds only while no owner-subject work runs concurrently with the measured request (Komugi).
This is guaranteed today by three facts outside the test: background reconcilers use system subjects, the default coderdtest autobuild ticker never fires, and setup owner requests are drained before Reset(). Verified stable under -count=20 -race. Worth knowing (not changing now): if a future change adds periodic owner-scoped work, or a test in this package enables a firing autobuild ticker that triggers an owner-context prepare, this exact-equality assertion and its twin at templates_test.go:71 will flake at count 2. The tight assertion is the right guard; this is a latent constraint to keep in mind.
🤖
Add a PrepareCountingAuthorizer to coderdtest that counts Authorizer.Prepare calls per (subject, action, objectType), and use it in TestWorkspacesListSingleAuthorizePrepare and TestTemplatesListSingleAuthorizePrepare to assert each list request runs OPA partial evaluation for its resource exactly once. These fail if the redundant handler-level prepare is reintroduced. Add BenchmarkWorkspacesHandler, which drives the real GET /workspaces handler over a mocked database for a user in a growing number of organizations, so the whole authorization path a request takes is measured and its cost scaling with org count is visible (see #21890).
f399563 to
d7e9248
Compare
Emyrk
left a comment
There was a problem hiding this comment.
overall LG.
Can we reuse the RecordingAuthorizer and extend it rather than make a new testing tool though?
| templates, err := api.Database.GetAuthorizedTemplates(ctx, args, prepared) | ||
| // GetTemplatesWithFilter authorizes the query itself, so we don't | ||
| // prepare a SQL filter here. | ||
| templates, err := api.Database.GetTemplatesWithFilter(ctx, args) |
| // Partial evaluation cost is driven by the subject, not the object type: OPA | ||
| // expands the policy against the subject's N org-scoped roles, so it scales | ||
| // with org count (see #21890). | ||
| func BenchmarkWorkspacesHandler(b *testing.B) { |
There was a problem hiding this comment.
Your call if we need to keep this benchmark
There was a problem hiding this comment.
removed, it's easy enough to come back and use this PR as a reference if we end up needing a benchmark here again in the future 👍
Replace the standalone PrepareCountingAuthorizer with prepare counting on the existing RecordingAuthorizer. Prepare calls are recorded into a separate Prepared slice (kept out of Called so existing Authorize-call assertions are unaffected), and PrepareCount/Reset expose and clear them. The single-prepare guard tests inject a plain RecordingAuthorizer instead of a bespoke type.
BenchmarkWorkspacesHandler served its purpose: it quantified the double to single OPA prepare reduction on the list handler while that change was under review. The prepare-count guard tests (TestWorkspacesListSingleAuthorizePrepare, TestTemplatesListSingleAuthorizePrepare) provide the lasting regression protection, so the benchmark and its capture-once helper are no longer needed. This commit can be referenced if the handler-over-mock benchmark is ever needed again.
This PR removes redundant (could be considered duplicate)
prepareSQLFiltercalls on the workspaces and templates paths. This is meant to partially address #21890.The
prepareSQLFilteris particularly expensive for users who are a member of many (in the order of 100) organizations. In those cases, the relevant paths (OPA and others) account for more than 50% of the total CPU time. A large part of this appears to be second hand as a result of allocations on those paths.Note: the benchmark is AI written, and as usual it hallucinated some things in the first few rounds of implementing it.
Benchmark results: