mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved - A projection that subscribes to several event types gets slower the further it falls behind. - The handler reads its events with one query: `aggregate_type = ANY(...) AND event_type = ANY(...)`, ordered by position and limited to the bulk limit. - The `es_projection` index returns each (aggregate type, event type) range in position order, but Postgres cannot merge those ranges. - So each batch reads every matching event after the projection's position, sorts them all, and keeps only the bulk limit. - A projection that is N events behind therefore reads about N rows per batch. Catching up costs about N² in total, and once events arrive faster than such batches can process them, the projection does not recover. - Every query that triggers the projection waits for that catch-up. Examples are `GetSession` and the notification handlers. # How the Problems Are Solved - **New opt-in, `SearchQueryBuilder.ScanEventTypesSeparately()`.** It applies to event queries for a single instance, ascending and limited, whose sub-queries filter only by aggregate and event types. - For such a query, the SQL layer reads each distinct (aggregate type, event type) combination in its own `ORDER BY … LIMIT` subquery, using `UNNEST … CROSS JOIN LATERAL`. - It then merges the results with the same sort key and limit. - Each subquery is one `es_projection` range read in position order, so it stops at the limit. A batch reads at most `limit × combinations` rows, however far behind the projection is. - Every other query renders exactly as before. - **The projection handler** now passes its real combinations, one sub-query per aggregate type instead of the cross product of all types, and sets the opt-in. - **Results are identical to the single query.** - The sort key (`position, in_tx_order, instance_id, aggregate_type, aggregate_id, sequence`) is a total order, and the global top n is contained in the union of each combination's top n. - The cursor from #12703 and the open-transaction cap apply inside every subquery. - A test against a real Postgres compares both shapes from every cursor and every position, for several limits. The test events include positions shared by several transactions, whose `in_tx_order` restarts. # Additional Changes This is a separate commit and can be split into its own PR: - `cmd/defaults.yaml` sets `BulkLimit: 1000` for the `sessions` projection. - `projection_state_latency` gets histogram buckets up to one week. Until now, every projection more than 30 minutes behind was reported in the same top bucket. # Additional Context These numbers come from a local, CPU-pinned sandbox, for comparing builds only. - **Database:** Postgres 17 in a VM with 8 vCPUs and 16 GiB. The `Eventstore.Autovacuum` and `Projections` settings mirror a production-like setup. - **Starting state:** `sessions8` 4.8M events behind, restored byte-identically for every run. Batch query for `sessions8`, `EXPLAIN ANALYZE`, median of 5 runs, parallel workers at the default: | | before | after | | --- | ---: | ---: | | at the backlog, limit 1000 | 832 ms | 4.0 ms | | at the backlog, limit 200 | 832 ms | 1.0 ms | | at the head (idle poll) | 0.14 ms | 0.19 ms | | generic plan (`plan_cache_mode=force_generic_plan`), at the backlog | 1,042 ms | 4.0 ms | Draining `sessions8` from that backlog for 600 s, 3 runs per build (median, with the range in brackets): | | before | after | | --- | ---: | ---: | | events/s | 975 (932-1,032) | 7,811 (7,523-7,955) | | mean batch time, including writes | 1.03 s | 0.13 s | | still behind after 600 s | 4.2M | 17k-277k | A new index `(instance_id, position, in_tx_order, aggregate_type, event_type)` with the old query was evaluated as an alternative and rejected. Under a generic plan, Postgres chose it for projections whose events are rare and walked the whole event stream: 430 ms instead of 0.15 ms. This is the behaviour that led to dropping `es_instance_position` in #10626. - Follow-up for #12703 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]>
238 lines
6.0 KiB
Go
238 lines
6.0 KiB
Go
package sql
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"regexp"
|
|
"strconv"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/shopspring/decimal"
|
|
|
|
"github.com/zitadel/zitadel/internal/database"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/eventstore/repository"
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
|
)
|
|
|
|
// awaitOpenTransactions drains in-flight writers, then caps at projector TX start (now()),
|
|
// not clock_timestamp(). A wall-clock cap at SELECT time would include rows inserted after
|
|
// BEGIN and recreate position overtake.
|
|
var (
|
|
awaitOpenTransactionsV1 = ` AND created_at <= now()`
|
|
awaitOpenTransactionsV2 = ` AND "position" <= EXTRACT(EPOCH FROM now())`
|
|
)
|
|
|
|
func awaitOpenTransactions(useV1 bool) string {
|
|
if useV1 {
|
|
return awaitOpenTransactionsV1
|
|
}
|
|
return awaitOpenTransactionsV2
|
|
}
|
|
|
|
type Postgres struct {
|
|
*database.DB
|
|
}
|
|
|
|
func NewPostgres(client *database.DB) *Postgres {
|
|
return &Postgres{client}
|
|
}
|
|
|
|
func (db *Postgres) Health(ctx context.Context) error { return db.Ping() }
|
|
|
|
const (
|
|
eventSortKeySQL = `"position", in_tx_order, instance_id, aggregate_type, aggregate_id, "sequence"`
|
|
eventColumnsSQL = `created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order`
|
|
)
|
|
|
|
// FilterToReducer finds all events matching the given search query and passes them to the reduce function.
|
|
func (psql *Postgres) FilterToReducer(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder, reduce eventstore.Reducer) (err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
err = query(ctx, psql, searchQuery, reduce, false)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
pgErr := new(pgconn.PgError)
|
|
// check events2 not exists
|
|
if errors.As(err, &pgErr) && pgErr.Code == "42P01" {
|
|
return query(ctx, psql, searchQuery, reduce, true)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// LatestPosition returns the latest position found by the search query
|
|
func (db *Postgres) LatestPosition(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder) (decimal.Decimal, error) {
|
|
var position decimal.Decimal
|
|
err := query(ctx, db, searchQuery, &position, false)
|
|
return position, err
|
|
}
|
|
|
|
// InstanceIDs returns the instance ids found by the search query
|
|
func (db *Postgres) InstanceIDs(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder) ([]string, error) {
|
|
var ids []string
|
|
err := query(ctx, db, searchQuery, &ids, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (db *Postgres) Client() *database.DB {
|
|
return db.DB
|
|
}
|
|
|
|
func (db *Postgres) orderByEventSequence(desc, shouldOrderBySequence, useV1 bool) string {
|
|
if useV1 {
|
|
if desc {
|
|
return ` ORDER BY event_sequence DESC`
|
|
}
|
|
return ` ORDER BY event_sequence`
|
|
}
|
|
if shouldOrderBySequence {
|
|
if desc {
|
|
return ` ORDER BY "sequence" DESC`
|
|
}
|
|
return ` ORDER BY "sequence"`
|
|
}
|
|
|
|
if desc {
|
|
return ` ORDER BY (` + eventSortKeySQL + `) DESC`
|
|
}
|
|
return ` ORDER BY ` + eventSortKeySQL
|
|
}
|
|
|
|
func (db *Postgres) eventQuery(useV1 bool) string {
|
|
if useV1 {
|
|
return "SELECT" +
|
|
" creation_date" +
|
|
", event_type" +
|
|
", event_sequence" +
|
|
", event_data" +
|
|
", editor_user" +
|
|
", resource_owner" +
|
|
", instance_id" +
|
|
", aggregate_type" +
|
|
", aggregate_id" +
|
|
", aggregate_version" +
|
|
" FROM eventstore.events"
|
|
}
|
|
return "SELECT " + eventColumnsSQL + " FROM eventstore.events2"
|
|
}
|
|
|
|
func (db *Postgres) maxPositionQuery(useV1 bool) string {
|
|
if useV1 {
|
|
return `SELECT event_sequence FROM eventstore.events`
|
|
}
|
|
return `SELECT "position" FROM eventstore.events2`
|
|
}
|
|
|
|
func (db *Postgres) instanceIDsQuery(useV1 bool) string {
|
|
table := "eventstore.events2"
|
|
if useV1 {
|
|
table = "eventstore.events"
|
|
}
|
|
return "SELECT DISTINCT instance_id FROM " + table
|
|
}
|
|
|
|
func (db *Postgres) columnName(col repository.Field, useV1 bool) string {
|
|
switch col {
|
|
case repository.FieldAggregateID:
|
|
return "aggregate_id"
|
|
case repository.FieldAggregateType:
|
|
return "aggregate_type"
|
|
case repository.FieldSequence:
|
|
if useV1 {
|
|
return "event_sequence"
|
|
}
|
|
return `"sequence"`
|
|
case repository.FieldResourceOwner:
|
|
if useV1 {
|
|
return "resource_owner"
|
|
}
|
|
return `"owner"`
|
|
case repository.FieldInstanceID:
|
|
return "instance_id"
|
|
case repository.FieldEditorService:
|
|
if useV1 {
|
|
return "editor_service"
|
|
}
|
|
return ""
|
|
case repository.FieldEditorUser:
|
|
if useV1 {
|
|
return "editor_user"
|
|
}
|
|
return "creator"
|
|
case repository.FieldEventType:
|
|
return "event_type"
|
|
case repository.FieldEventData:
|
|
if useV1 {
|
|
return "event_data"
|
|
}
|
|
return "payload"
|
|
case repository.FieldCreationDate:
|
|
if useV1 {
|
|
return "creation_date"
|
|
}
|
|
return "created_at"
|
|
case repository.FieldPosition:
|
|
return `"position"`
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (db *Postgres) conditionFormat(operation repository.Operation) string {
|
|
switch operation {
|
|
case repository.OperationIn:
|
|
return "%s %s ANY(?)"
|
|
case repository.OperationNotIn:
|
|
return "%s %s ALL(?)"
|
|
case repository.OperationEquals, repository.OperationGreater, repository.OperationLess, repository.OperationJSONContains:
|
|
fallthrough
|
|
default:
|
|
return "%s %s ?"
|
|
}
|
|
}
|
|
|
|
func (db *Postgres) operation(operation repository.Operation) string {
|
|
switch operation {
|
|
case repository.OperationEquals, repository.OperationIn:
|
|
return "="
|
|
case repository.OperationGreater:
|
|
return ">"
|
|
case repository.OperationGreaterOrEquals:
|
|
return ">="
|
|
case repository.OperationLess:
|
|
return "<"
|
|
case repository.OperationJSONContains:
|
|
return "@>"
|
|
case repository.OperationNotIn:
|
|
return "<>"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var (
|
|
placeholder = regexp.MustCompile(`\?`)
|
|
)
|
|
|
|
// placeholder replaces all "?" with postgres placeholders ($<NUMBER>)
|
|
func (db *Postgres) placeholder(query string) string {
|
|
occurrences := placeholder.FindAllStringIndex(query, -1)
|
|
if len(occurrences) == 0 {
|
|
return query
|
|
}
|
|
replaced := query[:occurrences[0][0]]
|
|
|
|
for i, l := range occurrences {
|
|
nextIDX := len(query)
|
|
if i < len(occurrences)-1 {
|
|
nextIDX = occurrences[i+1][0]
|
|
}
|
|
replaced = replaced + "$" + strconv.Itoa(i+1) + query[l[1]:nextIDX]
|
|
}
|
|
return replaced
|
|
}
|