Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 35e6b13329 chore: improve monitoring for Race condition between cache invalidation of flat
**Monitoring improvement:** Added structured logging to `WorkspaceSchemaFactory` that records key diagnostic context on every schema build:
- Whether typeDefs were served from cache or freshly generated
- The `metadataVersion` used for the cache key
- The number of objects in `flatObjectMetadataMaps`

This log line fires on every `POST /graphql` request and provides the exact data needed to diagnose future cache desync issues. When a "defined in resolvers, but not in schema" error occurs, the preceding log entry will show `typeDefs=cached` with a metadata version that doesn't match the object count — immediately revealing the stale cache condition.

Added `Logger` from `@nestjs/common` (consistent with the pattern used in `WorkspaceResolverFactory` and other services in the codebase).
2026-03-06 22:57:04 +00:00
Sonarly Claude Code 46aad8813b Race condition between cache invalidation of flat entity maps and metadata version
https://sonarly.com/issue/5161?type=bug

A cache invalidation ordering bug causes GraphQL schema build failures when custom objects are created or modified. The flat entity maps cache is updated before the metadata version, creating a window where resolvers include new custom objects but cached typeDefs do not.

Fix: **Fix:** Reordered cache invalidation in `WorkspaceMigrationRunnerService.invalidateCache()` to increment the metadata version **before** invalidating flat entity maps, eliminating the race condition window.

**The problem:** When a custom object (e.g., "PersonalInsurancePolicy") was created, `invalidateCache` performed two sequential steps:
1. Invalidate and recompute flat entity maps (now includes the new object)
2. Increment metadata version (changes the typeDefs cache key)

Between steps 1 and 2, any GraphQL request would see fresh flat entity maps (with the new object) but the old metadata version, causing it to use stale cached typeDefs (without the new object). The resolvers (always freshly generated from flat entity maps) would then include `personalInsurancePolicies` while the cached typeDefs wouldn't — causing `makeExecutableSchema` to throw.

**The fix:** Moved `incrementMetadataVersion` out of `getLegacyCacheInvalidationPromises` and into `invalidateCache`, calling it **before** `invalidateFlatEntityMaps`. This ensures the typeDefs cache key is invalidated first, so any concurrent request will regenerate typeDefs from the current flat entity maps (whether old or new), keeping typeDefs and resolvers consistent.

The other legacy cache operations (rolesPermissions, ORM entities, etc.) remain in `getLegacyCacheInvalidationPromises` and still run after flat entity maps invalidation, preserving the inter-dependency ordering that commit 46cf551281 intended.
2026-03-06 22:57:04 +00:00
2 changed files with 27 additions and 10 deletions
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { GraphQLSchema, printSchema } from 'graphql';
@@ -27,6 +27,8 @@ import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty
@Injectable()
export class WorkspaceSchemaFactory {
private readonly logger = new Logger(WorkspaceSchemaFactory.name);
constructor(
private readonly dataSourceService: DataSourceService,
private readonly scalarsExplorerService: ScalarsExplorerService,
@@ -146,7 +148,9 @@ export class WorkspaceSchemaFactory {
applicationId,
);
if (!typeDefs || !usedScalarNames) {
const typeDefsFromCache = isDefined(typeDefs) && isDefined(usedScalarNames);
if (!typeDefsFromCache) {
const autoGeneratedSchema =
await this.workspaceGraphQLSchemaGenerator.generateSchema({
flatObjectMetadataMaps,
@@ -172,6 +176,16 @@ export class WorkspaceSchemaFactory {
);
}
const objectCount = Object.keys(
flatObjectMetadataMaps.byUniversalIdentifier,
).length;
this.logger.log(
`Building schema for workspace ${workspace.id} ` +
`(metadataVersion=${metadataVersion}, objects=${objectCount}, ` +
`typeDefs=${typeDefsFromCache ? 'cached' : 'generated'})`,
);
const autoGeneratedResolvers = await this.workspaceResolverFactory.create(
flatObjectMetadataMaps,
flatFieldMetadataMaps,
@@ -51,14 +51,6 @@ export class WorkspaceMigrationRunnerService {
flatMapsKeysSet.has('flatObjectMetadataMaps') ||
flatMapsKeysSet.has('flatFieldMetadataMaps');
if (shouldIncrementMetadataGraphqlSchemaVersion) {
asyncOperations.push(
this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
),
);
}
const viewRelatedFlatMapsKeys: (keyof AllFlatEntityMaps)[] = [
'flatViewMaps',
'flatViewFilterMaps',
@@ -116,6 +108,17 @@ export class WorkspaceMigrationRunnerService {
`Cache invalidation ${allFlatEntityMapsKeys.join()}`,
);
const flatMapsKeysSet = new Set(allFlatEntityMapsKeys);
if (
flatMapsKeysSet.has('flatObjectMetadataMaps') ||
flatMapsKeysSet.has('flatFieldMetadataMaps')
) {
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
}
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: allFlatEntityMapsKeys,