mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-10 16:15:12 +00:00
fix(projections): overhaul the event projection system (#10560)
This PR overhauls our event projection system to make it more robust and prevent skipped events under high load. The core change replaces our custom, transaction-based locking with standard PostgreSQL advisory locks. We also introduce a worker pool to manage concurrency and prevent database connection exhaustion. ### Key Changes * **Advisory Locks for Projections:** Replaces exclusive row locks and inspection of `pg_stat_activity` with PostgreSQL advisory locks for managing projection state. This is a more reliable and standard approach to distributed locking. * **Simplified Await Logic:** Removes the complex logic for awaiting open transactions, simplifying it to a more straightforward time-based filtering of events. * **Projection Worker Pool:** Implements a worker pool to limit concurrent projection triggers, preventing connection exhaustion and improving stability under load. A new `MaxParallelTriggers` configuration option is introduced. ### Problem Solved Under high throughput, a race condition could cause projections to miss events from the eventstore. This led to inconsistent data in projection tables (e.g., a user grant might be missing). This PR fixes the underlying locking and concurrency issues to ensure all events are processed reliably. ### How it Works 1. **Event Writing:** When writing events, a *shared* advisory lock is taken. This signals that a write is in progress. 2. **Event Handling (Projections):** * A projection worker attempts to acquire an *exclusive* advisory lock for that specific projection. If the lock is already held, it means another worker is on the job, so the current one backs off. * Once the lock is acquired, the worker briefly acquires and releases the same *shared* lock used by event writers. This acts as a barrier, ensuring it waits for any in-flight writes to complete. * Finally, it processes all events that occurred before its transaction began. ### Additional Information * ZITADEL no longer modifies the `application_name` PostgreSQL variable during event writes. * The lock on the `current_states` table is now `FOR NO KEY UPDATE`. * Fixes https://github.com/zitadel/zitadel/issues/8509 --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Tim Möhlmann <[email protected]>
This commit is contained in:
co-authored by
Copilot
Tim Möhlmann
parent
bdefd9147f
commit
0575f67e94
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/migration"
|
||||
"github.com/zitadel/zitadel/internal/repository/instance"
|
||||
"github.com/zitadel/zitadel/internal/repository/pseudo"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type EventStore interface {
|
||||
@@ -405,6 +406,9 @@ type triggerConfig struct {
|
||||
|
||||
type TriggerOpt func(conf *triggerConfig)
|
||||
|
||||
// WithAwaitRunning instructs the projection to wait until previous triggers within the same container are finished
|
||||
// If multiple containers are involved, we do not await them to finish. If another container is currently projecting the trigger is skipped.
|
||||
// The reason is that we do not want to cause potential database connection exhaustion.
|
||||
func WithAwaitRunning() TriggerOpt {
|
||||
return func(conf *triggerConfig) {
|
||||
conf.awaitRunning = true
|
||||
@@ -423,6 +427,31 @@ func WithMinPosition(position decimal.Decimal) TriggerOpt {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
queue chan func()
|
||||
queueStart sync.Once
|
||||
)
|
||||
|
||||
func StartWorkerPool(count uint16) {
|
||||
queueStart.Do(func() {
|
||||
queue = make(chan func())
|
||||
|
||||
for range count {
|
||||
go worker()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func worker() {
|
||||
for {
|
||||
processEvents, ok := <-queue
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
processEvents()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Trigger(ctx context.Context, opts ...TriggerOpt) (_ context.Context, err error) {
|
||||
if slices.Contains(h.skipInstanceIDs, authz.GetInstance(ctx).InstanceID()) {
|
||||
return call.ResetTimestamp(ctx), nil
|
||||
@@ -439,7 +468,16 @@ func (h *Handler) Trigger(ctx context.Context, opts ...TriggerOpt) (_ context.Co
|
||||
defer cancel()
|
||||
|
||||
for i := 0; ; i++ {
|
||||
additionalIteration, err := h.processEvents(ctx, config)
|
||||
var (
|
||||
additionalIteration bool
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(1)
|
||||
queue <- func() {
|
||||
additionalIteration, err = h.processEvents(ctx, config)
|
||||
wg.Done()
|
||||
}
|
||||
wg.Wait()
|
||||
h.log().OnError(err).Info("process events failed")
|
||||
h.log().WithField("iteration", i).Debug("trigger iteration")
|
||||
if !additionalIteration || err != nil {
|
||||
@@ -526,11 +564,17 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add
|
||||
}
|
||||
}()
|
||||
|
||||
currentState, err := h.currentState(ctx, tx, config)
|
||||
var hasLocked bool
|
||||
err = tx.QueryRowContext(ctx, "SELECT pg_try_advisory_xact_lock(hashtext($1), hashtext($2))", h.ProjectionName(), authz.GetInstance(ctx).InstanceID()).Scan(&hasLocked)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !hasLocked {
|
||||
return false, zerrors.ThrowInternal(nil, "V2-lpiK0", "projection already locked")
|
||||
}
|
||||
|
||||
currentState, err := h.currentState(ctx, tx)
|
||||
if err != nil {
|
||||
if errors.Is(err, errJustUpdated) {
|
||||
return false, nil
|
||||
}
|
||||
return additionalIteration, err
|
||||
}
|
||||
// stop execution if currentState.position >= config.maxPosition
|
||||
|
||||
Reference in New Issue
Block a user