Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 41aa332193 chore: improve monitoring for fix(server): handle missing role references gracef
**`workspace-migration-runner.service.ts`** — Improved the error log in `invalidateCache` to include the workspace ID and the specific cache keys that were being invalidated. Previously the error message was generic ("Failed to invalidate a legacy cache {reason}") which made it difficult to correlate errors to specific workspaces or identify which cache operation failed. The new message includes `workspaceId` and the list of `allFlatEntityMapsKeys`, making it actionable for operators debugging cache invalidation failures.

**WARN logs in both cache services** — The new `logger.warn()` calls in the cache services emit structured warnings when orphaned roleTargets are encountered. These serve as a signal that the workspace has data integrity issues that should be resolved by running the v1.19 upgrade command (`fix-invalid-standard-universal-identifiers`) or by manually cleaning up orphaned roleTarget rows. The WARN level is appropriate because the system degrades gracefully (orphaned targets are skipped) rather than failing.
2026-03-17 04:14:45 +00:00
Sonarly Claude Code 4122987433 fix(server): handle missing role references gracefully in flat role target cache builder
https://sonarly.com/issue/15476?type=bug

View create/update/delete operations trigger a background error during cache invalidation because the flat role target map builder throws when a roleTarget references a role with missing or null `universalIdentifier`. The mutation succeeds but the UI hangs briefly waiting for cache invalidation to resolve.

Fix: The fix addresses the root cause in two cache services that build flat role target maps from database entities:

**1. `workspace-flat-role-target-map-cache.service.ts`** — The `computeForCache` method now pre-filters roleTargets before passing them to `fromRoleTargetEntityToFlatRoleTarget`. RoleTargets whose `roleId` or `applicationId` reference entities that either don't exist or have null `universalIdentifier` are skipped with a WARN-level log. This prevents the `FlatEntityMapsException` from being thrown during cache recomputation, which was crashing every view mutation on upgraded instances.

**2. `workspace-flat-role-target-by-agent-id.service.ts`** — Same fix applied to the agent-specific role target cache, which has the identical vulnerability. Without this fix, any operation that triggers `flatRoleTargetByAgentIdMaps` recomputation would also crash.

The validation in `fromRoleTargetEntityToFlatRoleTarget` is intentionally preserved — it serves as a safety net for programming errors where the caller passes incomplete maps. The pre-filtering in `computeForCache` handles the data integrity scenario (orphaned/incomplete data from upgrades) at the appropriate layer.
2026-03-17 04:14:45 +00:00
3 changed files with 66 additions and 5 deletions
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { NonNullableRequired } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -17,6 +18,10 @@ import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/uti
@Injectable()
@WorkspaceCache('flatRoleTargetByAgentIdMaps')
export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvider<FlatRoleTargetByAgentIdMaps> {
private readonly logger = new Logger(
WorkspaceFlatRoleTargetByAgentIdService.name,
);
constructor(
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@@ -62,6 +67,24 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi
Omit<RoleTargetEntity, 'agentId'> &
NonNullableRequired<Pick<RoleTargetEntity, 'agentId'>>
>) {
const roleUniversalIdentifier = roleIdToUniversalIdentifierMap.get(
roleTargetEntity.roleId,
);
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
roleTargetEntity.applicationId,
);
if (
!isDefined(roleUniversalIdentifier) ||
!isDefined(applicationUniversalIdentifier)
) {
this.logger.warn(
`Skipping orphaned roleTarget ${roleTargetEntity.id}: role or application not found or has null universalIdentifier (workspace ${workspaceId})`,
);
continue;
}
const flatRoleTarget = fromRoleTargetEntityToFlatRoleTarget({
entity: roleTargetEntity,
applicationIdToUniversalIdentifierMap,
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -18,6 +19,10 @@ import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/
@Injectable()
@WorkspaceCache('flatRoleTargetMaps')
export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvider<FlatRoleTargetMaps> {
private readonly logger = new Logger(
WorkspaceFlatRoleTargetMapCacheService.name,
);
constructor(
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@@ -52,9 +57,42 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid
const roleIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(roles);
// Filter out orphaned roleTargets that reference non-existent roles or
// applications, or roles/applications with null universalIdentifier.
// This can happen on instances upgraded incrementally from older versions
// where data migrations did not fully populate universalIdentifier.
const validRoleTargets = roleTargets.filter((roleTargetEntity) => {
const roleUniversalIdentifier = roleIdToUniversalIdentifierMap.get(
roleTargetEntity.roleId,
);
if (!isDefined(roleUniversalIdentifier)) {
this.logger.warn(
`Skipping orphaned roleTarget ${roleTargetEntity.id}: role ${roleTargetEntity.roleId} not found or has null universalIdentifier (workspace ${workspaceId})`,
);
return false;
}
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
roleTargetEntity.applicationId,
);
if (!isDefined(applicationUniversalIdentifier)) {
this.logger.warn(
`Skipping orphaned roleTarget ${roleTargetEntity.id}: application ${roleTargetEntity.applicationId} not found or has null universalIdentifier (workspace ${workspaceId})`,
);
return false;
}
return true;
});
const flatRoleTargetMaps = createEmptyFlatEntityMaps();
for (const roleTargetEntity of roleTargets) {
for (const roleTargetEntity of validRoleTargets) {
const flatRoleTarget = fromRoleTargetEntityToFlatRoleTarget({
entity: roleTargetEntity,
applicationIdToUniversalIdentifierMap,
@@ -135,12 +135,12 @@ export class WorkspaceMigrationRunnerService {
if (invalidationFailures.length > 0) {
invalidationFailures.forEach((err) =>
this.logger.error(
`Failed to invalidate a legacy cache ${err.reason}`,
`Failed to invalidate a legacy cache for workspace ${workspaceId} (keys: ${allFlatEntityMapsKeys.join(', ')}): ${err.reason}`,
'Runner',
),
);
throw new Error(
`Failed to invalidate ${invalidationFailures.length} cache operations`,
`Failed to invalidate ${invalidationFailures.length} cache operations for workspace ${workspaceId}`,
);
}