Files
zitadel/internal/eventstore/v3/unique_constraints.go
T
0a08e7269a fix(eventstore): omit owners on empty unique constraint inserts (#12785)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

After #12740, setup on existing deployments (v4.17.x → v4.18.0) fails
before migration 78 can add `eventstore.unique_constraints.owners`.
Starting any setup step pushes `system.migration.started`, which inserts
a unique constraint that names the `owners` column. On upgraded
databases that column does not exist yet, so setup dies on
`76_users14_login_equality_indexes` with `SQLSTATE 42703`.

# How the Problems Are Solved

Empty-owner unique-constraint inserts no longer name the `owners`
column:

- `len(Owners) == 0` → `INSERT (instance_id, unique_type, unique_field)`
- tagged `WithOwners(...)` → unchanged `INSERT (..., owners)`

Setup started/done events never stamp owners, so 76–78 can start.
Migration 78 still adds the column; after that, omitted `owners` uses
`DEFAULT '{}'`. Both v2 and v3 pushers apply the same rule. Mixed pushes
split into two INSERT batches so tagged and empty rows do not share a
column list.

# Additional Changes

- v2 SQL mocks now expect the three-column INSERT for untagged adds,
plus tagged and mixed cases
- Pusher tests cover mixed tagged+empty adds and adds against a table
that does not have `owners` yet
- The missing-column pusher test drops `owners` and pushes in one
transaction (`PushWithClient`), then rolls back so the shared test
schema is not mutated. Other tests may stall on `AccessExclusiveLock`
until rollback.

# Additional Context

- Closes #12780
- Follow-up for PR #12740
- Operators do not need to re-run init or apply DDL outside setup

Verified locally:

- `go test ./internal/eventstore ./internal/v2/eventstore/postgres
./internal/eventstore/v3` (including `-race` on those packages)
- Empty-owner insert succeeds when the `owners` column is missing;
tagged insert still requires the column

<!-- 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 <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-09-22 15:36:44 +00:00

126 lines
5.2 KiB
Go

package eventstore
import (
"context"
_ "embed"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgconn"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
//go:embed unique_constraints_delete.sql
deleteConstraintStmt string
//go:embed unique_constraints_delete_placeholders.sql
deleteConstraintPlaceholdersStmt string
//go:embed unique_constraints_add.sql
addConstraintStmt string
//go:embed unique_constraints_add_without_owners.sql
addConstraintWithoutOwnersStmt string
)
func handleUniqueConstraints(ctx context.Context, tx database.Transaction, commands []eventstore.Command) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
deletePlaceholders := make([]string, 0)
deleteArgs := make([]any, 0)
addWithOwnersPlaceholders := make([]string, 0)
addWithOwnersArgs := make([]any, 0)
addWithoutOwnersPlaceholders := make([]string, 0)
addWithoutOwnersArgs := make([]any, 0)
addConstraints := map[string]*eventstore.UniqueConstraint{}
deleteConstraints := map[string]*eventstore.UniqueConstraint{}
for _, command := range commands {
for _, constraint := range command.UniqueConstraints() {
instanceID := command.Aggregate().InstanceID
if constraint.IsGlobal {
instanceID = ""
}
switch constraint.Action {
case eventstore.UniqueConstraintAdd:
constraint.UniqueField = strings.ToLower(constraint.UniqueField)
addConstraints[fmt.Sprintf(uniqueConstraintPlaceholderFmt, instanceID, constraint.UniqueType, constraint.UniqueField)] = constraint
if len(constraint.Owners) == 0 {
addWithoutOwnersPlaceholders = append(addWithoutOwnersPlaceholders, fmt.Sprintf("($%d, $%d, $%d)", len(addWithoutOwnersArgs)+1, len(addWithoutOwnersArgs)+2, len(addWithoutOwnersArgs)+3))
addWithoutOwnersArgs = append(addWithoutOwnersArgs, instanceID, constraint.UniqueType, constraint.UniqueField)
} else {
addWithOwnersPlaceholders = append(addWithOwnersPlaceholders, fmt.Sprintf("($%d, $%d, $%d, $%d)", len(addWithOwnersArgs)+1, len(addWithOwnersArgs)+2, len(addWithOwnersArgs)+3, len(addWithOwnersArgs)+4))
addWithOwnersArgs = append(addWithOwnersArgs, instanceID, constraint.UniqueType, constraint.UniqueField, constraint.Owners)
}
case eventstore.UniqueConstraintRemove:
deletePlaceholders = append(deletePlaceholders, fmt.Sprintf(deleteConstraintPlaceholdersStmt, len(deleteArgs)+1, len(deleteArgs)+2, len(deleteArgs)+3))
deleteArgs = append(deleteArgs, instanceID, constraint.UniqueType, constraint.UniqueField)
deleteConstraints[fmt.Sprintf(uniqueConstraintPlaceholderFmt, instanceID, constraint.UniqueType, constraint.UniqueField)] = constraint
case eventstore.UniqueConstraintInstanceRemove:
deletePlaceholders = append(deletePlaceholders, fmt.Sprintf("(instance_id = $%d)", len(deleteArgs)+1))
deleteArgs = append(deleteArgs, instanceID)
deleteConstraints[fmt.Sprintf(uniqueConstraintPlaceholderFmt, instanceID, constraint.UniqueType, constraint.UniqueField)] = constraint
case eventstore.UniqueConstraintRemoveByOwner:
if len(constraint.Owners) == 0 {
continue
}
tag := constraint.Owners[0]
deletePlaceholders = append(deletePlaceholders, fmt.Sprintf("(instance_id = $%d AND owners @> ARRAY[$%d]::text[])", len(deleteArgs)+1, len(deleteArgs)+2))
deleteArgs = append(deleteArgs, instanceID, tag)
deleteConstraints[fmt.Sprintf("%s:%s", instanceID, tag)] = constraint
}
}
}
if len(deletePlaceholders) > 0 {
_, err := tx.Exec(ctx, fmt.Sprintf(deleteConstraintStmt, strings.Join(deletePlaceholders, " OR ")), deleteArgs...)
if err != nil {
logging.WithError(err).Warn("delete unique constraint failed")
errMessage := "Errors.Internal"
if constraint := constraintFromErr(err, deleteConstraints); constraint != nil {
errMessage = constraint.ErrorMessage
}
return zerrors.ThrowInternal(err, "V3-C8l3V", errMessage)
}
}
if err := execAddConstraints(ctx, tx, addConstraintWithoutOwnersStmt, addWithoutOwnersPlaceholders, addWithoutOwnersArgs, addConstraints); err != nil {
return err
}
return execAddConstraints(ctx, tx, addConstraintStmt, addWithOwnersPlaceholders, addWithOwnersArgs, addConstraints)
}
func execAddConstraints(ctx context.Context, tx database.Transaction, stmt string, placeholders []string, args []any, addConstraints map[string]*eventstore.UniqueConstraint) error {
if len(placeholders) == 0 {
return nil
}
_, err := tx.Exec(ctx, fmt.Sprintf(stmt, strings.Join(placeholders, ", ")), args...)
if err != nil {
logging.WithError(err).Warn("add unique constraint failed")
errMessage := "Errors.Internal"
if constraint := constraintFromErr(err, addConstraints); constraint != nil {
errMessage = constraint.ErrorMessage
}
return zerrors.ThrowAlreadyExists(err, "V3-DKcYh", errMessage)
}
return nil
}
func constraintFromErr(err error, constraints map[string]*eventstore.UniqueConstraint) *eventstore.UniqueConstraint {
pgErr := new(pgconn.PgError)
if !errors.As(err, &pgErr) {
return nil
}
for key, constraint := range constraints {
if strings.Contains(pgErr.Detail, key) {
return constraint
}
}
return nil
}