mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-10 04:52:16 +00:00
feat(eventstore): exclude aggregate IDs when event_type occurred (#8940)
# Which Problems Are Solved
For truly event-based notification handler, we need to be able to filter
out events of aggregates which are already handled. For example when an
event like `notify.success` or `notify.failed` was created on an
aggregate, we no longer require events from that aggregate ID.
# How the Problems Are Solved
Extend the query builder to use a `NOT IN` clause which excludes
aggregate IDs when they have certain events for a certain aggregate
type. For optimization and proper index usages, certain filters are
inherited from the parent query, such as:
- Instance ID
- Instance IDs
- Position offset
This is a prettified query as used by the unit tests:
```sql
SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision
FROM eventstore.events2
WHERE instance_id = $1
AND aggregate_type = $2
AND event_type = $3
AND "position" > $4
AND aggregate_id NOT IN (
SELECT aggregate_id
FROM eventstore.events2
WHERE aggregate_type = $5
AND event_type = ANY($6)
AND instance_id = $7
AND "position" > $8
)
ORDER BY "position" DESC, in_tx_order DESC
LIMIT $9
```
I used this query to run it against the `oidc_session` aggregate looking
for added events, excluding aggregates where a token was revoked,
against a recent position. It fully used index scans:
<details>
```json
[
{
"Plan": {
"Node Type": "Index Scan",
"Parallel Aware": false,
"Async Capable": false,
"Scan Direction": "Forward",
"Index Name": "es_projection",
"Relation Name": "events2",
"Alias": "events2",
"Actual Rows": 2,
"Actual Loops": 1,
"Index Cond": "((instance_id = '286399006995644420'::text) AND (aggregate_type = 'oidc_session'::text) AND (event_type = 'oidc_session.added'::text) AND (\"position\" > 1731582100.784168))",
"Rows Removed by Index Recheck": 0,
"Filter": "(NOT (hashed SubPlan 1))",
"Rows Removed by Filter": 1,
"Plans": [
{
"Node Type": "Index Scan",
"Parent Relationship": "SubPlan",
"Subplan Name": "SubPlan 1",
"Parallel Aware": false,
"Async Capable": false,
"Scan Direction": "Forward",
"Index Name": "es_projection",
"Relation Name": "events2",
"Alias": "events2_1",
"Actual Rows": 1,
"Actual Loops": 1,
"Index Cond": "((instance_id = '286399006995644420'::text) AND (aggregate_type = 'oidc_session'::text) AND (event_type = 'oidc_session.access_token.revoked'::text) AND (\"position\" > 1731582100.784168))",
"Rows Removed by Index Recheck": 0
}
]
},
"Triggers": [
]
}
]
```
</details>
# Additional Changes
- None
# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/8931
---------
Co-authored-by: adlerhurst <silvan.reusser@gmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ type SearchQueryBuilder struct {
|
||||
instanceIDs []string
|
||||
editorUser string
|
||||
queries []*SearchQuery
|
||||
excludeAggregateIDs *ExclusionQuery
|
||||
tx *sql.Tx
|
||||
lockRows bool
|
||||
lockOption LockOption
|
||||
@@ -68,6 +69,10 @@ func (b *SearchQueryBuilder) GetQueries() []*SearchQuery {
|
||||
return b.queries
|
||||
}
|
||||
|
||||
func (b *SearchQueryBuilder) GetExcludeAggregateIDs() *ExclusionQuery {
|
||||
return b.excludeAggregateIDs
|
||||
}
|
||||
|
||||
func (b *SearchQueryBuilder) GetTx() *sql.Tx {
|
||||
return b.tx
|
||||
}
|
||||
@@ -136,6 +141,20 @@ func (q SearchQuery) GetPositionAfter() float64 {
|
||||
return q.positionAfter
|
||||
}
|
||||
|
||||
type ExclusionQuery struct {
|
||||
builder *SearchQueryBuilder
|
||||
aggregateTypes []AggregateType
|
||||
eventTypes []EventType
|
||||
}
|
||||
|
||||
func (q ExclusionQuery) GetAggregateTypes() []AggregateType {
|
||||
return q.aggregateTypes
|
||||
}
|
||||
|
||||
func (q ExclusionQuery) GetEventTypes() []EventType {
|
||||
return q.eventTypes
|
||||
}
|
||||
|
||||
// Columns defines which fields of the event are needed for the query
|
||||
type Columns int8
|
||||
|
||||
@@ -346,6 +365,16 @@ func (builder *SearchQueryBuilder) AddQuery() *SearchQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// ExcludeAggregateIDs excludes events from the aggregate IDs returned by the [ExclusionQuery].
|
||||
// There can be only 1 exclusion query. Subsequent calls overwrite previous definitions.
|
||||
func (builder *SearchQueryBuilder) ExcludeAggregateIDs() *ExclusionQuery {
|
||||
query := &ExclusionQuery{
|
||||
builder: builder,
|
||||
}
|
||||
builder.excludeAggregateIDs = query
|
||||
return query
|
||||
}
|
||||
|
||||
// Or creates a new sub query on the search query builder
|
||||
func (query SearchQuery) Or() *SearchQuery {
|
||||
return query.builder.AddQuery()
|
||||
@@ -398,3 +427,20 @@ func (query *SearchQuery) matches(command Command) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AggregateTypes filters for events with the given aggregate types
|
||||
func (query *ExclusionQuery) AggregateTypes(types ...AggregateType) *ExclusionQuery {
|
||||
query.aggregateTypes = types
|
||||
return query
|
||||
}
|
||||
|
||||
// EventTypes filters for events with the given event types
|
||||
func (query *ExclusionQuery) EventTypes(types ...EventType) *ExclusionQuery {
|
||||
query.eventTypes = types
|
||||
return query
|
||||
}
|
||||
|
||||
// Builder returns the SearchQueryBuilder of the sub query
|
||||
func (query *ExclusionQuery) Builder() *SearchQueryBuilder {
|
||||
return query.builder
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user