Files
zitadel/internal/eventstore/v3/sequence.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

144 lines
4.3 KiB
Go

package eventstore
import (
"context"
_ "embed"
"fmt"
"strings"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/zerrors"
)
type latestSequence struct {
aggregate *eventstore.Aggregate
sequence uint64
}
//go:embed sequences_query.sql
var latestSequencesStmt string
func latestSequences(ctx context.Context, tx database.Transaction, commands []eventstore.Command) ([]*latestSequence, error) {
sequences := commandsToSequences(ctx, commands)
conditions, args := sequencesToSql(sequences)
rows, err := tx.Query(ctx, fmt.Sprintf(latestSequencesStmt, strings.Join(conditions, " UNION ALL ")), args...)
if err != nil {
return nil, zerrors.ThrowInternal(err, "V3-5jU5z", "Errors.Internal")
}
defer rows.Close()
for rows.Next() {
if err := scanToSequence(rows, sequences); err != nil {
return nil, zerrors.ThrowInternal(err, "V3-Ydiwv", "Errors.Internal")
}
}
if rows.Err() != nil {
return nil, zerrors.ThrowInternal(rows.Err(), "V3-XApDk", "Errors.Internal")
}
return sequences, nil
}
func searchSequenceByCommand(sequences []*latestSequence, command eventstore.Command) *latestSequence {
for _, sequence := range sequences {
if sequence.aggregate.Type == command.Aggregate().Type &&
sequence.aggregate.ID == command.Aggregate().ID &&
sequence.aggregate.InstanceID == command.Aggregate().InstanceID {
return sequence
}
}
return nil
}
func searchSequence(sequences []*latestSequence, aggregateType eventstore.AggregateType, aggregateID, instanceID string) *latestSequence {
for _, sequence := range sequences {
if sequence.aggregate.Type == aggregateType &&
sequence.aggregate.ID == aggregateID &&
sequence.aggregate.InstanceID == instanceID {
return sequence
}
}
return nil
}
func commandsToSequences(ctx context.Context, commands []eventstore.Command) []*latestSequence {
sequences := make([]*latestSequence, 0, len(commands))
for _, command := range commands {
if searchSequenceByCommand(sequences, command) != nil {
continue
}
if command.Aggregate().InstanceID == "" {
command.Aggregate().InstanceID = authz.GetInstance(ctx).InstanceID()
}
sequences = append(sequences, &latestSequence{
aggregate: command.Aggregate(),
})
}
return sequences
}
const argsPerCondition = 3
func sequencesToSql(sequences []*latestSequence) (conditions []string, args []any) {
args = make([]interface{}, 0, len(sequences)*argsPerCondition)
conditions = make([]string, len(sequences))
for i, sequence := range sequences {
conditions[i] = fmt.Sprintf(`(SELECT instance_id, aggregate_type, aggregate_id, "sequence" FROM eventstore.events2 WHERE instance_id = $%d AND aggregate_type = $%d AND aggregate_id = $%d ORDER BY "sequence" DESC LIMIT 1)`,
i*argsPerCondition+1,
i*argsPerCondition+2,
i*argsPerCondition+3,
)
args = append(args, sequence.aggregate.InstanceID, sequence.aggregate.Type, sequence.aggregate.ID)
}
return conditions, args
}
func scanToSequence(rows database.Rows, sequences []*latestSequence) error {
var aggregateType eventstore.AggregateType
var aggregateID, instanceID string
var currentSequence uint64
var resourceOwner string
if err := rows.Scan(&instanceID, &resourceOwner, &aggregateType, &aggregateID, &currentSequence); err != nil {
return zerrors.ThrowInternal(err, "V3-OIWqj", "Errors.Internal")
}
sequence := searchSequence(sequences, aggregateType, aggregateID, instanceID)
if sequence == nil {
logging.WithFields(
"aggType", aggregateType,
"aggID", aggregateID,
"instance", instanceID,
).Panic("no sequence found")
// added return for linting
return nil
}
sequence.sequence = currentSequence
if resourceOwner != "" && sequence.aggregate.ResourceOwner != "" && sequence.aggregate.ResourceOwner != resourceOwner {
logging.WithFields(
"current_sequence", sequence.sequence,
"instance_id", sequence.aggregate.InstanceID,
"agg_type", sequence.aggregate.Type,
"agg_id", sequence.aggregate.ID,
"current_owner", resourceOwner,
"provided_owner", sequence.aggregate.ResourceOwner,
).Info("would have set wrong resource owner")
}
// set resource owner from previous events
if resourceOwner != "" {
sequence.aggregate.ResourceOwner = resourceOwner
}
return nil
}