Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 16c2404d31 Race condition between two cache reads in view field creation causes entity not found
https://sonarly.com/issue/4058?type=bug

Creating view fields fails with BUILDER_INTERNAL_SERVER_ERROR when a related entity (fieldMetadata, view, or viewFieldGroup) exists in the first workspace cache read but is absent from the second, separate cache read used during migration building.

Fix: ## Root Cause

A TOCTOU race condition between two separate workspace cache reads in `ViewFieldService.createMany`:

- **Read #1** (lines 79–89 of `view-field.service.ts`): fetches `flatFieldMetadataMaps`, `flatViewMaps`, and `flatViewFieldGroupMaps` to resolve raw IDs (e.g. `fieldMetadataId`) into universal identifiers for building `flatViewFieldsToCreate`.
- **Read #2** (inside `computeAllRelatedFlatEntityMaps` in `workspace-migration-validate-build-and-run-service.ts`): fetches all related entity maps used by the migration builder.

If a concurrent metadata operation (field deletion, cache invalidation) occurs between Read #1 and Read #2, the referenced entity (e.g. `fieldMetadata` resolved in Read #1) may be absent from the second snapshot. When the migration builder then calls `findFlatEntityByUniversalIdentifierOrThrow` on the Read #2 snapshot, it throws `FlatEntityMapsException → BUILDER_INTERNAL_SERVER_ERROR`.

## Fix

Pass the maps fetched in Read #1 as `preloadedFlatEntityMaps` to `validateBuildAndRunWorkspaceMigration`. Inside `computeAllRelatedFlatEntityMaps`, the pre-loaded maps are overlaid over the cache-fetched maps after Read #2:

```typescript file=packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts lines=210-219
const { flatApplicationMaps, ...cachedFlatEntityMaps } =
  await this.workspaceCacheService.getOrRecompute(workspaceId, [...]);

const allRelatedFlatEntityMaps = isDefined(preloadedFlatEntityMaps)
  ? { ...cachedFlatEntityMaps, ...preloadedFlatEntityMaps }
  : cachedFlatEntityMaps;
```

This guarantees the entities resolved during ID-to-universal-identifier translation are present in the maps used for migration building — even if the cache is invalidated between the two async operations. Keys not covered by `preloadedFlatEntityMaps` (e.g. `featureFlagsMap`, `flatObjectMetadataMaps`) still come from the cache normally.

The call site in `view-field.service.ts` now passes:

```typescript file=packages/twenty-server/src/engine/metadata-modules/view-field/services/view-field.service.ts lines=116-120
preloadedFlatEntityMaps: {
  flatFieldMetadataMaps,
  flatViewMaps,
  flatViewFieldGroupMaps,
},
```
2026-03-03 06:50:36 +00:00
2 changed files with 16 additions and 1 deletions
@@ -113,6 +113,11 @@ export class ViewFieldService {
isSystemBuild: false,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
preloadedFlatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
},
},
);
@@ -49,6 +49,7 @@ type ValidateBuildAndRunWorkspaceMigrationFromMatriceArgs = {
};
isSystemBuild?: boolean;
applicationUniversalIdentifier: string;
preloadedFlatEntityMaps?: Partial<AllFlatEntityMaps>;
};
@Injectable()
@@ -190,6 +191,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
allFlatEntityOperationByMetadataName,
workspaceId,
applicationUniversalIdentifier,
preloadedFlatEntityMaps,
}: ValidateBuildAndRunWorkspaceMigrationFromMatriceArgs) {
const allMetadataNameToCompare = Object.keys(
allFlatEntityOperationByMetadataName,
@@ -205,13 +207,17 @@ export class WorkspaceMigrationValidateBuildAndRunService {
const allFlatEntityMapsCacheKeysToCompute =
allMetadataNameCacheToCompute.map(getMetadataFlatEntityMapsKey);
const { flatApplicationMaps, ...allRelatedFlatEntityMaps } =
const { flatApplicationMaps, ...cachedFlatEntityMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
...allFlatEntityMapsCacheKeysToCompute,
...WORKSPACE_MIGRATION_ADDITIONAL_CACHE_DATA_MAPS_KEY,
'flatApplicationMaps',
]);
const allRelatedFlatEntityMaps = isDefined(preloadedFlatEntityMaps)
? { ...cachedFlatEntityMaps, ...preloadedFlatEntityMaps }
: cachedFlatEntityMaps;
const initialAccumulator = allMetadataNameCacheToCompute.reduce<
Partial<AllFlatEntityMaps>
>(
@@ -273,6 +279,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
allFlatEntityOperationByMetadataName,
workspaceId,
applicationUniversalIdentifier,
preloadedFlatEntityMaps,
}: ValidateBuildAndRunWorkspaceMigrationFromMatriceArgs): Promise<{
fromToAllFlatEntityMaps: FromToAllUniversalFlatEntityMaps;
inferDeletionFromMissingEntities: InferDeletionFromMissingEntities;
@@ -288,6 +295,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
allFlatEntityOperationByMetadataName,
workspaceId,
applicationUniversalIdentifier,
preloadedFlatEntityMaps,
});
const fromToAllFlatEntityMaps: FromToAllUniversalFlatEntityMaps = {};
@@ -416,6 +424,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
workspaceId,
isSystemBuild = false,
applicationUniversalIdentifier,
preloadedFlatEntityMaps,
}: ValidateBuildAndRunWorkspaceMigrationFromMatriceArgs): Promise<
| WorkspaceMigrationOrchestratorFailedResult
| WorkspaceMigrationOrchestratorSuccessfulResult
@@ -430,6 +439,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
allFlatEntityOperationByMetadataName: allFlatEntities,
workspaceId,
applicationUniversalIdentifier,
preloadedFlatEntityMaps,
});
return await this.validateBuildAndRunWorkspaceMigrationFromTo({