diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 9429468078f..f5df60e6608 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -53,7 +53,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.execution_annotation_tags](public.execution_annotation_tags.md) | 2 | | BASE TABLE | | [public.execution_annotations](public.execution_annotations.md) | 6 | | BASE TABLE | | [public.execution_data](public.execution_data.md) | 4 | | BASE TABLE | -| [public.execution_entity](public.execution_entity.md) | 17 | | BASE TABLE | +| [public.execution_entity](public.execution_entity.md) | 18 | | BASE TABLE | | [public.execution_metadata](public.execution_metadata.md) | 4 | | BASE TABLE | | [public.folder](public.folder.md) | 6 | | BASE TABLE | | [public.folder_tag](public.folder_tag.md) | 2 | | BASE TABLE | @@ -732,6 +732,7 @@ erDiagram varchar_36_ workflowVersionId } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.chat_hub_messages.md b/docs/generated/postgres-schema/public.chat_hub_messages.md index a102b84125f..9749ae06860 100644 --- a/docs/generated/postgres-schema/public.chat_hub_messages.md +++ b/docs/generated/postgres-schema/public.chat_hub_messages.md @@ -98,6 +98,7 @@ erDiagram timestamp_3__with_time_zone updatedAt } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.execution_annotations.md b/docs/generated/postgres-schema/public.execution_annotations.md index 9493478e0ad..a2d96c1ba70 100644 --- a/docs/generated/postgres-schema/public.execution_annotations.md +++ b/docs/generated/postgres-schema/public.execution_annotations.md @@ -46,6 +46,7 @@ erDiagram varchar_6_ vote } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.execution_data.md b/docs/generated/postgres-schema/public.execution_data.md index b85c68a34c2..fcfa9df071b 100644 --- a/docs/generated/postgres-schema/public.execution_data.md +++ b/docs/generated/postgres-schema/public.execution_data.md @@ -39,6 +39,7 @@ erDiagram varchar_36_ workflowVersionId } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.execution_entity.md b/docs/generated/postgres-schema/public.execution_entity.md index 7fe7276c0ab..20186a87a40 100644 --- a/docs/generated/postgres-schema/public.execution_entity.md +++ b/docs/generated/postgres-schema/public.execution_entity.md @@ -4,6 +4,7 @@ | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| binaryDataSizeBytes | bigint | 0 | false | | | Byte size of binary data offloaded to separate storage (db/fs/S3), deduplicated by blob; excludes inline binary counted in jsonSizeBytes. 0 means unknown. | | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | | deduplicationKey | varchar(255) | | true | | | | | deletedAt | timestamp(3) with time zone | | true | | | | @@ -26,6 +27,7 @@ | Name | Type | Definition | | ---- | ---- | ---------- | +| execution_entity_binaryDataSizeBytes_not_null | n | NOT NULL "binaryDataSizeBytes" | | execution_entity_createdAt_not_null | n | NOT NULL "createdAt" | | execution_entity_finished_not_null | n | NOT NULL finished | | execution_entity_id_not_null | n | NOT NULL id | @@ -63,6 +65,7 @@ erDiagram "public.execution_entity" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.execution_metadata.md b/docs/generated/postgres-schema/public.execution_metadata.md index 900382f2017..304a3ab040c 100644 --- a/docs/generated/postgres-schema/public.execution_metadata.md +++ b/docs/generated/postgres-schema/public.execution_metadata.md @@ -41,6 +41,7 @@ erDiagram text value } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.test_case_execution.md b/docs/generated/postgres-schema/public.test_case_execution.md index 7881cfa1bf0..aa566a5a612 100644 --- a/docs/generated/postgres-schema/public.test_case_execution.md +++ b/docs/generated/postgres-schema/public.test_case_execution.md @@ -64,6 +64,7 @@ erDiagram timestamp_3__with_time_zone updatedAt } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/postgres-schema/public.workflow_entity.md b/docs/generated/postgres-schema/public.workflow_entity.md index 2cc74b0ba5f..d4669e00a45 100644 --- a/docs/generated/postgres-schema/public.workflow_entity.md +++ b/docs/generated/postgres-schema/public.workflow_entity.md @@ -186,6 +186,7 @@ erDiagram varchar_36_ workflowId FK } "public.execution_entity" { + bigint binaryDataSizeBytes timestamp_3__with_time_zone createdAt varchar_255_ deduplicationKey timestamp_3__with_time_zone deletedAt diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 7c61c69faed..49775702016 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -53,7 +53,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [execution_annotation_tags](execution_annotation_tags.md) | 2 | | table | | [execution_annotations](execution_annotations.md) | 6 | | table | | [execution_data](execution_data.md) | 4 | | table | -| [execution_entity](execution_entity.md) | 17 | | table | +| [execution_entity](execution_entity.md) | 18 | | table | | [execution_metadata](execution_metadata.md) | 4 | | table | | [folder](folder.md) | 6 | | table | | [folder_tag](folder_tag.md) | 2 | | table | @@ -720,6 +720,7 @@ erDiagram VARCHAR_36_ workflowVersionId } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/chat_hub_messages.md b/docs/generated/sqlite-schema/chat_hub_messages.md index a7c48c7c1b4..9afa5026871 100644 --- a/docs/generated/sqlite-schema/chat_hub_messages.md +++ b/docs/generated/sqlite-schema/chat_hub_messages.md @@ -102,6 +102,7 @@ erDiagram datetime_3_ updatedAt } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/execution_annotations.md b/docs/generated/sqlite-schema/execution_annotations.md index 395cbf761bd..eb96181e32d 100644 --- a/docs/generated/sqlite-schema/execution_annotations.md +++ b/docs/generated/sqlite-schema/execution_annotations.md @@ -52,6 +52,7 @@ erDiagram varchar_6_ vote } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/execution_data.md b/docs/generated/sqlite-schema/execution_data.md index fdd9f224105..6e29cde6a05 100644 --- a/docs/generated/sqlite-schema/execution_data.md +++ b/docs/generated/sqlite-schema/execution_data.md @@ -53,6 +53,7 @@ erDiagram VARCHAR_36_ workflowVersionId } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/execution_entity.md b/docs/generated/sqlite-schema/execution_entity.md index 82c88c95125..f1a2abf0d0a 100644 --- a/docs/generated/sqlite-schema/execution_entity.md +++ b/docs/generated/sqlite-schema/execution_entity.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "workflowId" varchar(36) NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime, "stoppedAt" datetime, "waitTill" datetime, "status" varchar NOT NULL, "deletedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "tracingContext" text, "deduplicationKey" varchar(255), "jsonSizeBytes" BIGINT NOT NULL DEFAULT 0, "workflowVersionId" VARCHAR(36) DEFAULT NULL, CONSTRAINT "FK_c4d999a5e90784e8caccf5589de" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) +CREATE TABLE "execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "workflowId" varchar(36) NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime, "stoppedAt" datetime, "waitTill" datetime, "status" varchar NOT NULL, "deletedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "tracingContext" text, "deduplicationKey" varchar(255), "jsonSizeBytes" BIGINT NOT NULL DEFAULT 0, "workflowVersionId" VARCHAR(36) DEFAULT NULL, "binaryDataSizeBytes" BIGINT NOT NULL DEFAULT 0, CONSTRAINT "FK_c4d999a5e90784e8caccf5589de" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -15,6 +15,7 @@ CREATE TABLE "execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL | Name | Type | Default | Nullable | Children | Parents | Comment | | ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| binaryDataSizeBytes | BIGINT | 0 | false | | | | | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | | deduplicationKey | varchar(255) | | true | | | | | deletedAt | datetime(3) | | true | | | | @@ -63,6 +64,7 @@ erDiagram "execution_entity" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/execution_metadata.md b/docs/generated/sqlite-schema/execution_metadata.md index 429a85f5d59..39838cb6122 100644 --- a/docs/generated/sqlite-schema/execution_metadata.md +++ b/docs/generated/sqlite-schema/execution_metadata.md @@ -47,6 +47,7 @@ erDiagram TEXT value } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/test_case_execution.md b/docs/generated/sqlite-schema/test_case_execution.md index 45e3d8d7c22..22bea4de0ed 100644 --- a/docs/generated/sqlite-schema/test_case_execution.md +++ b/docs/generated/sqlite-schema/test_case_execution.md @@ -79,6 +79,7 @@ erDiagram datetime_3_ updatedAt } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/docs/generated/sqlite-schema/workflow_entity.md b/docs/generated/sqlite-schema/workflow_entity.md index 1ab4c9a0a1b..e9702532b6e 100644 --- a/docs/generated/sqlite-schema/workflow_entity.md +++ b/docs/generated/sqlite-schema/workflow_entity.md @@ -185,6 +185,7 @@ erDiagram varchar_36_ workflowId FK } "execution_entity" { + BIGINT binaryDataSizeBytes datetime_3_ createdAt varchar_255_ deduplicationKey datetime_3_ deletedAt diff --git a/packages/@n8n/db/src/entities/execution-entity.ts b/packages/@n8n/db/src/entities/execution-entity.ts index a8386176529..5b95c80a7cc 100644 --- a/packages/@n8n/db/src/entities/execution-entity.ts +++ b/packages/@n8n/db/src/entities/execution-entity.ts @@ -110,6 +110,15 @@ export class ExecutionEntity { @Column({ type: 'bigint', default: 0, transformer: bigintStringToNumber }) jsonSizeBytes: number; + /** + * Size in bytes of the binary data offloaded to separate storage (db/fs/S3), + * deduplicated by stored blob. Excludes inline binary from legacy in-memory + * executions, which lives in the bundle counted by {@link jsonSizeBytes}, so the + * two are additive. `0` means unknown. + */ + @Column({ type: 'bigint', default: 0, transformer: bigintStringToNumber }) + binaryDataSizeBytes: number; + /** * Version id of the workflow this execution ran, denormalized from the data * bundle so it can be queried without loading the bundle. `null` when the diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 6eb4f83fc2b..e4923d2d2d4 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -68,6 +68,7 @@ export interface IExecutionBase { tracingContext?: { traceparent: string; tracestate?: string } | null; deduplicationKey?: string | null; // see `ExecutionEntity.deduplicationKey` jsonSizeBytes?: number; // see `ExecutionEntity.jsonSizeBytes` + binaryDataSizeBytes?: number; // see `ExecutionEntity.binaryDataSizeBytes` workflowVersionId?: string | null; // see `ExecutionEntity.workflowVersionId` } @@ -168,7 +169,13 @@ export interface WorkflowWithSharingsMetaDataAndCredentials extends Omit; // Data in regular format with references diff --git a/packages/@n8n/db/src/migrations/common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity.ts b/packages/@n8n/db/src/migrations/common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity.ts new file mode 100644 index 00000000000..7433b5c52e6 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity.ts @@ -0,0 +1,28 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddBinaryDataSizeBytesToExecutionEntity1784000000033 implements ReversibleMigration { + async up({ escape, runQuery, isPostgres }: MigrationContext) { + const tableName = escape.tableName('execution_entity'); + const binaryDataSizeBytes = escape.columnName('binaryDataSizeBytes'); + + // Not using addColumn DSL to avoid recreating this large table in SQLite. + // See: https://github.com/n8n-io/n8n/blob/05c554dad53397f735003917f29eac5a5d62bdb4/.claude/plugins/n8n/skills/db-migrations/SKILL.md#sqlite-table-recreation-risk + await runQuery( + `ALTER TABLE ${tableName} ADD COLUMN ${binaryDataSizeBytes} BIGINT NOT NULL DEFAULT 0`, + ); + + // SQLite has no column comments, so annotate only on Postgres. + if (isPostgres) { + await runQuery( + `COMMENT ON COLUMN ${tableName}.${binaryDataSizeBytes} IS 'Byte size of binary data offloaded to separate storage (db/fs/S3), deduplicated by blob; excludes inline binary counted in jsonSizeBytes. 0 means unknown.'`, + ); + } + } + + async down({ escape, runQuery }: MigrationContext) { + const tableName = escape.tableName('execution_entity'); + const binaryDataSizeBytes = escape.columnName('binaryDataSizeBytes'); + + await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${binaryDataSizeBytes}`); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 357c7577747..b0e37513a5c 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -206,6 +206,7 @@ import { AddScopeColumnToOAuthTables1784000000026 } from '../common/178400000002 import { CreateWorkflowPublicationOutboxTable1784000000027 } from '../common/1784000000027-CreateWorkflowPublicationOutboxTable'; import { AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity1784000000029 } from '../common/1784000000029-AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity'; import { CreateAgentChatSubscriptions1784000000030 } from '../common/1784000000030-CreateAgentChatSubscriptions'; +import { AddBinaryDataSizeBytesToExecutionEntity1784000000033 } from '../common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -417,4 +418,5 @@ export const postgresMigrations: Migration[] = [ AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity1784000000029, CreateAgentChatSubscriptions1784000000030, AddExecutionEntityWorkflowStatusIndex1784000000031, + AddBinaryDataSizeBytesToExecutionEntity1784000000033, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 0be4817ac7d..409b4cdb99f 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -199,6 +199,7 @@ import type { Migration } from '../migration-types'; import { CreateAgentTaskDefinitionTable1784000000021 } from './1784000000021-CreateAgentTaskDefinitionTable'; import { MigrateRedactionEnforcementToFloor1784000000025 } from '../common/1784000000025-MigrateRedactionEnforcementToFloor'; import { AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity1784000000029 } from '../common/1784000000029-AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity'; +import { AddBinaryDataSizeBytesToExecutionEntity1784000000033 } from '../common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -401,6 +402,7 @@ const sqliteMigrations: Migration[] = [ AddProjectIdToInstanceAiThread1784000000028, AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity1784000000029, CreateAgentChatSubscriptions1784000000030, + AddBinaryDataSizeBytesToExecutionEntity1784000000033, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 815515079f3..af4e8d06d51 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -420,6 +420,7 @@ export class ExecutionRepository extends Repository { startedAt, // must never change customData, jsonSizeBytes, // computed by ExecutionPersistence on write; never set from a caller here + binaryDataSizeBytes, // computed by ExecutionPersistence on write; never set from a caller here ...executionInformation } = execution; @@ -745,6 +746,7 @@ export class ExecutionRepository extends Repository { workflowId: true, workflowVersionId: true, jsonSizeBytes: true, + binaryDataSizeBytes: true, mode: true, retryOf: true, status: true, @@ -830,6 +832,7 @@ export class ExecutionRepository extends Repository { stoppedAt?: Date | string; waitTill?: Date | string | null; jsonSizeBytes?: number | string; + binaryDataSizeBytes?: number | string; }): ExecutionSummary { execution.id = execution.id.toString(); @@ -838,6 +841,11 @@ export class ExecutionRepository extends Repository { execution.jsonSizeBytes = Number(execution.jsonSizeBytes); } + if (typeof execution.binaryDataSizeBytes === 'string') { + // Raw query bypasses the entity transformer, so Postgres hands bigint back as a string. + execution.binaryDataSizeBytes = Number(execution.binaryDataSizeBytes); + } + const normalizeDateString = (date: string) => { if (date.includes(' ')) return date.replace(' ', 'T') + 'Z'; return date; diff --git a/packages/cli/src/executions/__tests__/execution-persistence.integration.test.ts b/packages/cli/src/executions/__tests__/execution-persistence.integration.test.ts index 7c498731375..5e926f3f4a1 100644 --- a/packages/cli/src/executions/__tests__/execution-persistence.integration.test.ts +++ b/packages/cli/src/executions/__tests__/execution-persistence.integration.test.ts @@ -148,5 +148,50 @@ describe('ExecutionPersistence', () => { expect(execution?.jsonSizeBytes).toBe(133); expect(execution?.workflowVersionId).toBe('v-roundtrip-456'); }); + + it('persists and reads back binaryDataSizeBytes from offloaded binary in the run data', async () => { + const executionPersistence = Container.get(ExecutionPersistence); + const workflow = await createWorkflow({ settings: { executionOrder: 'v1' } }); + + const data = createEmptyRunExecutionData(); + data.resultData.runData = { + Node: [ + { + data: { + main: [ + [ + { + json: {}, + binary: { + file: { + data: '', + mimeType: 'application/octet-stream', + id: 'filesystem-v2:abc', + bytes: 100, + }, + }, + }, + ], + ], + }, + }, + ], + } as unknown as typeof data.resultData.runData; + + const executionId = await executionPersistence.create({ + workflowId: workflow.id, + data, + workflowData: workflow, + mode: 'manual', + status: 'new', + finished: false, + }); + + const execution = await executionPersistence.findSingleExecution(executionId, { + includeData: true, + }); + + expect(execution?.binaryDataSizeBytes).toBe(100); + }); }); }); diff --git a/packages/cli/src/executions/__tests__/execution-persistence.test.ts b/packages/cli/src/executions/__tests__/execution-persistence.test.ts index 362973bfd07..1443add6f02 100644 --- a/packages/cli/src/executions/__tests__/execution-persistence.test.ts +++ b/packages/cli/src/executions/__tests__/execution-persistence.test.ts @@ -12,7 +12,7 @@ import { import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import type { BinaryDataService, ErrorReporter, StorageConfig } from 'n8n-core'; -import type { IWorkflowBase } from 'n8n-workflow'; +import type { IBinaryData, IRunExecutionData, IWorkflowBase } from 'n8n-workflow'; import { createEmptyRunExecutionData, UnexpectedError } from 'n8n-workflow'; import { DuplicateExecutionError } from '@/errors/duplicate-execution.error'; @@ -51,6 +51,17 @@ describe('ExecutionPersistence', () => { const runData = createEmptyRunExecutionData(); + /** Build run data carrying the given binary maps on one node's main output items. */ + const runDataWithBinary = ( + binaryMaps: Array>>, + ): IRunExecutionData => { + const data = createEmptyRunExecutionData(); + data.resultData.runData = { + Node: [{ data: { main: [binaryMaps.map((b) => ({ json: {}, binary: b }))] } }], + } as unknown as IRunExecutionData['resultData']['runData']; + return data; + }; + const createMockTransaction = () => { const mockTx = mock(); mockTx.insert.mockResolvedValue({ @@ -136,7 +147,7 @@ describe('ExecutionPersistence', () => { expect(mockTx.update).toHaveBeenCalledWith( ExecutionEntity, { id: 'exec-1' }, - { jsonSizeBytes: 4321 }, + { jsonSizeBytes: 4321, binaryDataSizeBytes: 0 }, ); expect(eventService.emit).toHaveBeenCalledWith( 'execution-data-write', @@ -144,6 +155,27 @@ describe('ExecutionPersistence', () => { ); }); + it('persists binaryDataSizeBytes: offloaded blobs deduped by id, inline binary excluded', async () => { + const mockTx = createMockTransaction(); + executionRepository.manager.transaction = createMockTx(mockTx); + dbStore.write.mockResolvedValue(4321); + + await executionPersistence.create({ + ...createPayload, + data: runDataWithBinary([ + { a: { id: 'fs:1', bytes: 100 }, b: { id: 'fs:2', bytes: 50 } }, + { a: { id: 'fs:1', bytes: 100 } }, // same blob referenced again — counted once + { c: { bytes: 999 } }, // inline (no id) — excluded, lives in jsonSizeBytes + ]), + }); + + expect(mockTx.update).toHaveBeenCalledWith( + ExecutionEntity, + { id: 'exec-1' }, + { jsonSizeBytes: 4321, binaryDataSizeBytes: 150 }, + ); + }); + it('records the workflow version id on the entity from the workflow snapshot', async () => { const mockTx = createMockTransaction(); executionRepository.manager.transaction = createMockTx(mockTx); @@ -785,7 +817,7 @@ describe('ExecutionPersistence', () => { expect(mockTx.update).toHaveBeenCalledWith( ExecutionEntity, { id: executionId }, - { jsonSizeBytes: 512 }, + { jsonSizeBytes: 512, binaryDataSizeBytes: 0 }, ); }); @@ -1009,7 +1041,7 @@ describe('ExecutionPersistence', () => { expect(mockTx.update).toHaveBeenCalledWith( ExecutionEntity, { id: executionId }, - { jsonSizeBytes: 2048 }, + { jsonSizeBytes: 2048, binaryDataSizeBytes: 0 }, ); expect(eventService.emit).toHaveBeenCalledWith( 'execution-data-write', @@ -1034,7 +1066,7 @@ describe('ExecutionPersistence', () => { expect(mockTx.update).toHaveBeenCalledWith( ExecutionEntity, { id: executionId }, - { jsonSizeBytes: 1536 }, + { jsonSizeBytes: 1536, binaryDataSizeBytes: 0 }, ); expect(eventService.emit).toHaveBeenCalledWith( 'execution-data-write', @@ -1059,7 +1091,7 @@ describe('ExecutionPersistence', () => { expect(mockTx.update).toHaveBeenCalledWith( ExecutionEntity, { id: executionId }, - { jsonSizeBytes: 1536 }, + { jsonSizeBytes: 1536, binaryDataSizeBytes: 0 }, ); expect(mockTx.update).not.toHaveBeenCalledWith( ExecutionEntity, @@ -1080,6 +1112,52 @@ describe('ExecutionPersistence', () => { ); }); }); + + describe('binaryDataSizeBytes tracking', () => { + it('persists the summed offloaded binary size when data is provided', async () => { + const executionPersistence = createPersistenceService('fs'); + mockEntity('fs'); + fsStore.read.mockResolvedValue(existingBundle); + fsStore.write.mockResolvedValue(2048); + + const mockTx = createMockTransaction(); + executionRepository.manager.transaction = createMockTx(mockTx); + + await executionPersistence.updateExistingExecution(executionId, { + data: runDataWithBinary([{ a: { id: 'fs:1', bytes: 200 } }]), + }); + + expect(mockTx.update).toHaveBeenCalledWith( + ExecutionEntity, + { id: executionId }, + { jsonSizeBytes: 2048, binaryDataSizeBytes: 200 }, + ); + }); + + it('leaves binaryDataSizeBytes untouched on a workflowData-only update', async () => { + const executionPersistence = createPersistenceService('fs'); + mockEntity('fs'); + fsStore.read.mockResolvedValue(existingBundle); + fsStore.write.mockResolvedValue(512); + + const mockTx = createMockTransaction(); + executionRepository.manager.transaction = createMockTx(mockTx); + + await executionPersistence.updateExistingExecution(executionId, { workflowData }); + + // data was not supplied, so only jsonSizeBytes is written; binary can't be derived here. + expect(mockTx.update).toHaveBeenCalledWith( + ExecutionEntity, + { id: executionId }, + { jsonSizeBytes: 512 }, + ); + expect(mockTx.update).not.toHaveBeenCalledWith( + ExecutionEntity, + expect.anything(), + expect.objectContaining({ binaryDataSizeBytes: expect.anything() }), + ); + }); + }); }); describe('findSingleExecution', () => { diff --git a/packages/cli/src/executions/__tests__/sum-binary-data-bytes.test.ts b/packages/cli/src/executions/__tests__/sum-binary-data-bytes.test.ts new file mode 100644 index 00000000000..e46d6b5697e --- /dev/null +++ b/packages/cli/src/executions/__tests__/sum-binary-data-bytes.test.ts @@ -0,0 +1,160 @@ +import type { + IBinaryData, + IExecuteData, + INodeExecutionData, + IRunExecutionData, + ITaskData, + ITaskDataConnections, +} from 'n8n-workflow'; +import { BINARY_IN_JSON_PROPERTY, createRunExecutionData } from 'n8n-workflow'; + +import { sumBinaryDataBytes } from '../sum-binary-data-bytes'; + +const binary = (over: Partial): IBinaryData => + ({ data: '', mimeType: 'application/octet-stream', ...over }) as IBinaryData; + +const itemWith = (binaryMap: Record): INodeExecutionData => ({ + json: {}, + binary: binaryMap, +}); + +const mainWith = (items: INodeExecutionData[]): ITaskDataConnections => ({ main: [items] }); + +/** Branded run data with one node run whose main output holds the given binary maps. */ +const runDataWith = ( + binaryMaps: Array>, + connection = 'main', +): IRunExecutionData => + createRunExecutionData({ + resultData: { + runData: { + Node: [{ data: { [connection]: [binaryMaps.map(itemWith)] } } as unknown as ITaskData], + }, + }, + }); + +describe('sumBinaryDataBytes', () => { + it('returns 0 when there is no binary data', () => { + expect(sumBinaryDataBytes(runDataWith([{}]))).toBe(0); + }); + + it('returns 0 for empty run data', () => { + expect(sumBinaryDataBytes(createRunExecutionData())).toBe(0); + }); + + it('returns 0 when data is undefined', () => { + expect(sumBinaryDataBytes(undefined)).toBe(0); + }); + + it('sums bytes across multiple items and binary keys', () => { + const data = runDataWith([ + { a: binary({ id: '1', bytes: 100 }), b: binary({ id: '2', bytes: 50 }) }, + { a: binary({ id: '3', bytes: 25 }) }, + ]); + expect(sumBinaryDataBytes(data)).toBe(175); + }); + + it('counts each unique stored blob once (dedupes by id)', () => { + const data = runDataWith([ + { a: binary({ id: 'shared', bytes: 100 }) }, + { b: binary({ id: 'shared', bytes: 100 }) }, + ]); + expect(sumBinaryDataBytes(data)).toBe(100); + }); + + it('ignores inline binary (no id) — it is counted by jsonSizeBytes instead', () => { + const data = runDataWith([{ a: binary({ bytes: 30 }) }, { b: binary({ bytes: 30 }) }]); + expect(sumBinaryDataBytes(data)).toBe(0); + }); + + it('treats missing bytes as 0', () => { + const data = runDataWith([{ a: binary({ id: '1' }), b: binary({ id: '2', bytes: 40 }) }]); + expect(sumBinaryDataBytes(data)).toBe(40); + }); + + it('counts binary on non-main connections', () => { + const data = runDataWith([{ a: binary({ id: '1', bytes: 70 }) }], 'ai_tool'); + expect(sumBinaryDataBytes(data)).toBe(70); + }); + + it('counts binary embedded in item json under _files', () => { + const data = createRunExecutionData({ + resultData: { + runData: { + Node: [ + { + data: { + main: [ + [{ json: { [BINARY_IN_JSON_PROPERTY]: { f: binary({ id: '1', bytes: 80 }) } } }], + ], + }, + } as unknown as ITaskData, + ], + }, + }, + }); + expect(sumBinaryDataBytes(data)).toBe(80); + }); + + it('counts binary in a node run inputOverride', () => { + const data = createRunExecutionData({ + resultData: { + runData: { + Node: [ + { + data: { main: [[]] }, + inputOverride: mainWith([itemWith({ a: binary({ id: '1', bytes: 60 }) })]), + } as unknown as ITaskData, + ], + }, + }, + }); + expect(sumBinaryDataBytes(data)).toBe(60); + }); + + it('counts binary still sitting in the node execution stack', () => { + const data = createRunExecutionData({ + executionData: { + nodeExecutionStack: [ + { + data: mainWith([itemWith({ a: binary({ id: '1', bytes: 90 }) })]), + } as unknown as IExecuteData, + ], + }, + }); + expect(sumBinaryDataBytes(data)).toBe(90); + }); + + it('counts binary in the waiting execution map', () => { + const data = createRunExecutionData({ + executionData: { + waitingExecution: { + Node: { 0: mainWith([itemWith({ a: binary({ id: '1', bytes: 110 }) })]) }, + }, + }, + }); + expect(sumBinaryDataBytes(data)).toBe(110); + }); + + it('dedupes the same blob across run data and the execution stack', () => { + const data = createRunExecutionData({ + resultData: { + runData: { + Node: [ + { + data: mainWith([itemWith({ a: binary({ id: 'shared', bytes: 100 }) })]), + } as unknown as ITaskData, + ], + }, + }, + executionData: { + nodeExecutionStack: [ + { + data: mainWith([itemWith({ a: binary({ id: 'shared', bytes: 100 }) })]), + } as unknown as IExecuteData, + ], + }, + }); + expect(sumBinaryDataBytes(data)).toBe(100); + }); +}); diff --git a/packages/cli/src/executions/execution-persistence.ts b/packages/cli/src/executions/execution-persistence.ts index 2e3a360feb8..00671868446 100644 --- a/packages/cli/src/executions/execution-persistence.ts +++ b/packages/cli/src/executions/execution-persistence.ts @@ -30,6 +30,7 @@ import type { ExecutionRef, WorkflowSnapshot, } from './execution-data/types'; +import { sumBinaryDataBytes } from './sum-binary-data-bytes'; import { DuplicateExecutionError } from '../errors/duplicate-execution.error'; import { EventService } from '../events/event.service'; @@ -102,7 +103,12 @@ export class ExecutionPersistence { }; return await store.write(ref, bundle, tx); }); - await tx.update(ExecutionEntity, { id: executionId }, { jsonSizeBytes }); + const binaryDataSizeBytes = sumBinaryDataBytes(rawData); + await tx.update( + ExecutionEntity, + { id: executionId }, + { jsonSizeBytes, binaryDataSizeBytes }, + ); return executionId; }); @@ -548,6 +554,7 @@ export class ExecutionPersistence { workflowData !== undefined && (workflowVersionId !== null || store === this.dbStore) ) { + const binaryDataSizeBytes = sumBinaryDataBytes(data); const jsonSizeBytes = await this.trackWrite(mode, async () => { const bundle: ExecutionDataPayload = { data: stringify(data), @@ -559,7 +566,12 @@ export class ExecutionPersistence { ? await this.dbStore.overwrite(ref, bundle, tx) : await store.write(ref, bundle, tx); }); - await tx.update(ExecutionEntity, { id: ref.executionId }, { jsonSizeBytes }); + + await tx.update( + ExecutionEntity, + { id: ref.executionId }, + { jsonSizeBytes, binaryDataSizeBytes }, + ); return true; } @@ -579,7 +591,14 @@ export class ExecutionPersistence { return await store.write(ref, bundle, tx); }); - await tx.update(ExecutionEntity, { id: ref.executionId }, { jsonSizeBytes }); + // Binary size is derived from the in-memory run data, so only recompute it when the + // caller supplied `data`. A workflowData-only update leaves the column untouched (and + // doesn't affect binary anyway), mirroring when `jsonSizeBytes` would have changed. + const sizeColumns = + data !== undefined + ? { jsonSizeBytes, binaryDataSizeBytes: sumBinaryDataBytes(data) } + : { jsonSizeBytes }; + await tx.update(ExecutionEntity, { id: ref.executionId }, sizeColumns); return true; }); @@ -597,8 +616,8 @@ export class ExecutionPersistence { * - **Immutable after creation**: `workflowVersionId`, `createdAt`, * `startedAt` — set once at insert time and never overwritten. * - **Not persisted on the entity**: `customData` — handled separately. - * - **Computed locally**: `jsonSizeBytes` — derived from the persisted bundle by - * the data store, never trusted from the caller. + * - **Computed locally**: `jsonSizeBytes` and `binaryDataSizeBytes` — derived from + * the persisted bundle / run data, never trusted from the caller. */ private pickUpdatableEntityColumns( execution: Partial, @@ -613,6 +632,7 @@ export class ExecutionPersistence { startedAt: _startedAt, customData: _customData, jsonSizeBytes: _jsonSizeBytes, + binaryDataSizeBytes: _binaryDataSizeBytes, ...updatableColumns } = execution; return updatableColumns; diff --git a/packages/cli/src/executions/sum-binary-data-bytes.ts b/packages/cli/src/executions/sum-binary-data-bytes.ts new file mode 100644 index 00000000000..47b8bb0f2f6 --- /dev/null +++ b/packages/cli/src/executions/sum-binary-data-bytes.ts @@ -0,0 +1,73 @@ +import type { + IBinaryData, + IBinaryKeyData, + IRunExecutionData, + ITaskDataConnections, +} from 'n8n-workflow'; +import { BINARY_IN_JSON_PROPERTY } from 'n8n-workflow'; + +/** + * Yield every binary ref in a set of connections, including binary nested in json under + * {@link BINARY_IN_JSON_PROPERTY}. + */ +function* iterateConnectionsBinary( + connections: ITaskDataConnections | undefined, +): Iterable { + for (const outputs of Object.values(connections ?? {})) { + for (const items of outputs ?? []) { + for (const item of items ?? []) { + yield* Object.values(item?.binary ?? {}); + + const jsonBinary = item?.json?.[BINARY_IN_JSON_PROPERTY]; + if (jsonBinary && typeof jsonBinary === 'object') { + yield* Object.values(jsonBinary as IBinaryKeyData); + } + } + } + } +} + +/** + * Yield every binary ref an execution holds: each node run's `data` and `inputOverride`, plus the + * in-flight `nodeExecutionStack` and `waitingExecution` (a paused execution holds binary only there, + * not yet in `runData`). + * + * `data` can be `undefined` at runtime (and in integration tests), so it's guarded throughout. + */ +function* iterateBinary(data: IRunExecutionData | undefined): Iterable { + for (const nodeRuns of Object.values(data?.resultData.runData ?? {})) { + for (const nodeRun of nodeRuns ?? []) { + yield* iterateConnectionsBinary(nodeRun?.data); + yield* iterateConnectionsBinary(nodeRun?.inputOverride); + } + } + + const executionData = data?.executionData; + for (const executeData of executionData?.nodeExecutionStack ?? []) { + yield* iterateConnectionsBinary(executeData?.data); + } + for (const runs of Object.values(executionData?.waitingExecution ?? {})) { + for (const connections of Object.values(runs ?? {})) { + yield* iterateConnectionsBinary(connections); + } + } +} + +/** + * Sum the bytes of binary an execution offloaded to separate storage (db/fs/S3) — the binary it + * holds outside the JSON bundle. + * + * Counts only binary with an `id` (the handle to a stored blob), deduped so each blob counts once. + * Inline binary has no id and is already in `jsonSizeBytes`, so skipping it keeps the two columns + * additive. Missing `bytes` counts as `0`, matching `jsonSizeBytes`' "0 means unknown". + */ +export function sumBinaryDataBytes(data: IRunExecutionData | undefined): number { + const bytesByBlobId = new Map(); + for (const binary of iterateBinary(data)) { + if (binary?.id) bytesByBlobId.set(binary.id, binary.bytes ?? 0); + } + + let total = 0; + for (const bytes of bytesByBlobId.values()) total += bytes; + return total; +} diff --git a/packages/cli/test/integration/execution.repository.test.ts b/packages/cli/test/integration/execution.repository.test.ts index 8a3f6f37156..c45a3bcf408 100644 --- a/packages/cli/test/integration/execution.repository.test.ts +++ b/packages/cli/test/integration/execution.repository.test.ts @@ -70,10 +70,10 @@ describe('UserRepository', () => { ]); }); - test('exposes `jsonSizeBytes` as a number and `workflowVersionId`', async () => { + test('exposes `jsonSizeBytes` and `binaryDataSizeBytes` as numbers and `workflowVersionId`', async () => { const workflow = await createWorkflow({}, owner); const execution = await createExecution( - { jsonSizeBytes: 4096, workflowVersionId: 'v-123' }, + { jsonSizeBytes: 4096, binaryDataSizeBytes: 2048, workflowVersionId: 'v-123' }, workflow, ); @@ -86,6 +86,7 @@ describe('UserRepository', () => { expect(summary.id).toBe(execution.id); expect(summary.jsonSizeBytes).toBe(4096); + expect(summary.binaryDataSizeBytes).toBe(2048); expect(summary.workflowVersionId).toBe('v-123'); }); }); diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index c1542b2443b..0a94cf1fc4c 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -99,6 +99,7 @@ describe('ExecutionService', () => { workflowName: expect.any(String), workflowVersionId: null, jsonSizeBytes: 0, + binaryDataSizeBytes: 0, annotation: { tags: expect.arrayContaining([]), vote: null, @@ -964,6 +965,7 @@ describe('ExecutionService', () => { workflowName: expect.any(String), workflowVersionId: null, jsonSizeBytes: 0, + binaryDataSizeBytes: 0, }; afterEach(async () => { diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 397b2428433..7c1caa3aba6 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -45,6 +45,7 @@ export async function createExecution( metadata, createdAt, jsonSizeBytes, + binaryDataSizeBytes, workflowVersionId, } = attributes; @@ -59,6 +60,7 @@ export async function createExecution( status: status ?? 'success', deletedAt, ...(jsonSizeBytes !== undefined && { jsonSizeBytes }), + ...(binaryDataSizeBytes !== undefined && { binaryDataSizeBytes }), ...(workflowVersionId !== undefined && { workflowVersionId }), }); diff --git a/packages/cli/test/migration/1784000000033-add-binary-data-size-bytes-to-execution-entity.test.ts b/packages/cli/test/migration/1784000000033-add-binary-data-size-bytes-to-execution-entity.test.ts new file mode 100644 index 00000000000..7af597a74e5 --- /dev/null +++ b/packages/cli/test/migration/1784000000033-add-binary-data-size-bytes-to-execution-entity.test.ts @@ -0,0 +1,123 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +const MIGRATION_NAME = 'AddBinaryDataSizeBytesToExecutionEntity1784000000033'; + +interface SqliteColumnInfo { + name: string; + notnull: number; + dflt_value: string | null; +} + +interface PgColumnInfo { + column_name: string; + is_nullable: string; + column_default: string | null; +} + +describe('AddBinaryDataSizeBytesToExecutionEntity Migration', () => { + let dataSource: DataSource; + + beforeAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + dataSource = Container.get(DataSource); + }); + + beforeEach(async () => { + const context = createTestMigrationContext(dataSource); + await context.queryRunner.clearDatabase(); + await context.queryRunner.release(); + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + async function getColumnMeta( + context: TestMigrationContext, + columnName: string, + ): Promise { + if (context.isSqlite) { + const rows = (await context.queryRunner.query( + `PRAGMA table_info(${context.escape.tableName('execution_entity')})`, + )) as SqliteColumnInfo[]; + return rows.find((r) => r.name === columnName); + } + const rows = (await context.queryRunner.query( + 'SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND column_name = $2', + [`${context.tablePrefix}execution_entity`, columnName], + )) as PgColumnInfo[]; + return rows[0]; + } + + function expectNotNullDefaultZero( + col: SqliteColumnInfo | PgColumnInfo | undefined, + context: TestMigrationContext, + ) { + expect(col).toBeDefined(); + if (context.isSqlite) { + expect((col as SqliteColumnInfo).notnull).toBe(1); + expect((col as SqliteColumnInfo).dflt_value).toContain('0'); + } else { + expect((col as PgColumnInfo).is_nullable).toBe('NO'); + expect((col as PgColumnInfo).column_default).toContain('0'); + } + } + + describe('up', () => { + it('should add binaryDataSizeBytes as NOT NULL default 0', async () => { + await runSingleMigration(MIGRATION_NAME); + const context = createTestMigrationContext(dataSource); + + expectNotNullDefaultZero(await getColumnMeta(context, 'binaryDataSizeBytes'), context); + + await context.queryRunner.release(); + }); + + it('should set a column comment on binaryDataSizeBytes (Postgres only)', async () => { + await runSingleMigration(MIGRATION_NAME); + const context = createTestMigrationContext(dataSource); + + // SQLite has no column comments; only assert on Postgres. + if (!context.isPostgres) { + await context.queryRunner.release(); + return; + } + + const rows = (await context.queryRunner.query( + `SELECT pgd.description + FROM pg_description pgd + JOIN pg_class c ON c.oid = pgd.objoid + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = pgd.objsubid + WHERE c.relname = $1 AND a.attname = $2`, + [`${context.tablePrefix}execution_entity`, 'binaryDataSizeBytes'], + )) as Array<{ description: string }>; + + expect(rows[0]?.description).toContain('offloaded'); + + await context.queryRunner.release(); + }); + }); + + describe('down', () => { + it('should remove the column', async () => { + await runSingleMigration(MIGRATION_NAME); + await undoLastSingleMigration(); + + const context = createTestMigrationContext(dataSource); + expect(await getColumnMeta(context, 'binaryDataSizeBytes')).toBeUndefined(); + await context.queryRunner.release(); + }); + }); +}); diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 46e6c6391e4..f8d32dc1a10 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -3657,6 +3657,7 @@ export interface ExecutionSummary { workflowName?: string; workflowVersionId?: string | null; jsonSizeBytes?: number; + binaryDataSizeBytes?: number; status: ExecutionStatus; lastNodeExecuted?: string; executionError?: ExecutionError;