mirror of
https://github.com/zitadel/zitadel.git
synced 2026-11-03 12:47:09 -06:00
<!-- 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> <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> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
158 lines
5.2 KiB
Go
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)
|
|
}
|