fix(projections): added check to make sure there cannot be 2 projections for the same table (#10439)

# Which Problems Are Solved

It should not be possible to start 2 projections with the same name.

If this happens, it can cause issues with the event store such as events
being skipped/unprocessed and can be very hard/time-consuming to
diagnose.

# How the Problems Are Solved

A check was added to make sure no 2 projections have the same table


Closes https://github.com/zitadel/zitadel/issues/10453

---------

Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
This commit is contained in:
kkrime
2025-08-13 15:51:30 +02:00
committed by GitHub
parent 93c030d8fb
commit 10bd747105
4 changed files with 212 additions and 2 deletions

View File

@@ -209,10 +209,18 @@ func Init(ctx context.Context) error {
return nil
}
func Start(ctx context.Context) {
func Start(ctx context.Context) error {
projectionTableMap := make(map[string]bool, len(projections))
for _, projection := range projections {
table := projection.String()
if projectionTableMap[table] {
return fmt.Errorf("projeciton for %s already added", table)
}
projectionTableMap[table] = true
projection.Start(ctx)
}
return nil
}
func ProjectInstance(ctx context.Context) error {

View File

@@ -0,0 +1,130 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: projection.go
//
// Generated by this command:
//
// mockgen -source projection.go -destination ./projection_mock.go -package projection
//
// Package projection is a generated GoMock package.
package projection
import (
context "context"
reflect "reflect"
eventstore "github.com/zitadel/zitadel/internal/eventstore"
handler "github.com/zitadel/zitadel/internal/eventstore/handler/v2"
gomock "go.uber.org/mock/gomock"
)
// Mockprojection is a mock of projection interface.
type Mockprojection struct {
ctrl *gomock.Controller
recorder *MockprojectionMockRecorder
}
// MockprojectionMockRecorder is the mock recorder for Mockprojection.
type MockprojectionMockRecorder struct {
mock *Mockprojection
}
// NewMockprojection creates a new mock instance.
func NewMockprojection(ctrl *gomock.Controller) *Mockprojection {
mock := &Mockprojection{ctrl: ctrl}
mock.recorder = &MockprojectionMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *Mockprojection) EXPECT() *MockprojectionMockRecorder {
return m.recorder
}
// Execute mocks base method.
func (m *Mockprojection) Execute(ctx context.Context, startedEvent eventstore.Event) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Execute", ctx, startedEvent)
ret0, _ := ret[0].(error)
return ret0
}
// Execute indicates an expected call of Execute.
func (mr *MockprojectionMockRecorder) Execute(ctx, startedEvent any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*Mockprojection)(nil).Execute), ctx, startedEvent)
}
// Init mocks base method.
func (m *Mockprojection) Init(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Init", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// Init indicates an expected call of Init.
func (mr *MockprojectionMockRecorder) Init(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*Mockprojection)(nil).Init), ctx)
}
// ProjectionName mocks base method.
func (m *Mockprojection) ProjectionName() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ProjectionName")
ret0, _ := ret[0].(string)
return ret0
}
// ProjectionName indicates an expected call of ProjectionName.
func (mr *MockprojectionMockRecorder) ProjectionName() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProjectionName", reflect.TypeOf((*Mockprojection)(nil).ProjectionName))
}
// Start mocks base method.
func (m *Mockprojection) Start(ctx context.Context) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Start", ctx)
}
// Start indicates an expected call of Start.
func (mr *MockprojectionMockRecorder) Start(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*Mockprojection)(nil).Start), ctx)
}
// String mocks base method.
func (m *Mockprojection) String() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "String")
ret0, _ := ret[0].(string)
return ret0
}
// String indicates an expected call of String.
func (mr *MockprojectionMockRecorder) String() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*Mockprojection)(nil).String))
}
// Trigger mocks base method.
func (m *Mockprojection) Trigger(ctx context.Context, opts ...handler.TriggerOpt) (context.Context, error) {
m.ctrl.T.Helper()
varargs := []any{ctx}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Trigger", varargs...)
ret0, _ := ret[0].(context.Context)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Trigger indicates an expected call of Trigger.
func (mr *MockprojectionMockRecorder) Trigger(ctx any, opts ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trigger", reflect.TypeOf((*Mockprojection)(nil).Trigger), varargs...)
}

View File

@@ -0,0 +1,69 @@
package projection
import (
"fmt"
"testing"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/require"
gomock "go.uber.org/mock/gomock"
)
func TestStart(t *testing.T) {
duplicateName := gofakeit.Name()
tests := []struct {
name string
projections func(t *testing.T) []projection
err error
}{
{
name: "happy path",
projections: func(t *testing.T) []projection {
ctrl := gomock.NewController(t)
projections := make([]projection, 5)
for i := range 5 {
mock := NewMockprojection(ctrl)
mock.EXPECT().Start(gomock.Any())
mock.EXPECT().String().Return(gofakeit.Name())
projections[i] = mock
}
return projections
},
},
{
name: "same projection used twice error",
projections: func(t *testing.T) []projection {
projections := make([]projection, 5)
ctrl := gomock.NewController(t)
mock := NewMockprojection(ctrl)
mock.EXPECT().String().Return(duplicateName)
mock.EXPECT().Start(gomock.Any())
projections[0] = mock
for i := 1; i < 4; i++ {
mock := NewMockprojection(ctrl)
mock.EXPECT().String().Return(gofakeit.Name())
mock.EXPECT().Start(gomock.Any())
projections[i] = mock
}
mock = NewMockprojection(ctrl)
mock.EXPECT().String().Return(duplicateName)
projections[4] = mock
return projections
},
err: fmt.Errorf("projeciton for %s already added", duplicateName),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
projections = tt.projections(t)
err := Start(t.Context())
require.Equal(t, tt.err, err)
})
}
}

View File

@@ -90,7 +90,10 @@ func StartQueries(
return nil, err
}
if startProjections {
projection.Start(ctx)
err = projection.Start(ctx)
if err != nil {
return nil, err
}
}
repo.caches, err = startCaches(