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:
Weiko
2026-07-22 18:50:09 +02:00
committed by GitHub
parent f67e9c6b05
commit 0108a34765
20 changed files with 974 additions and 215 deletions
@@ -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(