Rekey metadata caches when flat map hashes change (#23164)
## Context
The `/metadata` GraphQL response cache (`ObjectMetadataItems`,
`FindAllViews`) and the workspace SDL cache were keyed on
`workspace.metadataVersion`, an integer bumped on every object/field
migration. That mechanism is legacy (the migration runner literally
calls it `getLegacyCacheInvalidationPromises`): the data plane already
moved to `WorkspaceCacheService`, which versions each flat entity map
with its own hash minted on invalidation.
Version keying had two concrete costs: the version bump was the only
proactive invalidation for `ObjectMetadataItems`, and keeping
`FindAllViews` fresh required `flushGraphQLOperation`, a full Redis
keyspace SCAN on every relevant migration. It is also the main blocker
for deprecating `metadataVersion` entirely.
This PR re-keys both caches on the flat-map hashes instead.
## What changed
**Response cache** (`use-cached-metadata.ts`): the key is now
`{operation}:{workspaceId}:{combinedDependencyHash}[:{userWorkspaceId}]:{locale}:{queryHash}`.
Each cached operation declares which flat maps its resolvers read
(`metadata-graphql-operations-to-cache.constant.ts`) plus a scope:
`ObjectMetadataItems` stays workspace-shared, `FindAllViews` is per-user
because unlisted-view visibility depends on the caller. When any
declared map changes, its hash rotates and the key rotates with it; no
flush needed. The key is resolved once per request and reused in
`onResponse`, so a rotation mid-request can never cache a response under
a fresher key than the data it was built from. If hash resolution fails,
the request is served uncached (Sentry-captured).
This also fixes three pre-existing key soundness gaps: `FindAllViews`
ignored locale although view names are translated server-side, the query
hash ignored GraphQL variables (`$viewTypes`), and mid-request version
rotation could re-key between request and response.
**SDL cache** (`workspace-graphql-schema-sdl.service.ts`): keyed on the
combined hash of the four maps the schema is generated from, taken from
the same `getOrRecomputeWithHashes` call that returns the data, so key
and content cannot skew. The `metadataVersion` read/seed block there is
gone; the Redis seed moved to `middleware.service.ts` so the
`X-Schema-Version` "refresh the page" check keeps working after the
Redis key's TTL expires.
**`WorkspaceCacheService`**: the internal pipeline now threads `{data,
hashes}` through every stage (local hit, hash validation, Redis fetch,
recompute) and the memoizer stores the pair, so returned hashes are
always consistent with returned data. New public
`getOrRecomputeWithHashes` and `getOrRecomputeCombinedHash`
(hashes-first: one MGET of the small `:hash` keys, full pipeline only
for missing ones, so cold pods never pull map payloads just to build a
key).
**Atomic pair writes** (`cache-storage.service.ts`): `mset` on the Redis
driver now delegates to the store's own `mset` (a MULTI of `SET ... PX`,
or native `MSET`), grouped by TTL. Previously it was a `Promise.all` of
independent SETs, so two concurrent recomputes could interleave and
leave one recompute's `:data` next to the other's `:hash`; with
hash-keyed caches that torn pair could persist a stale response under a
live key. `CoreEntityCacheService` writes through the same method and is
fixed for free.
**Cleanup**: `flush()` lost its `metadataVersion` parameter (always
pattern-flush per key on workspace deletion),
`METADATA_VERSIONED_WORKSPACE_CACHE_KEY` became
`HASH_KEYED_WORKSPACE_CACHE_KEYS` with the `MetadataVersion` key
relocated to `WORKSPACE_CACHE_KEYS` and the dead `ORMEntitySchemas`
entry removed.
## Deliberately unchanged
- `incrementMetadataVersion` and all its callers stay: the version still
feeds the `X-Schema-Version` check and the pinned upgrade commands.
Deprecating the column is a later stage.
- The runner's `FindAllViews` pattern-flush is kept for exactly one
release: view-only migrations never bump `metadataVersion`, so old pods
in a rolling deploy have no other invalidation signal for their
version-keyed entries. It gets deleted next release, which removes the
SCAN entirely.
- Old-shape cache entries are not migrated; they expire via the 7-day
TTL.
## Known limitations (follow-ups, not regressions)
- The plugin reads dependency hashes Redis-fresh while resolvers can
serve up to 10s-old memoized data, so a request landing right after a
migration can cache a pre-rotation response under the new key. Same
shape existed under `metadataVersion`; closing it needs request-scoped
snapshot plumbing.
- Concurrent recomputes are last-writer-wins (lost update). Fencing with
a conditional write is a follow-up.
## Validation
- Unit: response-cache plugin behavior (scope, key stash, serve-uncached
on failure, prototype-name guard), atomic `mset` batching, existing
`WorkspaceCacheService` spec passing unchanged.
- Integration: a new drift-guard spec runs the real
`ObjectMetadataItems`/`FindAllViews` operations with full frontend
selection sets against the in-process app, spies on
`WorkspaceCacheService`, and fails if resolvers read a flat map missing
from the declared dependency lists, so the constant cannot silently
drift.
- Manual against a live server: creating a field rotates the field-map
hash and the very next `ObjectMetadataItems` response contains it (hash
rotation is now its only invalidation path); warm hits are ~5ms; SDL
entries appear under hash-shaped keys via introspection.
## Suggested reading order
1. `workspace-cache.service.ts`, `workspace-cache-key.type.ts`,
`combine-cache-hashes.util.ts` (the `{data, hashes}` pipeline)
2. `use-cached-metadata.ts`,
`metadata-graphql-operations-to-cache.constant.ts`,
`metadata.module-factory.ts` (response cache)
3. `workspace-graphql-schema-sdl.service.ts`,
`workspace-cache-storage.service.ts` (SDL cache and renames)
4. `middleware.service.ts` (metadata version seed relocation)
5. `cache-storage.service.ts` (atomic writes)
6. Tests
This commit is contained in:
+19
-42
@@ -2,21 +2,18 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
|
||||
export const METADATA_VERSIONED_WORKSPACE_CACHE_KEY = {
|
||||
export const HASH_KEYED_WORKSPACE_CACHE_KEYS = {
|
||||
GraphQLTypeDefs: 'graphql:type-defs',
|
||||
MetadataVersion: 'metadata:workspace-metadata-version',
|
||||
GraphQLUsedScalarNames: 'graphql:used-scalar-names',
|
||||
ORMEntitySchemas: 'orm:entity-schemas',
|
||||
} as const;
|
||||
export const WORKSPACE_CACHE_KEYS = {
|
||||
MetadataVersion: 'metadata:workspace-metadata-version',
|
||||
GraphQLOperations: 'graphql:operations',
|
||||
GraphQLFeatureFlag: 'graphql:feature-flag',
|
||||
FeatureFlagMap: 'feature-flag:feature-flag-map',
|
||||
@@ -47,7 +44,7 @@ export class WorkspaceCacheStorageService {
|
||||
metadataVersion: number,
|
||||
): Promise<void> {
|
||||
return this.cacheStorageService.set<number>(
|
||||
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.MetadataVersion}:${workspaceId}`,
|
||||
`${WORKSPACE_CACHE_KEYS.MetadataVersion}:${workspaceId}`,
|
||||
metadataVersion,
|
||||
TTL_ONE_WEEK,
|
||||
);
|
||||
@@ -55,20 +52,20 @@ export class WorkspaceCacheStorageService {
|
||||
|
||||
getMetadataVersion(workspaceId: string): Promise<number | undefined> {
|
||||
return this.cacheStorageService.get<number>(
|
||||
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.MetadataVersion}:${workspaceId}`,
|
||||
`${WORKSPACE_CACHE_KEYS.MetadataVersion}:${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
setGraphQLTypeDefs(
|
||||
workspaceId: string,
|
||||
metadataVersion: number,
|
||||
metadataCacheHash: string,
|
||||
typeDefs: string,
|
||||
applicationId?: string,
|
||||
): Promise<void> {
|
||||
const applicationSuffix = applicationId ? `:${applicationId}` : '';
|
||||
|
||||
return this.cacheStorageService.set<string>(
|
||||
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
|
||||
`${HASH_KEYED_WORKSPACE_CACHE_KEYS.GraphQLTypeDefs}:${workspaceId}:${metadataCacheHash}${applicationSuffix}`,
|
||||
typeDefs,
|
||||
TTL_ONE_WEEK,
|
||||
);
|
||||
@@ -76,26 +73,26 @@ export class WorkspaceCacheStorageService {
|
||||
|
||||
getGraphQLTypeDefs(
|
||||
workspaceId: string,
|
||||
metadataVersion: number,
|
||||
metadataCacheHash: string,
|
||||
applicationId?: string,
|
||||
): Promise<string | undefined> {
|
||||
const applicationSuffix = applicationId ? `:${applicationId}` : '';
|
||||
|
||||
return this.cacheStorageService.get<string>(
|
||||
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLTypeDefs}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
|
||||
`${HASH_KEYED_WORKSPACE_CACHE_KEYS.GraphQLTypeDefs}:${workspaceId}:${metadataCacheHash}${applicationSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
setGraphQLUsedScalarNames(
|
||||
workspaceId: string,
|
||||
metadataVersion: number,
|
||||
metadataCacheHash: string,
|
||||
usedScalarNames: string[],
|
||||
applicationId?: string,
|
||||
): Promise<void> {
|
||||
const applicationSuffix = applicationId ? `:${applicationId}` : '';
|
||||
|
||||
return this.cacheStorageService.set<string[]>(
|
||||
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
|
||||
`${HASH_KEYED_WORKSPACE_CACHE_KEYS.GraphQLUsedScalarNames}:${workspaceId}:${metadataCacheHash}${applicationSuffix}`,
|
||||
usedScalarNames,
|
||||
TTL_ONE_WEEK,
|
||||
);
|
||||
@@ -103,13 +100,13 @@ export class WorkspaceCacheStorageService {
|
||||
|
||||
getGraphQLUsedScalarNames(
|
||||
workspaceId: string,
|
||||
metadataVersion: number,
|
||||
metadataCacheHash: string,
|
||||
applicationId?: string,
|
||||
): Promise<string[] | undefined> {
|
||||
const applicationSuffix = applicationId ? `:${applicationId}` : '';
|
||||
|
||||
return this.cacheStorageService.get<string[]>(
|
||||
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.GraphQLUsedScalarNames}:${workspaceId}:${metadataVersion}${applicationSuffix}`,
|
||||
`${HASH_KEYED_WORKSPACE_CACHE_KEYS.GraphQLUsedScalarNames}:${workspaceId}:${metadataCacheHash}${applicationSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,36 +166,16 @@ export class WorkspaceCacheStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
async flushVersionedMetadata(
|
||||
workspaceId: string,
|
||||
metadataVersion?: number,
|
||||
): Promise<void> {
|
||||
const { MetadataVersion, ...versionedCacheKeys } =
|
||||
METADATA_VERSIONED_WORKSPACE_CACHE_KEY;
|
||||
|
||||
await Promise.all([
|
||||
this.cacheStorageService.del(`${MetadataVersion}:${workspaceId}`),
|
||||
...Object.values(versionedCacheKeys).flatMap((key) =>
|
||||
isDefined(metadataVersion)
|
||||
? [
|
||||
this.cacheStorageService.del(
|
||||
`${key}:${workspaceId}:${metadataVersion}`,
|
||||
),
|
||||
this.cacheStorageService.flushByPattern(
|
||||
`${key}:${workspaceId}:${metadataVersion}:*`,
|
||||
),
|
||||
]
|
||||
: [
|
||||
this.cacheStorageService.flushByPattern(
|
||||
`${key}:${workspaceId}:*`,
|
||||
),
|
||||
],
|
||||
async flushHashKeyedWorkspaceCache(workspaceId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(HASH_KEYED_WORKSPACE_CACHE_KEYS).map((key) =>
|
||||
this.cacheStorageService.flushByPattern(`${key}:${workspaceId}:*`),
|
||||
),
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
async flush(workspaceId: string, metadataVersion?: number): Promise<void> {
|
||||
await this.flushVersionedMetadata(workspaceId, metadataVersion);
|
||||
async flush(workspaceId: string): Promise<void> {
|
||||
await this.flushHashKeyedWorkspaceCache(workspaceId);
|
||||
|
||||
await Promise.all(
|
||||
Object.values(WORKSPACE_CACHE_KEYS).map(
|
||||
|
||||
Reference in New Issue
Block a user