fix: Sync partial execution version of FE and BE, also allow enforcing a specific version (#12840)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Danny Martini 2025-02-03 14:17:31 +01:00 committed by GitHub
parent a65a9e631b
commit a15504329b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 193 additions and 62 deletions

View file

@ -44,6 +44,7 @@ export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
export { ManualRunQueryDto } from './workflows/manual-run-query.dto';
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';

View file

@ -0,0 +1,47 @@
import { ManualRunQueryDto } from '../manual-run-query.dto';
describe('ManualRunQueryDto', () => {
describe('Valid requests', () => {
test.each([
{ name: 'version number 1', partialExecutionVersion: '1' },
{ name: 'version number 2', partialExecutionVersion: '2' },
{ name: 'missing version' },
])('should validate $name', ({ partialExecutionVersion }) => {
const result = ManualRunQueryDto.safeParse({ partialExecutionVersion });
if (!result.success) {
return fail('expected validation to succeed');
}
expect(result.success).toBe(true);
expect(typeof result.data.partialExecutionVersion).toBe('number');
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid version 0',
partialExecutionVersion: '0',
expectedErrorPath: ['partialExecutionVersion'],
},
{
name: 'invalid type (boolean)',
partialExecutionVersion: true,
expectedErrorPath: ['partialExecutionVersion'],
},
{
name: 'invalid type (number)',
partialExecutionVersion: 1,
expectedErrorPath: ['partialExecutionVersion'],
},
])('should fail validation for $name', ({ partialExecutionVersion, expectedErrorPath }) => {
const result = ManualRunQueryDto.safeParse({ partialExecutionVersion });
if (result.success) {
return fail('expected validation to fail');
}
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
});
});
});

View file

@ -0,0 +1,9 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ManualRunQueryDto extends Z.class({
partialExecutionVersion: z
.enum(['1', '2'])
.default('1')
.transform((version) => Number.parseInt(version) as 1 | 2),
}) {}

View file

@ -178,4 +178,8 @@ export interface FrontendSettings {
};
betaFeatures: FrontendBetaFeatures[];
easyAIWorkflowOnboarded: boolean;
partialExecution: {
version: 1 | 2;
enforce: boolean;
};
}

View file

@ -0,0 +1,12 @@
import { Config, Env } from '../decorators';
@Config
export class PartialExecutionsConfig {
/** Partial execution logic version to use by default. */
@Env('N8N_PARTIAL_EXECUTION_VERSION_DEFAULT')
version: 1 | 2 = 1;
/** Set this to true to enforce using the default version. Users cannot use the other version then by setting a local storage key. */
@Env('N8N_PARTIAL_EXECUTION_ENFORCE_VERSION')
enforce: boolean = false;
}

View file

@ -14,6 +14,7 @@ import { LicenseConfig } from './configs/license.config';
import { LoggingConfig } from './configs/logging.config';
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
import { NodesConfig } from './configs/nodes.config';
import { PartialExecutionsConfig } from './configs/partial-executions.config';
import { PublicApiConfig } from './configs/public-api.config';
import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
@ -134,4 +135,7 @@ export class GlobalConfig {
@Nested
tags: TagsConfig;
@Nested
partialExecutions: PartialExecutionsConfig;
}

View file

@ -302,6 +302,10 @@ describe('GlobalConfig', () => {
tags: {
disabled: false,
},
partialExecutions: {
version: 1,
enforce: false,
},
};
it('should use all default values when no env variables are defined', () => {

View file

@ -370,13 +370,4 @@ export const schema = {
env: 'N8N_PROXY_HOPS',
doc: 'Number of reverse-proxies n8n is running behind',
},
featureFlags: {
partialExecutionVersionDefault: {
format: String,
default: '0',
env: 'PARTIAL_EXECUTION_VERSION_DEFAULT',
doc: 'Set this to 1 to enable the new partial execution logic by default.',
},
},
};

View file

@ -141,7 +141,7 @@ export class TestRunnerService {
pinData,
workflowData: { ...workflow, pinData },
userId: metadata.userId,
partialExecutionVersion: '1',
partialExecutionVersion: 2,
};
// Trigger the workflow under test with mocked data

View file

@ -105,7 +105,7 @@ export class ManualExecutionService {
// Execute only the nodes between start and destination nodes
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
if (data.partialExecutionVersion === '1') {
if (data.partialExecutionVersion === 2) {
return workflowExecute.runPartialWorkflow2(
workflow,
data.runData,

View file

@ -234,6 +234,7 @@ export class FrontendService {
},
betaFeatures: this.frontendConfig.betaFeatures,
easyAIWorkflowOnboarded: false,
partialExecution: this.globalConfig.partialExecutions,
};
}

View file

@ -97,7 +97,7 @@ export class WorkflowExecutionService {
}: WorkflowRequest.ManualRunPayload,
user: User,
pushRef?: string,
partialExecutionVersion?: string,
partialExecutionVersion: 1 | 2 = 1,
) {
const pinData = workflowData.pinData;
const pinnedTrigger = this.selectPinnedActivatorStarter(
@ -142,7 +142,7 @@ export class WorkflowExecutionService {
startNodes,
workflowData,
userId: user.id,
partialExecutionVersion: partialExecutionVersion ?? '0',
partialExecutionVersion,
dirtyNodeNames,
triggerToStartFrom,
};

View file

@ -55,12 +55,7 @@ export declare namespace WorkflowRequest {
type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>;
type ManualRun = AuthenticatedRequest<
{ workflowId: string },
{},
ManualRunPayload,
{ partialExecutionVersion?: string }
>;
type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>;
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;

View file

@ -1,4 +1,4 @@
import { ImportWorkflowFromUrlDto } from '@n8n/api-types';
import { ImportWorkflowFromUrlDto, ManualRunQueryDto } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type FindOptionsRelations } from '@n8n/typeorm';
@ -9,7 +9,6 @@ import { ApplicationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { z } from 'zod';
import config from '@/config';
import type { Project } from '@/databases/entities/project';
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
import { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -367,7 +366,11 @@ export class WorkflowsController {
@Post('/:workflowId/run')
@ProjectScope('workflow:execute')
async runManually(req: WorkflowRequest.ManualRun) {
async runManually(
req: WorkflowRequest.ManualRun,
_res: unknown,
@Query query: ManualRunQueryDto,
) {
if (!req.body.workflowData.id) {
throw new ApplicationError('You cannot execute a workflow without an ID', {
level: 'warning',
@ -395,9 +398,7 @@ export class WorkflowsController {
req.body,
req.user,
req.headers['push-ref'],
req.query.partialExecutionVersion === '-1'
? config.getEnv('featureFlags.partialExecutionVersionDefault')
: req.query.partialExecutionVersion,
query.partialExecutionVersion,
);
}

View file

@ -137,4 +137,8 @@ export const defaultSettings: FrontendSettings = {
},
betaFeatures: [],
easyAIWorkflowOnboarded: false,
partialExecution: {
version: 1,
enforce: false,
},
};

View file

@ -19,9 +19,8 @@ import { useUIStore } from '@/stores/ui.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useToast } from './useToast';
import { useI18n } from '@/composables/useI18n';
import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue';
import { captor, mock } from 'vitest-mock-extended';
import { useSettingsStore } from '@/stores/settings.store';
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({
@ -41,16 +40,6 @@ vi.mock('@/stores/workflows.store', () => ({
}),
}));
vi.mock('@vueuse/core', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const originalModule = await vi.importActual<typeof import('@vueuse/core')>('@vueuse/core');
return {
...originalModule, // Keep all original exports
useLocalStorage: vi.fn().mockReturnValue({ value: undefined }), // Mock useLocalStorage
};
});
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn().mockReturnValue({ track: vi.fn() }),
}));
@ -106,6 +95,7 @@ describe('useRunWorkflow({ router })', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let router: ReturnType<typeof useRouter>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
let settingsStore: ReturnType<typeof useSettingsStore>;
beforeAll(() => {
const pinia = createTestingPinia({ stubActions: false });
@ -115,6 +105,7 @@ describe('useRunWorkflow({ router })', () => {
rootStore = useRootStore();
uiStore = useUIStore();
workflowsStore = useWorkflowsStore();
settingsStore = useSettingsStore();
router = useRouter();
workflowHelpers = useWorkflowHelpers({ router });
@ -322,8 +313,8 @@ describe('useRunWorkflow({ router })', () => {
expect(result).toEqual(mockExecutionResponse);
});
it('should send dirty nodes for partial executions', async () => {
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1));
it('should send dirty nodes for partial executions v2', async () => {
vi.mocked(settingsStore).partialExecutionVersion = 2;
const composable = useRunWorkflow({ router });
const parentName = 'When clicking';
const executeName = 'Code';
@ -404,7 +395,7 @@ describe('useRunWorkflow({ router })', () => {
);
});
it('does not use the original run data if `PartialExecution.version` is set to 0', async () => {
it('does not use the original run data if `partialExecutionVersion` is set to 1', async () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };
const mockRunData = { nodeName: [] };
@ -413,7 +404,7 @@ describe('useRunWorkflow({ router })', () => {
const workflow = mock<Workflow>({ name: 'Test Workflow' });
workflow.getParentNodes.mockReturnValue([]);
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(0));
vi.mocked(settingsStore).partialExecutionVersion = 1;
vi.mocked(rootStore).pushConnectionActive = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
@ -435,7 +426,7 @@ describe('useRunWorkflow({ router })', () => {
});
});
it('retains the original run data if `PartialExecution.version` is set to 1', async () => {
it('retains the original run data if `partialExecutionVersion` is set to 2', async () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };
const mockRunData = { nodeName: [] };
@ -444,7 +435,7 @@ describe('useRunWorkflow({ router })', () => {
const workflow = mock<Workflow>({ name: 'Test Workflow' });
workflow.getParentNodes.mockReturnValue([]);
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1));
vi.mocked(settingsStore).partialExecutionVersion = 2;
vi.mocked(rootStore).pushConnectionActive = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
@ -464,7 +455,7 @@ describe('useRunWorkflow({ router })', () => {
expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } });
});
it("does not send run data if it's not a partial execution even if `PartialExecution.version` is set to 1", async () => {
it("does not send run data if it's not a partial execution even if `partialExecutionVersion` is set to 2", async () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };
const mockRunData = { nodeName: [] };
@ -473,7 +464,7 @@ describe('useRunWorkflow({ router })', () => {
const workflow = mock<Workflow>({ name: 'Test Workflow' });
workflow.getParentNodes.mockReturnValue([]);
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1));
vi.mocked(settingsStore).partialExecutionVersion = 2;
vi.mocked(rootStore).pushConnectionActive = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;

View file

@ -35,7 +35,7 @@ import { isEmpty } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { get } from 'lodash-es';
import { useExecutionsStore } from '@/stores/executions.store';
import { useLocalStorage } from '@vueuse/core';
import { useSettingsStore } from '@/stores/settings.store';
const getDirtyNodeNames = (
runData: IRunData,
@ -260,18 +260,18 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
return undefined;
}
// -1 means the backend chooses the default
// 0 is the old flow
// 1 is the new flow
const partialExecutionVersion = useLocalStorage('PartialExecution.version', -1);
// partial executions must have a destination node
const isPartialExecution = options.destinationNode !== undefined;
const settingsStore = useSettingsStore();
const version = settingsStore.partialExecutionVersion;
const startRunData: IStartRunData = {
workflowData,
// With the new partial execution version the backend decides what run
// data to use and what to ignore.
runData: !isPartialExecution
? // if it's a full execution we don't want to send any run data
undefined
: partialExecutionVersion.value === 1
: version === 2
? // With the new partial execution version the backend decides
//what run data to use and what to ignore.
(runData ?? undefined)

View file

@ -2,6 +2,8 @@ import type { FrontendSettings } from '@n8n/api-types';
import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { useSettingsStore } from './settings.store';
import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue';
const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn(),
@ -54,6 +56,16 @@ vi.mock('@/stores/versions.store', () => ({
})),
}));
vi.mock('@vueuse/core', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const originalModule = await vi.importActual<typeof import('@vueuse/core')>('@vueuse/core');
return {
...originalModule,
useLocalStorage: vi.fn().mockReturnValue({ value: undefined }),
};
});
const mockSettings = mock<FrontendSettings>({
authCookie: { secure: true },
});
@ -99,4 +111,47 @@ describe('settings.store', () => {
expect(sessionStarted).not.toHaveBeenCalled();
});
});
describe('partialExecutionVersion', () => {
it.each([
{
name: 'pick the default',
default: 1 as const,
enforce: false,
userVersion: -1,
result: 1,
},
{
name: "pick the user' choice",
default: 1 as const,
enforce: false,
userVersion: 2,
result: 2,
},
{
name: 'enforce the default',
default: 1 as const,
enforce: true,
userVersion: 2,
result: 1,
},
{
name: 'enforce the default',
default: 2 as const,
enforce: true,
userVersion: 1,
result: 2,
},
])('%name', async ({ default: defaultVersion, userVersion, enforce, result }) => {
const settingsStore = useSettingsStore();
settingsStore.settings.partialExecution = {
version: defaultVersion,
enforce,
};
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(userVersion));
expect(settingsStore.partialExecutionVersion).toBe(result);
});
});
});

View file

@ -19,6 +19,7 @@ import { useVersionsStore } from './versions.store';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { useToast } from '@/composables/useToast';
import { i18n } from '@/plugins/i18n';
import { useLocalStorage } from '@vueuse/core';
export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const initialized = ref(false);
@ -98,6 +99,22 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isCloudDeployment = computed(() => settings.value.deployment?.type === 'cloud');
const partialExecutionVersion = computed(() => {
const defaultVersion = settings.value.partialExecution?.version ?? 1;
const enforceVersion = settings.value.partialExecution?.enforce ?? false;
// -1 means we pick the defaultVersion
// 1 is the old flow
// 2 is the new flow
const userVersion = useLocalStorage('PartialExecution.version', -1).value;
const version = enforceVersion
? defaultVersion
: userVersion === -1
? defaultVersion
: userVersion;
return version;
});
const isAiCreditsEnabled = computed(() => settings.value.aiCredits?.enabled);
const aiCreditsQuota = computed(() => settings.value.aiCredits?.credits);
@ -430,5 +447,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
getSettings,
setSettings,
initialize,
partialExecutionVersion,
};
});

View file

@ -79,7 +79,6 @@ import { computed, ref } from 'vue';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectSharingData } from '@/types/projects.types';
import type { PushPayload } from '@n8n/api-types';
import { useLocalStorage } from '@vueuse/core';
import { useTelemetry } from '@/composables/useTelemetry';
import { TelemetryHelpers } from 'n8n-workflow';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
@ -125,10 +124,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const nodeHelpers = useNodeHelpers();
const usersStore = useUsersStore();
// -1 means the backend chooses the default
// 0 is the old flow
// 1 is the new flow
const partialExecutionVersion = useLocalStorage('PartialExecution.version', -1);
const version = settingsStore.partialExecutionVersion;
const workflow = ref<IWorkflowDb>(createEmptyWorkflow());
const usedCredentials = ref<Record<string, IUsedCredential>>({});
@ -1474,7 +1470,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return await makeRestApiRequest(
rootStore.restApiContext,
'POST',
`/workflows/${startRunData.workflowData.id}/run?partialExecutionVersion=${partialExecutionVersion.value}`,
`/workflows/${startRunData.workflowData.id}/run?partialExecutionVersion=${version}`,
startRunData as unknown as IDataObject,
);
} catch (error) {

View file

@ -2293,12 +2293,10 @@ export interface IWorkflowExecutionDataProcess {
/**
* Defines which version of the partial execution flow is used.
* Possible values are:
* 0 - use the old flow
* 1 - use the new flow
* -1 - the backend chooses which flow based on the environment variable
* PARTIAL_EXECUTION_VERSION_DEFAULT
* 1 - use the old flow
* 2 - use the new flow
*/
partialExecutionVersion?: string;
partialExecutionVersion?: 1 | 2;
dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;