mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(core): Track binary data size on execution entity rows (no-changelog) (#32387)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
35b7fa7b99
commit
10856a4f4d
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,6 +102,7 @@ erDiagram
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"execution_entity" {
|
||||
BIGINT binaryDataSizeBytes
|
||||
datetime_3_ createdAt
|
||||
varchar_255_ deduplicationKey
|
||||
datetime_3_ deletedAt
|
||||
|
||||
@@ -52,6 +52,7 @@ erDiagram
|
||||
varchar_6_ vote
|
||||
}
|
||||
"execution_entity" {
|
||||
BIGINT binaryDataSizeBytes
|
||||
datetime_3_ createdAt
|
||||
varchar_255_ deduplicationKey
|
||||
datetime_3_ deletedAt
|
||||
|
||||
@@ -53,6 +53,7 @@ erDiagram
|
||||
VARCHAR_36_ workflowVersionId
|
||||
}
|
||||
"execution_entity" {
|
||||
BIGINT binaryDataSizeBytes
|
||||
datetime_3_ createdAt
|
||||
varchar_255_ deduplicationKey
|
||||
datetime_3_ deletedAt
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ erDiagram
|
||||
TEXT value
|
||||
}
|
||||
"execution_entity" {
|
||||
BIGINT binaryDataSizeBytes
|
||||
datetime_3_ createdAt
|
||||
varchar_255_ deduplicationKey
|
||||
datetime_3_ deletedAt
|
||||
|
||||
@@ -79,6 +79,7 @@ erDiagram
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"execution_entity" {
|
||||
BIGINT binaryDataSizeBytes
|
||||
datetime_3_ createdAt
|
||||
varchar_255_ deduplicationKey
|
||||
datetime_3_ deletedAt
|
||||
|
||||
@@ -185,6 +185,7 @@ erDiagram
|
||||
varchar_36_ workflowId FK
|
||||
}
|
||||
"execution_entity" {
|
||||
BIGINT binaryDataSizeBytes
|
||||
datetime_3_ createdAt
|
||||
varchar_255_ deduplicationKey
|
||||
datetime_3_ deletedAt
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Workflo
|
||||
/** Payload for creating an execution. */
|
||||
export type CreateExecutionPayload = Omit<
|
||||
IExecutionDb,
|
||||
'id' | 'createdAt' | 'startedAt' | 'storedAt' | 'jsonSizeBytes' | 'workflowVersionId'
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
| 'startedAt'
|
||||
| 'storedAt'
|
||||
| 'jsonSizeBytes'
|
||||
| 'binaryDataSizeBytes'
|
||||
| 'workflowVersionId'
|
||||
>;
|
||||
|
||||
// Data in regular format with references
|
||||
|
||||
+28
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -420,6 +420,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||
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<ExecutionEntity> {
|
||||
workflowId: true,
|
||||
workflowVersionId: true,
|
||||
jsonSizeBytes: true,
|
||||
binaryDataSizeBytes: true,
|
||||
mode: true,
|
||||
retryOf: true,
|
||||
status: true,
|
||||
@@ -830,6 +832,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||
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<ExecutionEntity> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, Partial<IBinaryData>>>,
|
||||
): 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<EntityManager>();
|
||||
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', () => {
|
||||
|
||||
@@ -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>): IBinaryData =>
|
||||
({ data: '', mimeType: 'application/octet-stream', ...over }) as IBinaryData;
|
||||
|
||||
const itemWith = (binaryMap: Record<string, IBinaryData>): 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<Record<string, IBinaryData>>,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<IExecutionResponse>,
|
||||
@@ -613,6 +632,7 @@ export class ExecutionPersistence {
|
||||
startedAt: _startedAt,
|
||||
customData: _customData,
|
||||
jsonSizeBytes: _jsonSizeBytes,
|
||||
binaryDataSizeBytes: _binaryDataSizeBytes,
|
||||
...updatableColumns
|
||||
} = execution;
|
||||
return updatableColumns;
|
||||
|
||||
@@ -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<IBinaryData> {
|
||||
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<IBinaryData> {
|
||||
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<string, number>();
|
||||
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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
+123
@@ -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<SqliteColumnInfo | PgColumnInfo | undefined> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3657,6 +3657,7 @@ export interface ExecutionSummary {
|
||||
workflowName?: string;
|
||||
workflowVersionId?: string | null;
|
||||
jsonSizeBytes?: number;
|
||||
binaryDataSizeBytes?: number;
|
||||
status: ExecutionStatus;
|
||||
lastNodeExecuted?: string;
|
||||
executionError?: ExecutionError;
|
||||
|
||||
Reference in New Issue
Block a user