fix(app-dev): serialize dev sync per workspace with a cache lock (#21250)

Split out of #21240. Stacked on #21249 (review/merge that first).

Concurrent `syncApplication` calls on the same workspace could
interleave their metadata migrations and leave metadata partially
applied. Wrap the manifest sync in a per-workspace cache lock
(`app-sync:<workspaceId>`), mirroring the install path. The rate-limit
throttle stays outside the lock.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2026-06-05 17:30:55 +02:00
committed by GitHub
parent a3d73740a1
commit 84ba7eb8dc
2 changed files with 17 additions and 0 deletions
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
@@ -17,6 +18,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
ApplicationModule,
ApplicationManifestModule,
ApplicationRegistrationModule,
CacheLockModule,
FeatureFlagModule,
SdkClientModule,
TokenModule,
@@ -33,6 +33,7 @@ import {
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@@ -52,6 +53,8 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
const APP_DEV_RATE_LIMIT_MAX = 30;
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@@ -71,6 +74,7 @@ export class ApplicationDevelopmentResolver {
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
private readonly throttlerService: ThrottlerService,
private readonly cacheLockService: CacheLockService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -137,6 +141,17 @@ export class ApplicationDevelopmentResolver {
workspaceId,
);
return this.cacheLockService.withLock(
() => this.applyManifestSync(manifest, workspaceId),
`app-sync:${workspaceId}`,
APP_SYNC_LOCK_OPTIONS,
);
}
private async applyManifestSync(
manifest: ApplicationInput['manifest'],
workspaceId: string,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
);