improvement: limit app connection concurrent syncs

This commit is contained in:
Scott Wilson
2025-09-04 16:52:28 -07:00
parent 8aa270545d
commit fbb243b2a0
5 changed files with 104 additions and 39 deletions

View File

@@ -38,6 +38,7 @@ export const KeyStorePrefixes = {
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const,
AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const,
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => SecretScanningLock: (dataSourceId: string, resourceExternalId: string) =>
`secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const,

View File

@@ -12,7 +12,7 @@ type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>; type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>;
type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>; type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>;
const MAX_RETRIES = 5; const MAX_RETRIES = 10;
const BATCH_SIZE = 10; const BATCH_SIZE = 10;
const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => { const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => {

View File

@@ -38,7 +38,7 @@ type TAwsSecretsRecord = Record<string, SecretListEntry>;
type TAwsSecretValuesRecord = Record<string, SecretValueEntry>; type TAwsSecretValuesRecord = Record<string, SecretValueEntry>;
type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>; type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>;
const MAX_RETRIES = 5; const MAX_RETRIES = 10;
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => { const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => {

View File

@@ -81,7 +81,7 @@ type TSecretSyncQueueFactoryDep = {
| "invalidateSecretCacheByProjectId" | "invalidateSecretCacheByProjectId"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">; secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById" | "update">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
@@ -104,17 +104,15 @@ type SecretSyncActionJob = Job<
TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO
>; >;
const JITTER_MS = 10 * 1000;
const REQUEUE_MS = 30 * 1000;
const REQUEUE_LIMIT = 30;
const CONNECTION_CONCURRENCY_LIMIT = 3;
const getRequeueDelay = (failureCount?: number) => { const getRequeueDelay = (failureCount?: number) => {
if (!failureCount) return 0; const jitter = Math.random() * JITTER_MS;
if (!failureCount) return jitter;
const baseDelay = 1000; return REQUEUE_MS + jitter;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5);
return jitter;
}; };
export const secretSyncQueueFactory = ({ export const secretSyncQueueFactory = ({
@@ -193,6 +191,46 @@ export const secretSyncQueueFactory = ({
folderCommitService folderCommitService
}); });
const $isConnectionConcurrencyLimitReached = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
if (!concurrencyCount) return false;
const count = Number.parseInt(concurrencyCount, 10);
if (Number.isNaN(count)) return false;
return count >= CONNECTION_CONCURRENCY_LIMIT;
};
const $incrementConnectionConcurrencyCount = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1;
await keyStore.setItemWithExpiry(
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
incrementedCount
);
};
const $decrementConnectionConcurrencyCount = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1);
await keyStore.setItemWithExpiry(
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
decrementedCount
);
};
const $getInfisicalSecrets = async ( const $getInfisicalSecrets = async (
secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, secretSync: TSecretSyncRaw | TSecretSyncWithCredentials,
includeImports = true includeImports = true
@@ -416,15 +454,11 @@ export const secretSyncQueueFactory = ({
return importedSecretMap; return importedSecretMap;
}; };
const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => { const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo } data: { syncId, auditLogInfo }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await enterpriseSyncCheck( await enterpriseSyncCheck(
licenseService, licenseService,
secretSync.destination as SecretSync, secretSync.destination as SecretSync,
@@ -566,15 +600,11 @@ export const secretSyncQueueFactory = ({
logger.info("SecretSync Sync Job with ID %s Completed", job.id); logger.info("SecretSync Sync Job with ID %s Completed", job.id);
}; };
const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => { const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo, importBehavior } data: { syncId, auditLogInfo, importBehavior }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await secretSyncDAL.updateById(syncId, { await secretSyncDAL.updateById(syncId, {
importStatus: SecretSyncStatus.Running importStatus: SecretSyncStatus.Running
}); });
@@ -683,15 +713,11 @@ export const secretSyncQueueFactory = ({
logger.info("SecretSync Import Job with ID %s Completed", job.id); logger.info("SecretSync Import Job with ID %s Completed", job.id);
}; };
const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo, deleteSyncOnComplete } data: { syncId, auditLogInfo, deleteSyncOnComplete }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await enterpriseSyncCheck( await enterpriseSyncCheck(
licenseService, licenseService,
secretSync.destination as SecretSync, secretSync.destination as SecretSync,
@@ -894,6 +920,17 @@ export const secretSyncQueueFactory = ({
const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true }); const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true });
await secretSyncDAL.update(
{
$in: {
id: secretSyncs.map((sync) => sync.id)
}
},
{
syncStatus: SecretSyncStatus.Pending
}
);
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id }))); await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id })));
}; };
@@ -904,7 +941,7 @@ export const secretSyncQueueFactory = ({
case QueueJobs.SecretSyncSyncSecrets: { case QueueJobs.SecretSyncSyncSecrets: {
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO; const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO;
if (failedToAcquireLockCount < 10) { if (failedToAcquireLockCount < REQUEUE_LIMIT) {
await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
return; return;
} }
@@ -974,6 +1011,26 @@ export const secretSyncQueueFactory = ({
| TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO
| TQueueSecretSyncRemoveSecretsByIdDTO; | TQueueSecretSyncRemoveSecretsByIdDTO;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
const { connectionId } = secretSync;
if (job.name === QueueJobs.SecretSyncSyncSecrets) {
const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId);
if (isConcurrentLimitReached) {
logger.info(
`SecretSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]`
);
await $handleAcquireLockFailure(job as SecretSyncActionJob);
return;
}
}
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>; let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try { try {
@@ -993,20 +1050,26 @@ export const secretSyncQueueFactory = ({
try { try {
switch (job.name) { switch (job.name) {
case QueueJobs.SecretSyncSyncSecrets: case QueueJobs.SecretSyncSyncSecrets: {
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO); await $incrementConnectionConcurrencyCount(connectionId);
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO, secretSync);
break; break;
}
case QueueJobs.SecretSyncImportSecrets: case QueueJobs.SecretSyncImportSecrets:
await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO); await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO, secretSync);
break; break;
case QueueJobs.SecretSyncRemoveSecrets: case QueueJobs.SecretSyncRemoveSecrets:
await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO); await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO, secretSync);
break; break;
default: default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled Secret Sync Job ${job.name}`); throw new Error(`Unhandled Secret Sync Job ${job.name}`);
} }
} finally { } finally {
if (job.name === QueueJobs.SecretSyncSyncSecrets) {
await $decrementConnectionConcurrencyCount(connectionId);
}
await lock.release(); await lock.release();
} }
}); });

View File

@@ -1,6 +1,7 @@
import { import {
faCheck, faCheck,
faExclamationTriangle, faExclamationTriangle,
faHourglass,
faRotate, faRotate,
IconDefinition IconDefinition
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -29,7 +30,11 @@ export const SecretSyncStatusBadge = ({ status }: Props) => {
text = "Synced"; text = "Synced";
icon = faCheck; icon = faCheck;
break; break;
case SecretSyncStatus.Pending: // no need to differentiate from user perspective case SecretSyncStatus.Pending:
variant = "primary";
text = "Queued";
icon = faHourglass;
break;
case SecretSyncStatus.Running: case SecretSyncStatus.Running:
default: default:
variant = "primary"; variant = "primary";
@@ -42,11 +47,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => {
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" variant={variant}> <Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" variant={variant}>
<FontAwesomeIcon <FontAwesomeIcon
icon={icon} icon={icon}
className={ className={[SecretSyncStatus.Running].includes(status) ? "animate-spin" : ""}
[SecretSyncStatus.Pending, SecretSyncStatus.Running].includes(status)
? "animate-spin"
: ""
}
/> />
<span>{text}</span> <span>{text}</span>
</Badge> </Badge>