diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 4854356455..f0c8bedbeb 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -328,6 +328,16 @@ Caches: AddSource: true Formatter: Format: text + # Organization cache, gettable by primary domain or ID. + Organization: + Connector: "" + MaxAge: 1h + LastUsage: 10m + Log: + Level: error + AddSource: true + Formatter: + Format: text Machine: # Cloud-hosted VMs need to specify their metadata endpoint so that the machine can be uniquely identified. diff --git a/docs/docs/self-hosting/manage/cache.md b/docs/docs/self-hosting/manage/cache.md index 2de0b43faa..def2ece633 100644 --- a/docs/docs/self-hosting/manage/cache.md +++ b/docs/docs/self-hosting/manage/cache.md @@ -176,6 +176,16 @@ Milestones are reached upon the first time a certain action is performed. For ex As an extra optimization, once all milestones are reached by the instance, an in-memory flag is set and the milestone state is never queried again from the database nor cache. For single instance setups which fulfilled all milestone (*your next steps* in console) it is not needed to enable this cache. We mainly use it for ZITADEL cloud where there are many instances with *incomplete* milestones. +### Organization + +Most resources like users, project and applications are part of an [organization](/docs/concepts/structure/organizations). Therefore many parts of the ZITADEL logic search for an organization by ID or by their primary domain. +Organization objects are quite small and receive infrequent updates after they are created: + +- Change of organization name +- Deactivation / Reactivation +- Change of primary domain +- Removal + ## Examples Currently caches are in beta and disabled by default. However, if you want to give caching a try, the following sections contains some suggested configurations for different setups. @@ -189,6 +199,9 @@ Caches: Instance: Connector: "memory" MaxAge: 1h + Organization: + Connector: "memory" + MaxAge: 1h ``` The following configuration is recommended for single instance setups with high traffic on multiple servers, where Redis is not available: @@ -206,6 +219,9 @@ Caches: Connector: "postgres" MaxAge: 1h LastUsage: 10m + Organization: + Connector: "memory" + MaxAge: 1s ``` When running many instances on multiple servers: @@ -225,6 +241,10 @@ Caches: Connector: "redis" MaxAge: 1h LastUsage: 10m + Organization: + Connector: "redis" + MaxAge: 1h + LastUsage: 10m ``` ---- diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 9e92f50988..c7dbad6f2c 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -16,6 +16,7 @@ const ( PurposeUnspecified Purpose = iota PurposeAuthzInstance PurposeMilestones + PurposeOrganization ) // Cache stores objects with a value of type `V`. diff --git a/internal/cache/connector/connector.go b/internal/cache/connector/connector.go index 0c4fb9ccc6..09298fa688 100644 --- a/internal/cache/connector/connector.go +++ b/internal/cache/connector/connector.go @@ -19,8 +19,9 @@ type CachesConfig struct { Postgres pg.Config Redis redis.Config } - Instance *cache.Config - Milestones *cache.Config + Instance *cache.Config + Milestones *cache.Config + Organization *cache.Config } type Connectors struct { diff --git a/internal/cache/purpose_enumer.go b/internal/cache/purpose_enumer.go index bae47476ff..47ad167d70 100644 --- a/internal/cache/purpose_enumer.go +++ b/internal/cache/purpose_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _PurposeName = "unspecifiedauthz_instancemilestones" +const _PurposeName = "unspecifiedauthz_instancemilestonesorganization" -var _PurposeIndex = [...]uint8{0, 11, 25, 35} +var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47} -const _PurposeLowerName = "unspecifiedauthz_instancemilestones" +const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganization" func (i Purpose) String() string { if i < 0 || i >= Purpose(len(_PurposeIndex)-1) { @@ -27,9 +27,10 @@ func _PurposeNoOp() { _ = x[PurposeUnspecified-(0)] _ = x[PurposeAuthzInstance-(1)] _ = x[PurposeMilestones-(2)] + _ = x[PurposeOrganization-(3)] } -var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones} +var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization} var _PurposeNameToValueMap = map[string]Purpose{ _PurposeName[0:11]: PurposeUnspecified, @@ -38,12 +39,15 @@ var _PurposeNameToValueMap = map[string]Purpose{ _PurposeLowerName[11:25]: PurposeAuthzInstance, _PurposeName[25:35]: PurposeMilestones, _PurposeLowerName[25:35]: PurposeMilestones, + _PurposeName[35:47]: PurposeOrganization, + _PurposeLowerName[35:47]: PurposeOrganization, } var _PurposeNames = []string{ _PurposeName[0:11], _PurposeName[11:25], _PurposeName[25:35], + _PurposeName[35:47], } // PurposeString retrieves an enum value from the enum constants string name. diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index 378dc2f09b..e2642d9b8f 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -8,28 +8,30 @@ TLS: Caches: Connectors: + Memory: + Enabled: true Postgres: Enabled: true Redis: Enabled: true Instance: - Connector: "redis" - MaxAge: 1h - LastUsage: 10m + Connector: "memory" + MaxAge: 5m + LastUsage: 1m Log: Level: info - AddSource: true - Formatter: - Format: text Milestones: Connector: "postgres" - MaxAge: 1h - LastUsage: 10m + MaxAge: 5m + LastUsage: 1m + Log: + Level: info + Organization: + Connector: "redis" + MaxAge: 5m + LastUsage: 1m Log: Level: info - AddSource: true - Formatter: - Format: text Quotas: Access: diff --git a/internal/query/cache.go b/internal/query/cache.go index 55f7bb3db6..949e121c1f 100644 --- a/internal/query/cache.go +++ b/internal/query/cache.go @@ -12,6 +12,7 @@ import ( type Caches struct { instance cache.Cache[instanceIndex, string, *authzInstance] + org cache.Cache[orgIndex, string, *Org] } func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) { @@ -20,7 +21,13 @@ func startCaches(background context.Context, connectors connector.Connectors) (_ if err != nil { return nil, err } + caches.org, err = connector.StartCache[orgIndex, string, *Org](background, orgIndexValues(), cache.PurposeOrganization, connectors.Config.Organization, connectors) + if err != nil { + return nil, err + } + caches.registerInstanceInvalidation() + caches.registerOrgInvalidation() return caches, nil } diff --git a/internal/query/instance.go b/internal/query/instance.go index 549c05a233..8dd0db7d89 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -522,9 +522,9 @@ func (i *authzInstance) Keys(index instanceIndex) []string { return []string{i.ID} case instanceIndexByHost: return i.ExternalDomains - default: - return nil + case instanceIndexUnspecified: } + return nil } func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { diff --git a/internal/query/org.go b/internal/query/org.go index 1c20255171..a57867d92b 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -107,10 +107,19 @@ func (q *OrgSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { return query } -func (q *Queries) OrgByID(ctx context.Context, shouldTriggerBulk bool, id string) (_ *Org, err error) { +func (q *Queries) OrgByID(ctx context.Context, shouldTriggerBulk bool, id string) (org *Org, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + if org, ok := q.caches.org.Get(ctx, orgIndexByID, id); ok { + return org, nil + } + defer func() { + if err == nil && org != nil { + q.caches.org.Set(ctx, org) + } + }() + if !authz.GetInstance(ctx).Features().ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeOrgByID) { return q.oldOrgByID(ctx, shouldTriggerBulk, id) } @@ -175,6 +184,11 @@ func (q *Queries) OrgByPrimaryDomain(ctx context.Context, domain string) (org *O ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + org, ok := q.caches.org.Get(ctx, orgIndexByPrimaryDomain, domain) + if ok { + return org, nil + } + stmt, scan := prepareOrgQuery(ctx, q.client) query, args, err := stmt.Where(sq.Eq{ OrgColumnDomain.identifier(): domain, @@ -189,6 +203,9 @@ func (q *Queries) OrgByPrimaryDomain(ctx context.Context, domain string) (org *O org, err = scan(row) return err }, query, args...) + if err == nil { + q.caches.org.Set(ctx, org) + } return org, err } @@ -476,3 +493,30 @@ func prepareOrgUniqueQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu return isUnique, err } } + +type orgIndex int + +//go:generate enumer -type orgIndex -linecomment +const ( + // Empty line comment ensures empty string for unspecified value + orgIndexUnspecified orgIndex = iota // + orgIndexByID + orgIndexByPrimaryDomain +) + +// Keys implements [cache.Entry] +func (o *Org) Keys(index orgIndex) []string { + switch index { + case orgIndexByID: + return []string{o.ID} + case orgIndexByPrimaryDomain: + return []string{o.Domain} + case orgIndexUnspecified: + } + return nil +} + +func (c *Caches) registerOrgInvalidation() { + invalidate := cacheInvalidationFunc(c.instance, instanceIndexByID, getAggregateID) + projection.OrgProjection.RegisterCacheInvalidation(invalidate) +} diff --git a/internal/query/orgindex_enumer.go b/internal/query/orgindex_enumer.go new file mode 100644 index 0000000000..74f7c985c9 --- /dev/null +++ b/internal/query/orgindex_enumer.go @@ -0,0 +1,82 @@ +// Code generated by "enumer -type orgIndex -linecomment"; DO NOT EDIT. + +package query + +import ( + "fmt" + "strings" +) + +const _orgIndexName = "orgIndexByIDorgIndexByPrimaryDomain" + +var _orgIndexIndex = [...]uint8{0, 0, 12, 35} + +const _orgIndexLowerName = "orgindexbyidorgindexbyprimarydomain" + +func (i orgIndex) String() string { + if i < 0 || i >= orgIndex(len(_orgIndexIndex)-1) { + return fmt.Sprintf("orgIndex(%d)", i) + } + return _orgIndexName[_orgIndexIndex[i]:_orgIndexIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _orgIndexNoOp() { + var x [1]struct{} + _ = x[orgIndexUnspecified-(0)] + _ = x[orgIndexByID-(1)] + _ = x[orgIndexByPrimaryDomain-(2)] +} + +var _orgIndexValues = []orgIndex{orgIndexUnspecified, orgIndexByID, orgIndexByPrimaryDomain} + +var _orgIndexNameToValueMap = map[string]orgIndex{ + _orgIndexName[0:0]: orgIndexUnspecified, + _orgIndexLowerName[0:0]: orgIndexUnspecified, + _orgIndexName[0:12]: orgIndexByID, + _orgIndexLowerName[0:12]: orgIndexByID, + _orgIndexName[12:35]: orgIndexByPrimaryDomain, + _orgIndexLowerName[12:35]: orgIndexByPrimaryDomain, +} + +var _orgIndexNames = []string{ + _orgIndexName[0:0], + _orgIndexName[0:12], + _orgIndexName[12:35], +} + +// orgIndexString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func orgIndexString(s string) (orgIndex, error) { + if val, ok := _orgIndexNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _orgIndexNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to orgIndex values", s) +} + +// orgIndexValues returns all values of the enum +func orgIndexValues() []orgIndex { + return _orgIndexValues +} + +// orgIndexStrings returns a slice of all String values of the enum +func orgIndexStrings() []string { + strs := make([]string, len(_orgIndexNames)) + copy(strs, _orgIndexNames) + return strs +} + +// IsAorgIndex returns "true" if the value is listed in the enum definition. "false" otherwise +func (i orgIndex) IsAorgIndex() bool { + for _, v := range _orgIndexValues { + if i == v { + return true + } + } + return false +}