Files
zitadel/backend/v3/domain/invoke.go
T
SilvanandGitHub 22ef817d5c fix(eventstore): Make Eventstore Compatible with Relational Table Package (#10687)
Improves compatibility of eventstore and related database components
with the new relational table package.

## Which problems are solved

1. **Incompatible Database Interfaces**: The existing eventstore was
tightly coupled to the database package, which is incompatible with the
new, more abstract relational table package in v3. This prevented the
new command-side logic from pushing events to the legacy eventstore.
2. **Missing Health Checks**: The database interfaces in the new package
lacked a Ping method, making it impossible to perform health checks on
database connections.
3. **Event Publishing Logic**: The command handling logic in domain
needed a way to collect and push events to the legacy eventstore after a
command was successfully executed.

## How the problems are solved

1. **`LegacyEventstore` Interface**:
* A new `LegacyEventstore` interface is introduced in the new
`database/eventstore` . This interface exposes a `PushWithNewClient`
method that accepts the new `database.QueryExecutor` interface,
decoupling the v3 domain from the legacy implementation.
* The `internal/eventstore.Eventstore` now implements this interface. A
wrapper, PushWithClient, is added to convert the old database client
types (`*sql.DB`, `*sql.Tx`) into the new `QueryExecutor` types before
calling `PushWithNewClient`.
2. **Database Interface Updates**:
* The `database.Pool` and `database.Client` interfaces in
`storage/eventstore` have been updated to include a Ping method,
allowing for consistent health checks across different database
dialects.
* The `postgres` and `sql` dialect implementations have been updated to
support this new method.
3. **Command and Invoker Refactoring**:
* The `Commander` interface in domain now includes an `Events()
[]legacy_es.Command` method. This allows commands to declare which
events they will generate.
* The `eventCollector` in the invoker logic has been redesigned. It now
ensures a database transaction is started before executing a command.
After successful execution, it calls the `Events()` method on the
command to collect the generated events and appends them to a list.
* The `eventStoreInvoker` then pushes all collected events to the legacy
eventstore using the new `LegacyEventstore` interface, ensuring that
events are only pushed if the entire command (and any sub-commands)
executes successfully within the transaction.
4. **Testing**:
* New unit tests have been added for the invoker to verify that events
are correctly collected from single commands, batched commands, and
nested commands.

These changes create a clean bridge between the new v3 command-side
logic and the existing v1 eventstore, allowing for incremental adoption
of the new architecture while maintaining full functionality.

## Additional Information

closes https://github.com/zitadel/zitadel/issues/10442
2025-09-16 18:58:49 +02:00

160 lines
4.4 KiB
Go

package domain
import (
"context"
"fmt"
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
legacy_es "github.com/zitadel/zitadel/internal/eventstore"
)
// Invoke provides a way to execute commands within the domain package.
// It uses a chain of responsibility pattern to handle the command execution.
// The default chain includes logging, tracing, and event publishing.
// If you want to invoke multiple commands in a single transaction, you can use the [commandBatch].
func Invoke(ctx context.Context, cmd Commander) error {
invoker := newEventStoreInvoker(newLoggingInvoker(newTraceInvoker(nil)))
opts := &CommandOpts{
Invoker: invoker.collector,
DB: pool,
}
return invoker.Invoke(ctx, cmd, opts)
}
// eventStoreInvoker checks if the [Commander].Events function returns any events.
// If it does, it collects the events and publishes them to the event store.
type eventStoreInvoker struct {
collector *eventCollector
}
func newEventStoreInvoker(next Invoker) *eventStoreInvoker {
return &eventStoreInvoker{collector: &eventCollector{next: next}}
}
func (i *eventStoreInvoker) Invoke(ctx context.Context, command Commander, opts *CommandOpts) (err error) {
err = i.collector.Invoke(ctx, command, opts)
if err != nil {
return err
}
if len(i.collector.events) > 0 {
err = eventstore.Publish(ctx, legacyEventstore, opts.DB, i.collector.events...)
if err != nil {
return err
}
}
return nil
}
// eventCollector collects events from all commands. The [eventStoreInvoker] pushes the collected events after all commands are executed.
// The events are collected after the command got executed, the collector ensures that the command is executed in the same transaction as writing the events.
type eventCollector struct {
next Invoker
events []legacy_es.Command
}
func (i *eventCollector) Invoke(ctx context.Context, command Commander, opts *CommandOpts) (err error) {
close, err := opts.EnsureTx(ctx)
if err != nil {
return err
}
defer func() { err = close(ctx, err) }()
if i.next != nil {
err = i.next.Invoke(ctx, command, opts)
} else {
err = command.Execute(ctx, opts)
}
if err != nil {
return err
}
i.events = append(command.Events(ctx), i.events...)
return
}
// traceInvoker decorates each command with tracing.
type traceInvoker struct {
next Invoker
}
func newTraceInvoker(next Invoker) *traceInvoker {
return &traceInvoker{next: next}
}
func (i *traceInvoker) Invoke(ctx context.Context, command Commander, opts *CommandOpts) (err error) {
ctx, span := tracer.Start(ctx, fmt.Sprintf("%T", command))
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
if i.next != nil {
return i.next.Invoke(ctx, command, opts)
}
return command.Execute(ctx, opts)
}
// loggingInvoker decorates each command with logging.
// It is an example implementation and logs the command name at the beginning and success or failure after the command got executed.
type loggingInvoker struct {
next Invoker
}
func newLoggingInvoker(next Invoker) *loggingInvoker {
return &loggingInvoker{next: next}
}
func (i *loggingInvoker) Invoke(ctx context.Context, command Commander, opts *CommandOpts) (err error) {
logger.InfoContext(ctx, "Invoking command", "command", command.String())
if i.next != nil {
err = i.next.Invoke(ctx, command, opts)
} else {
err = command.Execute(ctx, opts)
}
if err != nil {
logger.ErrorContext(ctx, "Command invocation failed", "command", command.String(), "error", err)
return err
}
logger.InfoContext(ctx, "Command invocation succeeded", "command", command.String())
return nil
}
type noopInvoker struct {
next Invoker
}
func (i *noopInvoker) Invoke(ctx context.Context, command Commander, opts *CommandOpts) error {
if i.next != nil {
return i.next.Invoke(ctx, command, opts)
}
return command.Execute(ctx, opts)
}
// cacheInvoker could be used in the future to do the caching.
// My goal would be to have two interfaces:
// - cacheSetter: which caches an object
// - cacheGetter: which gets an object from the cache, this should also skip the command execution
// type cacheInvoker struct {
// next Invoker
// }
// type cacher interface {
// Cache(opts *CommandOpts)
// }
// func (i *cacheInvoker) Invoke(ctx context.Context, command Commander, opts *CommandOpts) (err error) {
// if c, ok := command.(cacher); ok {
// c.Cache(opts)
// }
// if i.next != nil {
// err = i.next.Invoke(ctx, command, opts)
// } else {
// err = command.Execute(ctx, opts)
// }
// return err
// }