Files
zitadel/cmd/setup/79.go
T
25c23cf91d fix: setup step 79 batch unique constraint owner backfill (#12803)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->

- Setup 79 rewrites unique-constraint owners in one statement per file.
On a large table that statement holds row locks for the whole update,
and adding `LIMIT` does not help: the username statement bitmap-scans
every empty `owners` value and then index-scans every user in the
instance for each constraint.
- The backfill joined projection tables before those tables were created
and before event replay filled them.
- The page cursor is a `text[]`. `database.TextArray.Value` joined
elements with commas and never quoted them, so a project role key such
as `a,b` became two array elements. The next page repeated the same row
and the migration stopped.

- Each setup 79 statement pages its projection by primary key and
updates `eventstore.unique_constraints` with an equality lookup on
`(instance_id, unique_type, unique_field)`.
- The original field and `lower(...)` are separate `UNION` arms, so the
lookup stays a primary-key probe.
- Setup repeats a file until the projection page is empty. A page that
matches no constraint still advances the cursor.
- `BatchSize` is a `uint16`, configured like `ForceFinalize`
(`cmd/setup/steps.yaml`,
`ZITADEL_BACKFILLUNIQUECONSTRAINTOWNERS_BATCHSIZE`) and is not stored on
lastRun. The default in `steps.yaml` is 5000.
- Each page logs the file, the page index, and the number of rows
updated.
- The backfill runs after projection prefill. When `InitProjections` is
disabled or setup is for mirror, it still runs against the projection
rows already stored.
- `TextArray.Value` quotes every element and backslash-escapes `"` and
`\`. An empty array is still SQL `NULL`, so existing `ANY($1)` callers
stay unchanged. The setup statements already cast `$1` to `text[]`.

- Integration tests for the username and org-domain statements pass an
empty cursor and a page size.
- `TestTextArray_Value` expects quoted elements, and a round trip
through `Scan` covers a comma, a quote, and a backslash.
- initial fix https://github.com/zitadel/zitadel/pull/12740
- fix of the setup job, broken by the first fix
https://github.com/zitadel/zitadel/pull/12785
- needs to be a `fix` tag because `perf` wouldn't bump the version

- Based on `owner-app-fix`.
- A setup process already running the old statement keeps that plan
until it is restarted.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-31bfb5c4-5888-43f0-85cf-af1af097ca30&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
2026-09-23 16:35:17 +02:00

158 lines
5.2 KiB
Go

package setup
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5/pgconn"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query/projection"
)
var (
//go:embed 79/*.sql
backfillUniqueConstraintOwnersFS embed.FS
)
type BackfillUniqueConstraintOwners struct {
dbClient *database.DB
Version string `json:"version"`
Finalized bool `json:"finalized"`
ForceFinalize bool `json:"-"` // YAML/env only; not persisted on lastRun
BatchSize uint16 `json:"-"` // YAML/env only; not persisted on lastRun
lastVersion string `json:"-"`
lastFinalized bool `json:"-"`
}
func (mig *BackfillUniqueConstraintOwners) Check(lastRun map[string]interface{}) bool {
if lastRun == nil {
lastRun = map[string]interface{}{}
}
mig.lastVersion, _ = lastRun["version"].(string)
mig.lastFinalized, _ = lastRun["finalized"].(bool)
versionChanged := mig.lastVersion != mig.Version
if mig.lastVersion == "" {
return true
}
return versionChanged || (mig.ForceFinalize && !mig.lastFinalized)
}
func (mig *BackfillUniqueConstraintOwners) finalizeAfterSuccess() {
mig.Finalized = mig.lastFinalized || mig.ForceFinalize || (mig.lastVersion != "" && mig.lastVersion != mig.Version)
}
func (mig *BackfillUniqueConstraintOwners) Execute(ctx context.Context, _ eventstore.Event) error {
statements, err := backfillUniqueConstraintOwnersStatements()
if err != nil {
return err
}
for _, stmt := range statements {
var cursor database.TextArray[string]
for page := 1; ; page++ {
var next database.TextArray[string]
var updated int64
err := mig.dbClient.QueryRowContext(ctx, func(row *sql.Row) error {
return row.Scan(&next, &updated)
}, stmt.query, cursor, mig.BatchSize)
if err != nil {
if isUndefinedTable(err) {
logging.Info(ctx, "skip unique constraint owners backfill, relation missing", "file", stmt.file, "migration", mig.String())
break
}
return fmt.Errorf("backfill unique constraint owners %s: %w", stmt.file, err)
}
if len(next) == 0 {
break
}
if slices.Equal(cursor, next) {
return fmt.Errorf("backfill unique constraint owners %s: cursor did not advance", stmt.file)
}
logging.Info(ctx, "backfill unique constraint owners page", "file", stmt.file, "page", page, "updated", updated, "migration", mig.String())
cursor = next
}
}
var unmatched int64
err = mig.dbClient.QueryRowContext(ctx, func(row *sql.Row) error {
return row.Scan(&unmatched)
}, `SELECT COUNT(*) FROM eventstore.unique_constraints WHERE unique_type = ANY($1) AND owners = '{}'`,
database.TextArray[string](eventstore.UniqueTypesWithOwners))
if err != nil {
return err
}
mig.finalizeAfterSuccess()
logging.Info(ctx, "unique constraint owners backfill complete", "unmatched", unmatched, "finalized", mig.Finalized, "migration", mig.String())
return nil
}
func (mig *BackfillUniqueConstraintOwners) String() string {
return eventstore.UniqueConstraintOwnersBackfillStep
}
func isUndefinedTable(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "42P01"
}
func interpolateBackfillSQL(query string) string {
return strings.NewReplacer(
"{{.users}}", projection.UserTable,
"{{.domain_policies}}", projection.DomainPolicyTable,
"{{.idp_user_links}}", projection.IDPUserLinkTable,
"{{.orgs}}", projection.OrgProjectionTable,
"{{.org_domains}}", projection.OrgDomainTable,
"{{.projects}}", projection.ProjectProjectionTable,
"{{.apps}}", projection.AppProjectionTable,
"{{.apps_saml}}", projection.AppSAMLTable,
"{{.project_roles}}", projection.ProjectRoleProjectionTable,
"{{.project_grants}}", projection.ProjectGrantProjectionTable,
"{{.project_grant_members}}", projection.ProjectGrantMemberProjectionTable,
"{{.user_grants}}", projection.UserGrantProjectionTable,
"{{.org_members}}", projection.OrgMemberProjectionTable,
"{{.project_members}}", projection.ProjectMemberProjectionTable,
"{{.instance_members}}", projection.InstanceMemberProjectionTable,
"{{.actions}}", projection.ActionTable,
"{{.idps}}", projection.IDPTable,
"{{.idp_templates}}", projection.IDPTemplateTable,
"{{.message_texts}}", projection.MessageTextTable,
).Replace(query)
}
func backfillUniqueConstraintOwnersStatements() ([]statement, error) {
statements, err := readStatements(backfillUniqueConstraintOwnersFS, "79")
if err != nil {
return nil, err
}
for i := range statements {
statements[i].query = interpolateBackfillSQL(statements[i].query)
}
return statements, nil
}
// BackfillUniqueConstraintOwnersQuery returns the interpolated SQL for a setup 79 file.
// The statement reads one projection page: $1 is a text[] primary-key cursor (nil starts at the beginning) and $2 is the page size.
// It returns the next cursor and the number of unique-constraint rows updated.
func BackfillUniqueConstraintOwnersQuery(file string) (string, error) {
statements, err := backfillUniqueConstraintOwnersStatements()
if err != nil {
return "", err
}
for _, stmt := range statements {
if stmt.file == file {
return stmt.query, nil
}
}
return "", errors.New("unknown backfill statement: " + file)
}