first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.kafka",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/kafka/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.kafka/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { SchemaRegistry } from '@kafkajs/confluent-schema-registry';
|
||||
import type { KafkaConfig, SASLOptions, TopicMessages } from 'kafkajs';
|
||||
import { CompressionTypes, Kafka as apacheKafka } from 'kafkajs';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IDataObject,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { generatePairedItemData } from '../../utils/utilities';
|
||||
|
||||
export class Kafka implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Kafka',
|
||||
name: 'kafka',
|
||||
icon: { light: 'file:kafka.svg', dark: 'file:kafka.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Sends messages to a Kafka topic',
|
||||
defaults: {
|
||||
name: 'Kafka',
|
||||
},
|
||||
usableAsTool: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'kafka',
|
||||
required: true,
|
||||
testedBy: 'kafkaConnectionTest',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Topic',
|
||||
name: 'topic',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'topic-name',
|
||||
description: 'Name of the queue of topic to publish to',
|
||||
},
|
||||
{
|
||||
displayName: 'Send Input Data',
|
||||
name: 'sendInputData',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to send the data the node receives as JSON to Kafka',
|
||||
},
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendInputData: [false],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The message to be sent',
|
||||
},
|
||||
{
|
||||
displayName: 'JSON Parameters',
|
||||
name: 'jsonParameters',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Use Schema Registry',
|
||||
name: 'useSchemaRegistry',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to use Confluent Schema Registry',
|
||||
},
|
||||
{
|
||||
displayName: 'Schema Registry URL',
|
||||
name: 'schemaRegistryUrl',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
useSchemaRegistry: [true],
|
||||
},
|
||||
},
|
||||
placeholder: 'https://schema-registry-domain:8081',
|
||||
default: '',
|
||||
description: 'URL of the schema registry',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Key',
|
||||
name: 'useKey',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to use a message key',
|
||||
},
|
||||
{
|
||||
displayName: 'Key',
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
useKey: [true],
|
||||
},
|
||||
},
|
||||
placeholder: '',
|
||||
default: '',
|
||||
description: 'The message key',
|
||||
},
|
||||
{
|
||||
displayName: 'Event Name',
|
||||
name: 'eventName',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
useSchemaRegistry: [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Namespace and Name of Schema in Schema Registry (namespace.name)',
|
||||
},
|
||||
{
|
||||
displayName: 'Headers',
|
||||
name: 'headersUi',
|
||||
placeholder: 'Add Header',
|
||||
type: 'fixedCollection',
|
||||
displayOptions: {
|
||||
show: {
|
||||
jsonParameters: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'headerValues',
|
||||
displayName: 'Header',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Key',
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Headers (JSON)',
|
||||
name: 'headerParametersJson',
|
||||
type: 'json',
|
||||
displayOptions: {
|
||||
show: {
|
||||
jsonParameters: [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Header parameters as JSON (flat object)',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Acks',
|
||||
name: 'acks',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether or not producer must wait for acknowledgement from all replicas',
|
||||
},
|
||||
{
|
||||
displayName: 'Compression',
|
||||
name: 'compression',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to send the data in a compressed format using the GZIP codec',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: 30000,
|
||||
description: 'The time to await a response in ms',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
async kafkaConnectionTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const credentials = credential.data as ICredentialDataDecryptedObject;
|
||||
try {
|
||||
const brokers = ((credentials.brokers as string) || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim());
|
||||
|
||||
const clientId = credentials.clientId as string;
|
||||
|
||||
const ssl = credentials.ssl as boolean;
|
||||
|
||||
const config: KafkaConfig = {
|
||||
clientId,
|
||||
brokers,
|
||||
ssl,
|
||||
};
|
||||
if (credentials.authentication === true) {
|
||||
if (!(credentials.username && credentials.password)) {
|
||||
throw new ApplicationError('Username and password are required for authentication', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
config.sasl = {
|
||||
username: credentials.username as string,
|
||||
password: credentials.password as string,
|
||||
mechanism: credentials.saslMechanism as string,
|
||||
} as SASLOptions;
|
||||
}
|
||||
|
||||
const kafka = new apacheKafka(config);
|
||||
|
||||
await kafka.admin().connect();
|
||||
await kafka.admin().disconnect();
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Authentication successful',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const itemData = generatePairedItemData(items.length);
|
||||
|
||||
const length = items.length;
|
||||
|
||||
const topicMessages: TopicMessages[] = [];
|
||||
|
||||
let responseData: IDataObject[];
|
||||
|
||||
try {
|
||||
const options = this.getNodeParameter('options', 0);
|
||||
const sendInputData = this.getNodeParameter('sendInputData', 0) as boolean;
|
||||
|
||||
const useSchemaRegistry = this.getNodeParameter('useSchemaRegistry', 0) as boolean;
|
||||
|
||||
const timeout = options.timeout as number;
|
||||
|
||||
let compression = CompressionTypes.None;
|
||||
|
||||
const acks = options.acks === true ? 1 : 0;
|
||||
|
||||
if (options.compression === true) {
|
||||
compression = CompressionTypes.GZIP;
|
||||
}
|
||||
|
||||
const credentials = await this.getCredentials('kafka');
|
||||
|
||||
const brokers = ((credentials.brokers as string) || '').split(',').map((item) => item.trim());
|
||||
|
||||
const clientId = credentials.clientId as string;
|
||||
|
||||
const ssl = credentials.ssl as boolean;
|
||||
|
||||
const config: KafkaConfig = {
|
||||
clientId,
|
||||
brokers,
|
||||
ssl,
|
||||
};
|
||||
|
||||
if (credentials.authentication === true) {
|
||||
if (!(credentials.username && credentials.password)) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Username and password are required for authentication',
|
||||
);
|
||||
}
|
||||
config.sasl = {
|
||||
username: credentials.username as string,
|
||||
password: credentials.password as string,
|
||||
mechanism: credentials.saslMechanism as string,
|
||||
} as SASLOptions;
|
||||
}
|
||||
|
||||
const kafka = new apacheKafka(config);
|
||||
|
||||
const producer = kafka.producer();
|
||||
|
||||
await producer.connect();
|
||||
|
||||
let message: string | Buffer;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (sendInputData) {
|
||||
message = JSON.stringify(items[i].json);
|
||||
} else {
|
||||
message = this.getNodeParameter('message', i) as string;
|
||||
}
|
||||
|
||||
if (useSchemaRegistry) {
|
||||
try {
|
||||
const schemaRegistryUrl = this.getNodeParameter('schemaRegistryUrl', 0) as string;
|
||||
const eventName = this.getNodeParameter('eventName', 0) as string;
|
||||
|
||||
const registry = new SchemaRegistry({ host: schemaRegistryUrl });
|
||||
const id = await registry.getLatestSchemaId(eventName);
|
||||
|
||||
message = await registry.encode(id, JSON.parse(message));
|
||||
} catch (exception) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Verify your Schema Registry configuration',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const topic = this.getNodeParameter('topic', i) as string;
|
||||
|
||||
const jsonParameters = this.getNodeParameter('jsonParameters', i);
|
||||
|
||||
const useKey = this.getNodeParameter('useKey', i) as boolean;
|
||||
|
||||
const key = useKey ? (this.getNodeParameter('key', i) as string) : null;
|
||||
|
||||
let headers;
|
||||
|
||||
if (jsonParameters) {
|
||||
headers = this.getNodeParameter('headerParametersJson', i) as string;
|
||||
try {
|
||||
headers = JSON.parse(headers);
|
||||
} catch (exception) {
|
||||
throw new NodeOperationError(this.getNode(), 'Headers must be a valid json');
|
||||
}
|
||||
} else {
|
||||
const values = (this.getNodeParameter('headersUi', i) as IDataObject)
|
||||
.headerValues as IDataObject[];
|
||||
headers = {};
|
||||
if (values !== undefined) {
|
||||
for (const value of values) {
|
||||
//@ts-ignore
|
||||
headers[value.key] = value.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topicMessages.push({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
value: message,
|
||||
headers,
|
||||
key,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
responseData = await producer.sendBatch({
|
||||
topicMessages,
|
||||
timeout,
|
||||
compression,
|
||||
acks,
|
||||
});
|
||||
|
||||
if (responseData.length === 0) {
|
||||
responseData.push({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
await producer.disconnect();
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData),
|
||||
{ itemData },
|
||||
);
|
||||
|
||||
return [executionData];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [[{ json: { error: error.message }, pairedItem: itemData }]];
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.kafkaTrigger",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Development"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/kafka/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.kafkatrigger/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import type { EachBatchPayload } from 'kafkajs';
|
||||
import { Kafka as apacheKafka } from 'kafkajs';
|
||||
import type {
|
||||
ITriggerFunctions,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ITriggerResponse,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ensureError,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
TriggerCloseError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
type KafkaTriggerOptions,
|
||||
connectEventListeners,
|
||||
disconnectEventListeners,
|
||||
setSchemaRegistry,
|
||||
configureMessageParser,
|
||||
createConfig,
|
||||
createConsumerConfig,
|
||||
configureDataEmitter,
|
||||
getAutoCommitSettings,
|
||||
runWithHeartbeat,
|
||||
} from './utils';
|
||||
|
||||
export class KafkaTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Kafka Trigger',
|
||||
name: 'kafkaTrigger',
|
||||
icon: { light: 'file:kafka.svg', dark: 'file:kafka.dark.svg' },
|
||||
group: ['trigger'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Consume messages from a Kafka topic',
|
||||
defaults: {
|
||||
name: 'Kafka Trigger',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'kafka',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Topic',
|
||||
name: 'topic',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'topic-name',
|
||||
description: 'Name of the queue of topic to consume from',
|
||||
},
|
||||
{
|
||||
displayName: 'Group ID',
|
||||
name: 'groupId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'n8n-kafka',
|
||||
description: 'ID of the consumer group',
|
||||
},
|
||||
{
|
||||
displayName: 'Resolve Offset',
|
||||
name: 'resolveOffset',
|
||||
type: 'options',
|
||||
default: 'onCompletion',
|
||||
description:
|
||||
'Select on which condition the offsets should be resolved. In the manual mode, when execution started by clicking on Execute Workflow or Execute Step button, offsets are always resolved immediately after message received.',
|
||||
options: [
|
||||
{
|
||||
name: 'On Execution Completion',
|
||||
value: 'onCompletion',
|
||||
description: 'Resolve offset after execution completion regardless of the status',
|
||||
},
|
||||
{
|
||||
name: 'On Execution Success',
|
||||
value: 'onSuccess',
|
||||
description: 'Resolve offset only if execution status equals success',
|
||||
},
|
||||
{
|
||||
name: 'On Allowed Execution Statuses',
|
||||
value: 'onStatus',
|
||||
description: 'Resolve offset only if execution status in the list of selected statuses',
|
||||
},
|
||||
{
|
||||
name: 'Immediately',
|
||||
value: 'immediately',
|
||||
description:
|
||||
'Resolve offset immediately after message received. This option is not recommended as it can cause messages loss.',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Allowed Statuses',
|
||||
name: 'allowedStatuses',
|
||||
type: 'multiOptions',
|
||||
default: ['success'],
|
||||
options: [
|
||||
{
|
||||
name: 'Canceled',
|
||||
value: 'canceled',
|
||||
},
|
||||
{
|
||||
name: 'Crashed',
|
||||
value: 'crashed',
|
||||
},
|
||||
{
|
||||
name: 'Error',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
name: 'New',
|
||||
value: 'new',
|
||||
},
|
||||
{
|
||||
name: 'Running',
|
||||
value: 'running',
|
||||
},
|
||||
{
|
||||
name: 'Success',
|
||||
value: 'success',
|
||||
},
|
||||
{
|
||||
name: 'Unknown',
|
||||
value: 'unknown',
|
||||
},
|
||||
{
|
||||
name: 'Waiting',
|
||||
value: 'waiting',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
resolveOffset: ['onStatus'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Use Schema Registry',
|
||||
name: 'useSchemaRegistry',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to use Confluent Schema Registry',
|
||||
},
|
||||
{
|
||||
displayName: 'Schema Registry URL',
|
||||
name: 'schemaRegistryUrl',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
useSchemaRegistry: [true],
|
||||
},
|
||||
},
|
||||
placeholder: 'https://schema-registry-domain:8081',
|
||||
default: '',
|
||||
description: 'URL of the schema registry',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Allow Topic Creation',
|
||||
name: 'allowAutoTopicCreation',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to allow sending message to a previously non-existing topic',
|
||||
},
|
||||
{
|
||||
displayName: 'Auto Commit Threshold',
|
||||
name: 'autoCommitThreshold',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'The consumer will commit offsets after resolving a given number of messages',
|
||||
},
|
||||
{
|
||||
displayName: 'Auto Commit Interval',
|
||||
name: 'autoCommitInterval',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'The consumer will commit offsets after a given period, for example, five seconds',
|
||||
hint: 'Value in milliseconds',
|
||||
},
|
||||
{
|
||||
displayName: 'Batch Size',
|
||||
name: 'batchSize',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description:
|
||||
'Number of messages to process in each batch, when set to 1, message-by-message processing is enabled',
|
||||
},
|
||||
{
|
||||
displayName: 'Each Batch Auto Resolve',
|
||||
name: 'eachBatchAutoResolve',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to auto resolve offsets for each batch',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fetch Max Bytes',
|
||||
name: 'fetchMaxBytes',
|
||||
type: 'number',
|
||||
default: 1048576,
|
||||
description:
|
||||
'Maximum amount of data the server should return for a fetch request. In bytes. Default is 1MB. Higher values allow fetching more messages at once.',
|
||||
},
|
||||
{
|
||||
displayName: 'Fetch Min Bytes',
|
||||
name: 'fetchMinBytes',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description:
|
||||
'Minimum amount of data the server should return for a fetch request. In bytes. Server will wait up to fetchMaxWaitTime for this amount to accumulate.',
|
||||
},
|
||||
{
|
||||
displayName: 'Heartbeat Interval',
|
||||
name: 'heartbeatInterval',
|
||||
type: 'number',
|
||||
default: 10000,
|
||||
description:
|
||||
'Controls how often the consumer sends heartbeats to the broker to indicate it is still alive. Must be lower than Session Timeout. Recommended value is approximately one third of the Session Timeout (for example: 10s heartbeat with 30s session timeout).',
|
||||
hint: 'Value in milliseconds',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Heartbeat Interval',
|
||||
name: 'heartbeatInterval',
|
||||
type: 'number',
|
||||
default: 3000,
|
||||
description: "Heartbeats are used to ensure that the consumer's session stays active",
|
||||
hint: 'The value must be set lower than Session Timeout',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Max Number of Requests',
|
||||
name: 'maxInFlightRequests',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description:
|
||||
'The maximum number of unacknowledged requests the client will send on a single connection',
|
||||
},
|
||||
{
|
||||
displayName: 'Read Messages From Beginning',
|
||||
name: 'fromBeginning',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to read message from beginning',
|
||||
},
|
||||
{
|
||||
displayName: 'JSON Parse Message',
|
||||
name: 'jsonParseMessage',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to try to parse the message to an object',
|
||||
},
|
||||
{
|
||||
displayName: 'Keep Message as Binary Data',
|
||||
name: 'keepBinaryData',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Whether to keep message value as binary data for downstream processing (e.g., Avro deserialization)',
|
||||
},
|
||||
{
|
||||
displayName: 'Parallel Processing',
|
||||
name: 'parallelProcessing',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to process messages in parallel resolving offsets independently or in order resolving offsets after execution completion. In the manual mode, when execution started by clicking on Execute Workflow or Execute Step button, messages are processed in parallel resolving offsets immediately.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.1, 1.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Partitions Consumed Concurrently',
|
||||
name: 'partitionsConsumedConcurrently',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'Number of Kafka partitions to process in parallel. Controls how many partitions are processed concurrently by the consumer.',
|
||||
hint: 'Set to 0 to process all partitions sequentially',
|
||||
},
|
||||
{
|
||||
displayName: 'Only Message',
|
||||
name: 'onlyMessage',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
jsonParseMessage: [true],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: 'Whether to return only the message property',
|
||||
},
|
||||
{
|
||||
displayName: 'Return Headers',
|
||||
name: 'returnHeaders',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return the headers received from Kafka',
|
||||
},
|
||||
{
|
||||
displayName: 'Rebalance Timeout',
|
||||
name: 'rebalanceTimeout',
|
||||
type: 'number',
|
||||
default: 600000,
|
||||
description: 'The maximum time allowed for a consumer to join the group',
|
||||
},
|
||||
{
|
||||
displayName: 'Retry Delay on Error',
|
||||
name: 'errorRetryDelay',
|
||||
type: 'number',
|
||||
default: 5000,
|
||||
description:
|
||||
'Delay in milliseconds before retrying after a failed offset resolution. This prevents rapid retry loops that could overwhelm the Kafka broker.',
|
||||
hint: 'Value in milliseconds',
|
||||
typeOptions: {
|
||||
minValue: 1000,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
hide: {
|
||||
'/resolveOffset': ['immediately'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Session Timeout',
|
||||
name: 'sessionTimeout',
|
||||
type: 'number',
|
||||
default: 30000,
|
||||
description:
|
||||
'Timeout in milliseconds used to detect failures. Has to be higher than Heartbeat Interval. During the workflow execution heartbeat will be sent periodically to keep the session alive with configured Heartbeat Interval.',
|
||||
hint: 'Value in milliseconds',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const config = await createConfig(this);
|
||||
const kafka = new apacheKafka(config);
|
||||
const registry = setSchemaRegistry(this);
|
||||
|
||||
const options = this.getNodeParameter('options', {}) as KafkaTriggerOptions;
|
||||
if (options.keepBinaryData && nodeVersion < 1.2) {
|
||||
options.keepBinaryData = undefined;
|
||||
}
|
||||
|
||||
const consumerConfig = createConsumerConfig(this, options, nodeVersion);
|
||||
const consumer = kafka.consumer(consumerConfig);
|
||||
|
||||
const processMessage = configureMessageParser(
|
||||
options,
|
||||
this.logger,
|
||||
registry,
|
||||
this.helpers.prepareBinaryData,
|
||||
);
|
||||
|
||||
const topic = this.getNodeParameter('topic') as string;
|
||||
const batchSize = options.batchSize ?? 1;
|
||||
const partitionsConsumedConcurrently = options.partitionsConsumedConcurrently || undefined;
|
||||
|
||||
const dataEmitter = configureDataEmitter(this, options, nodeVersion);
|
||||
|
||||
const startConsumer = async () => {
|
||||
try {
|
||||
await consumer.connect();
|
||||
|
||||
await consumer.subscribe({ topic, fromBeginning: options.fromBeginning ? true : false });
|
||||
|
||||
await consumer.run({
|
||||
partitionsConsumedConcurrently,
|
||||
...getAutoCommitSettings(options),
|
||||
eachBatch: async ({
|
||||
batch,
|
||||
resolveOffset,
|
||||
heartbeat,
|
||||
isStale,
|
||||
isRunning,
|
||||
commitOffsetsIfNecessary,
|
||||
}: EachBatchPayload) => {
|
||||
// avoid throwing error in the callback, as it leads to consumer stop, disconnect and crash
|
||||
const messages = batch.messages;
|
||||
const messageTopic = batch.topic;
|
||||
|
||||
for (let i = 0; i < messages.length; i += batchSize) {
|
||||
// stop if consumer stopped or partition revoked
|
||||
if (!isRunning() || isStale()) {
|
||||
this.logger.debug('Batch processing interrupted due to rebalance or consumer stop');
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = messages.slice(i, Math.min(i + batchSize, messages.length));
|
||||
|
||||
let processedData;
|
||||
try {
|
||||
processedData = await Promise.all(
|
||||
chunk.map(async (message) => await processMessage(message, messageTopic)),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error('Chunk processing failed, skipping commit for this chunk', err);
|
||||
await heartbeat();
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await runWithHeartbeat(
|
||||
dataEmitter(processedData),
|
||||
heartbeat,
|
||||
consumerConfig.heartbeatInterval,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
this.logger.warn('runWithHeartbeat failed, skipping commit for this chunk');
|
||||
await heartbeat();
|
||||
break;
|
||||
}
|
||||
|
||||
const lastMessage = chunk[chunk.length - 1];
|
||||
if (lastMessage) {
|
||||
resolveOffset(lastMessage.offset);
|
||||
await commitOffsetsIfNecessary();
|
||||
}
|
||||
|
||||
await heartbeat();
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start Kafka consumer', { error });
|
||||
throw new NodeOperationError(this.getNode(), error);
|
||||
}
|
||||
};
|
||||
|
||||
const listeners = connectEventListeners(consumer, this.logger);
|
||||
|
||||
const closeFunction = async () => {
|
||||
try {
|
||||
disconnectEventListeners(listeners);
|
||||
await consumer.stop();
|
||||
await consumer.disconnect();
|
||||
} catch (error) {
|
||||
throw new TriggerCloseError(this.getNode(), {
|
||||
cause: ensureError(error),
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (this.getMode() !== 'manual') {
|
||||
await startConsumer();
|
||||
return { closeFunction };
|
||||
} else {
|
||||
// The "manualTriggerFunction" function gets called by n8n
|
||||
// when a user is in the workflow editor and starts the
|
||||
// workflow manually. So the function has to make sure that
|
||||
// the emit() gets called with similar data like when it
|
||||
// would trigger by itself so that the user knows what data
|
||||
// to expect.
|
||||
async function manualTriggerFunction() {
|
||||
await startConsumer();
|
||||
}
|
||||
|
||||
return {
|
||||
closeFunction,
|
||||
manualTriggerFunction,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.4219 22.155C25.8642 22.155 24.4678 22.8451 23.5115 23.9316L21.061 22.1969C21.3211 21.4807 21.4705 20.7117 21.4705 19.9067C21.4705 19.1158 21.3263 18.3597 21.0748 17.6541L23.5197 15.9377C24.4759 17.0187 25.8688 17.7051 27.4219 17.7051C30.297 17.7051 32.6364 15.3661 32.6364 12.4906C32.6364 9.6152 30.297 7.27619 27.4219 7.27619C24.5469 7.27619 22.2075 9.6152 22.2075 12.4906C22.2075 13.0053 22.2852 13.5016 22.4248 13.9716L19.9782 15.6888C18.9562 14.4209 17.4846 13.5357 15.808 13.2654V10.3168C18.17 9.82066 19.9497 7.72205 19.9497 5.21444C19.9497 2.33901 17.6103 0 14.7353 0C11.8602 0 9.52081 2.33901 9.52081 5.21444C9.52081 7.68847 11.2544 9.76119 13.5692 10.2927V13.2797C10.4101 13.8342 8 16.5911 8 19.9067C8 23.2385 10.434 26.0056 13.6158 26.5412V29.6953C11.2774 30.2093 9.52081 32.2947 9.52081 34.7856C9.52081 37.661 11.8602 40 14.7353 40C17.6103 40 19.9497 37.661 19.9497 34.7856C19.9497 32.2947 18.1931 30.2093 15.8547 29.6953V26.5411C17.4652 26.2699 18.9222 25.4225 19.9543 24.1568L22.4211 25.9028C22.2842 26.3686 22.2075 26.86 22.2075 27.3694C22.2075 30.2449 24.5469 32.5839 27.4219 32.5839C30.297 32.5839 32.6364 30.2449 32.6364 27.3694C32.6364 24.494 30.297 22.155 27.4219 22.155ZM27.4219 9.96242C28.8161 9.96242 29.95 11.0968 29.95 12.4906C29.95 13.8844 28.8161 15.0187 27.4219 15.0187C26.0277 15.0187 24.8938 13.8844 24.8938 12.4906C24.8938 11.0968 26.0277 9.96242 27.4219 9.96242ZM12.207 5.21444C12.207 3.82065 13.3411 2.68632 14.7353 2.68632C16.1294 2.68632 17.2634 3.82065 17.2634 5.21444C17.2634 6.60822 16.1294 7.74255 14.7353 7.74255C13.3411 7.74255 12.207 6.60822 12.207 5.21444ZM17.2634 34.7856C17.2634 36.1793 16.1294 37.3137 14.7353 37.3137C13.3411 37.3137 12.207 36.1793 12.207 34.7856C12.207 33.3918 13.3411 32.2574 14.7353 32.2574C16.1294 32.2574 17.2634 33.3918 17.2634 34.7856ZM14.7351 23.4326C12.7906 23.4326 11.2088 21.8511 11.2088 19.9067C11.2088 17.9623 12.7906 16.3805 14.7351 16.3805C16.6794 16.3805 18.2612 17.9623 18.2612 19.9067C18.2612 21.8511 16.6794 23.4326 14.7351 23.4326ZM27.4219 29.8977C26.0277 29.8977 24.8938 28.7632 24.8938 27.3694C24.8938 25.9757 26.0277 24.8413 27.4219 24.8413C28.8161 24.8413 29.95 25.9757 29.95 27.3694C29.95 28.7632 28.8161 29.8977 27.4219 29.8977Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.4219 22.155C25.8642 22.155 24.4678 22.8451 23.5115 23.9316L21.061 22.1969C21.3211 21.4807 21.4705 20.7117 21.4705 19.9067C21.4705 19.1158 21.3263 18.3597 21.0748 17.6541L23.5197 15.9377C24.4759 17.0187 25.8688 17.7051 27.4219 17.7051C30.297 17.7051 32.6364 15.3661 32.6364 12.4906C32.6364 9.6152 30.297 7.27619 27.4219 7.27619C24.5469 7.27619 22.2075 9.6152 22.2075 12.4906C22.2075 13.0053 22.2852 13.5016 22.4248 13.9716L19.9782 15.6888C18.9562 14.4209 17.4846 13.5357 15.808 13.2654V10.3168C18.17 9.82066 19.9497 7.72205 19.9497 5.21444C19.9497 2.33901 17.6103 0 14.7353 0C11.8602 0 9.52081 2.33901 9.52081 5.21444C9.52081 7.68847 11.2544 9.76119 13.5692 10.2927V13.2797C10.4101 13.8342 8 16.5911 8 19.9067C8 23.2385 10.434 26.0056 13.6158 26.5412V29.6953C11.2774 30.2093 9.52081 32.2947 9.52081 34.7856C9.52081 37.661 11.8602 40 14.7353 40C17.6103 40 19.9497 37.661 19.9497 34.7856C19.9497 32.2947 18.1931 30.2093 15.8547 29.6953V26.5411C17.4652 26.2699 18.9222 25.4225 19.9543 24.1568L22.4211 25.9028C22.2842 26.3686 22.2075 26.86 22.2075 27.3694C22.2075 30.2449 24.5469 32.5839 27.4219 32.5839C30.297 32.5839 32.6364 30.2449 32.6364 27.3694C32.6364 24.494 30.297 22.155 27.4219 22.155ZM27.4219 9.96242C28.8161 9.96242 29.95 11.0968 29.95 12.4906C29.95 13.8844 28.8161 15.0187 27.4219 15.0187C26.0277 15.0187 24.8938 13.8844 24.8938 12.4906C24.8938 11.0968 26.0277 9.96242 27.4219 9.96242ZM12.207 5.21444C12.207 3.82065 13.3411 2.68632 14.7353 2.68632C16.1294 2.68632 17.2634 3.82065 17.2634 5.21444C17.2634 6.60822 16.1294 7.74255 14.7353 7.74255C13.3411 7.74255 12.207 6.60822 12.207 5.21444ZM17.2634 34.7856C17.2634 36.1793 16.1294 37.3137 14.7353 37.3137C13.3411 37.3137 12.207 36.1793 12.207 34.7856C12.207 33.3918 13.3411 32.2574 14.7353 32.2574C16.1294 32.2574 17.2634 33.3918 17.2634 34.7856ZM14.7351 23.4326C12.7906 23.4326 11.2088 21.8511 11.2088 19.9067C11.2088 17.9623 12.7906 16.3805 14.7351 16.3805C16.6794 16.3805 18.2612 17.9623 18.2612 19.9067C18.2612 21.8511 16.6794 23.4326 14.7351 23.4326ZM27.4219 29.8977C26.0277 29.8977 24.8938 28.7632 24.8938 27.3694C24.8938 25.9757 26.0277 24.8413 27.4219 24.8413C28.8161 24.8413 29.95 25.9757 29.95 27.3694C29.95 28.7632 28.8161 29.8977 27.4219 29.8977Z" fill="#231F20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,102 @@
|
||||
import { SchemaRegistry } from '@kafkajs/confluent-schema-registry';
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { Producer } from 'kafkajs';
|
||||
import { Kafka as apacheKafka } from 'kafkajs';
|
||||
|
||||
jest.mock('kafkajs');
|
||||
jest.mock('@kafkajs/confluent-schema-registry');
|
||||
|
||||
describe('Kafka Node', () => {
|
||||
let mockProducer: jest.Mocked<Producer>;
|
||||
let mockKafka: jest.Mocked<apacheKafka>;
|
||||
let mockRegistry: jest.Mocked<SchemaRegistry>;
|
||||
let mockProducerConnect: jest.Mock;
|
||||
let mockProducerSend: jest.Mock;
|
||||
let mockProducerDisconnect: jest.Mock;
|
||||
let mockRegistryEncode: jest.Mock;
|
||||
|
||||
beforeAll(() => {
|
||||
mockProducerConnect = jest.fn();
|
||||
mockProducerSend = jest.fn().mockImplementation(async () => []);
|
||||
mockProducerDisconnect = jest.fn();
|
||||
|
||||
mockProducer = mock<Producer>({
|
||||
connect: mockProducerConnect,
|
||||
send: mockProducerSend,
|
||||
sendBatch: mockProducerSend,
|
||||
disconnect: mockProducerDisconnect,
|
||||
});
|
||||
|
||||
mockKafka = mock<apacheKafka>({
|
||||
producer: jest.fn().mockReturnValue(mockProducer),
|
||||
});
|
||||
|
||||
mockRegistryEncode = jest.fn((_id, input) => Buffer.from(JSON.stringify(input)));
|
||||
mockRegistry = mock<SchemaRegistry>({
|
||||
encode: mockRegistryEncode,
|
||||
});
|
||||
|
||||
(apacheKafka as jest.Mock).mockReturnValue(mockKafka);
|
||||
(SchemaRegistry as jest.Mock).mockReturnValue(mockRegistry);
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests();
|
||||
|
||||
test('should publish the correct kafka messages', async () => {
|
||||
expect(mockProducerSend).toHaveBeenCalledTimes(2);
|
||||
expect(mockProducerSend).toHaveBeenCalledWith({
|
||||
acks: 1,
|
||||
compression: 1,
|
||||
timeout: 1000,
|
||||
topicMessages: [
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
headers: { header: 'value' },
|
||||
key: 'messageKey',
|
||||
value: '{"name":"First item","code":1}',
|
||||
},
|
||||
],
|
||||
topic: 'test-topic',
|
||||
},
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
headers: { header: 'value' },
|
||||
key: 'messageKey',
|
||||
value: '{"name":"Second item","code":2}',
|
||||
},
|
||||
],
|
||||
topic: 'test-topic',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockProducerSend).toHaveBeenCalledWith({
|
||||
acks: 0,
|
||||
compression: 0,
|
||||
topicMessages: [
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
headers: { headerKey: 'headerValue' },
|
||||
key: null,
|
||||
value: Buffer.from(JSON.stringify({ foo: 'bar' })),
|
||||
},
|
||||
],
|
||||
topic: 'test-topic',
|
||||
},
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
headers: { headerKey: 'headerValue' },
|
||||
key: null,
|
||||
value: Buffer.from(JSON.stringify({ foo: 'bar' })),
|
||||
},
|
||||
],
|
||||
topic: 'test-topic',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ITriggerFunctions, IRun, INode, Logger, IDeferredPromise } from 'n8n-workflow';
|
||||
import { NodeOperationError, sleep } from 'n8n-workflow';
|
||||
|
||||
import { getAutoCommitSettings, configureDataEmitter, type KafkaTriggerOptions } from '../utils';
|
||||
|
||||
jest.mock('n8n-workflow', () => {
|
||||
const actual = jest.requireActual('n8n-workflow');
|
||||
return {
|
||||
...actual,
|
||||
sleep: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedSleep = jest.mocked(sleep);
|
||||
|
||||
describe('Kafka Utils', () => {
|
||||
describe('getAutoCommitSettings', () => {
|
||||
it('should return autoCommit true and eachBatchAutoResolve false for version 1.1', () => {
|
||||
const options: KafkaTriggerOptions = {};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: false,
|
||||
autoCommitInterval: undefined,
|
||||
autoCommitThreshold: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return eachBatchAutoResolve true when option is set', () => {
|
||||
const options: KafkaTriggerOptions = {
|
||||
eachBatchAutoResolve: true,
|
||||
};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: true,
|
||||
autoCommitInterval: undefined,
|
||||
autoCommitThreshold: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return eachBatchAutoResolve false when option is explicitly false', () => {
|
||||
const options: KafkaTriggerOptions = {
|
||||
eachBatchAutoResolve: false,
|
||||
};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: false,
|
||||
autoCommitInterval: undefined,
|
||||
autoCommitThreshold: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through autoCommitInterval when provided', () => {
|
||||
const options: KafkaTriggerOptions = {
|
||||
autoCommitInterval: 5000,
|
||||
};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: false,
|
||||
autoCommitInterval: 5000,
|
||||
autoCommitThreshold: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through autoCommitThreshold when provided', () => {
|
||||
const options: KafkaTriggerOptions = {
|
||||
autoCommitThreshold: 100,
|
||||
};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: false,
|
||||
autoCommitInterval: undefined,
|
||||
autoCommitThreshold: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through both autoCommit options when provided', () => {
|
||||
const options: KafkaTriggerOptions = {
|
||||
autoCommitInterval: 5000,
|
||||
autoCommitThreshold: 100,
|
||||
};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: false,
|
||||
autoCommitInterval: 5000,
|
||||
autoCommitThreshold: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine eachBatchAutoResolve with autoCommit options', () => {
|
||||
const options: KafkaTriggerOptions = {
|
||||
eachBatchAutoResolve: true,
|
||||
autoCommitInterval: 5000,
|
||||
autoCommitThreshold: 100,
|
||||
};
|
||||
const result = getAutoCommitSettings(options);
|
||||
|
||||
expect(result).toEqual({
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve: true,
|
||||
autoCommitInterval: 5000,
|
||||
autoCommitThreshold: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureDataEmitter', () => {
|
||||
const mockNode: INode = {
|
||||
id: 'test-node-id',
|
||||
name: 'Test Kafka Trigger',
|
||||
type: 'n8n-nodes-base.kafkaTrigger',
|
||||
typeVersion: 1.3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
interface TestDeferredPromise<T> extends IDeferredPromise<T> {
|
||||
resolveWith: (value: T) => void;
|
||||
rejectWith: (error: Error) => void;
|
||||
}
|
||||
|
||||
const createDeferredPromise = <T>(): TestDeferredPromise<T> => {
|
||||
let resolveFunc: (value: T) => void;
|
||||
let rejectFunc: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolveFunc = res;
|
||||
rejectFunc = rej;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
resolveWith: (value: T) => resolveFunc(value),
|
||||
rejectWith: (error: Error) => rejectFunc(error),
|
||||
};
|
||||
};
|
||||
|
||||
const createMockContext = (
|
||||
params: Record<string, unknown> = {},
|
||||
mode: 'manual' | 'trigger' = 'trigger',
|
||||
deferredPromise?: TestDeferredPromise<IRun>,
|
||||
) => {
|
||||
const ctx = mock<ITriggerFunctions>();
|
||||
const mockLogger = mock<Logger>();
|
||||
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, fallback?: unknown) => (params[name] ?? fallback) as never,
|
||||
);
|
||||
ctx.getMode.mockReturnValue(mode);
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
ctx.logger = mockLogger;
|
||||
ctx.getWorkflowSettings.mockReturnValue({ executionTimeout: 3600 });
|
||||
|
||||
// Mock helpers.createDeferredPromise
|
||||
if (deferredPromise) {
|
||||
ctx.helpers = {
|
||||
...ctx.helpers,
|
||||
createDeferredPromise: jest.fn().mockReturnValue(deferredPromise),
|
||||
} as unknown as ITriggerFunctions['helpers'];
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('immediate emit mode', () => {
|
||||
it('should emit immediately in manual mode regardless of resolveOffset setting', async () => {
|
||||
const ctx = createMockContext({ resolveOffset: 'onCompletion' }, 'manual');
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const result = await emitter(testData);
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should emit immediately for version 1 (resolveOffset mode is "immediately")', async () => {
|
||||
const ctx = createMockContext({}, 'trigger');
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const result = await emitter(testData);
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should emit immediately for version 1.1 with parallelProcessing enabled', async () => {
|
||||
const ctx = createMockContext({}, 'trigger');
|
||||
const options: KafkaTriggerOptions = { parallelProcessing: true };
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.1);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const result = await emitter(testData);
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should emit immediately when resolveOffset is explicitly set to "immediately"', async () => {
|
||||
const ctx = createMockContext({ resolveOffset: 'immediately' }, 'trigger');
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const result = await emitter(testData);
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred emit mode - onCompletion', () => {
|
||||
it('should wait for execution completion and return success', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
// Simulate successful execution completion
|
||||
deferredPromise.resolveWith({ status: 'success' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData], undefined, deferredPromise);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return success for any status in onCompletion mode', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
// Simulate failed execution - should still succeed in onCompletion mode
|
||||
deferredPromise.resolveWith({ status: 'error' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should use version 1.1 onCompletion mode when parallelProcessing is false', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext({}, 'trigger', deferredPromise);
|
||||
const options: KafkaTriggerOptions = { parallelProcessing: false };
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.1);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'success' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData], undefined, deferredPromise);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred emit mode - onSuccess', () => {
|
||||
it('should return success when execution status is "success"', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext({ resolveOffset: 'onSuccess' }, 'trigger', deferredPromise);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'success' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return failure and sleep when execution status is not "success"', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext({ resolveOffset: 'onSuccess' }, 'trigger', deferredPromise);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'error' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5000); // DEFAULT_ERROR_RETRY_DELAY_MS
|
||||
expect(ctx.logger.error).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
|
||||
it('should use custom errorRetryDelay when provided', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext({ resolveOffset: 'onSuccess' }, 'trigger', deferredPromise);
|
||||
const options: KafkaTriggerOptions = { errorRetryDelay: 10000 };
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'error' } as unknown as IRun);
|
||||
|
||||
await resultPromise;
|
||||
|
||||
expect(mockedSleep).toHaveBeenCalledWith(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred emit mode - onStatus', () => {
|
||||
it('should throw error when no statuses are selected', () => {
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onStatus', allowedStatuses: [] },
|
||||
'trigger',
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
expect(() => configureDataEmitter(ctx, options, 1.3)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return success when execution status matches allowed statuses', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onStatus', allowedStatuses: ['success', 'warning'] },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'warning' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return failure when execution status does not match allowed statuses', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onStatus', allowedStatuses: ['success'] },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'error' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(mockedSleep).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout handling', () => {
|
||||
it('should timeout and return failure when execution takes too long', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
ctx.getWorkflowSettings.mockReturnValue({ executionTimeout: 1 }); // 1 second timeout
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
// Advance timers past the timeout
|
||||
jest.advanceTimersByTime(1001);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(mockedSleep).toHaveBeenCalled();
|
||||
expect(ctx.logger.error).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
|
||||
it('should use default timeout of 3600 seconds when not configured', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
ctx.getWorkflowSettings.mockReturnValue({}); // No timeout configured
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
// Resolve before timeout
|
||||
deferredPromise.resolveWith({ status: 'success' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should clear timeout when execution completes before timeout', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
ctx.getWorkflowSettings.mockReturnValue({ executionTimeout: 10 });
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
// Resolve quickly
|
||||
deferredPromise.resolveWith({ status: 'success' } as unknown as IRun);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
// Advance timers past what would have been the timeout
|
||||
jest.advanceTimersByTime(15000);
|
||||
|
||||
// Should not have logged any timeout error
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle promise rejection and return failure', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.rejectWith(new Error('Execution failed'));
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5000);
|
||||
expect(ctx.logger.error).toHaveBeenCalledWith('Execution failed', expect.any(Object));
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
|
||||
it('should handle non-Error objects in catch block', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
// Reject with a string instead of Error
|
||||
deferredPromise.rejectWith('String error' as unknown as Error);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(mockedSleep).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('data emission', () => {
|
||||
it('should emit data array wrapped in another array', async () => {
|
||||
const ctx = createMockContext({}, 'manual');
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test1' } }, { json: { message: 'test2' } }];
|
||||
|
||||
await emitter(testData);
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData]);
|
||||
});
|
||||
|
||||
it('should pass deferred promise to emit in deferred mode', async () => {
|
||||
const deferredPromise = createDeferredPromise<IRun>();
|
||||
const ctx = createMockContext(
|
||||
{ resolveOffset: 'onCompletion' },
|
||||
'trigger',
|
||||
deferredPromise,
|
||||
);
|
||||
const options: KafkaTriggerOptions = {};
|
||||
|
||||
const emitter = configureDataEmitter(ctx, options, 1.3);
|
||||
const testData = [{ json: { message: 'test' } }];
|
||||
|
||||
const resultPromise = emitter(testData);
|
||||
|
||||
deferredPromise.resolveWith({ status: 'success' } as unknown as IRun);
|
||||
|
||||
await resultPromise;
|
||||
|
||||
expect(ctx.emit).toHaveBeenCalledWith([testData], undefined, deferredPromise);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"name": "Kafka test",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, -100],
|
||||
"id": "d0594d58-ebb3-4dc0-a241-3f2531212fd7",
|
||||
"name": "When clicking ‘Execute workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"topic": "test-topic",
|
||||
"useKey": true,
|
||||
"key": "messageKey",
|
||||
"headersUi": {
|
||||
"headerValues": [
|
||||
{
|
||||
"key": "header",
|
||||
"value": "value"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"acks": true,
|
||||
"compression": true,
|
||||
"timeout": 1000
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.kafka",
|
||||
"typeVersion": 1,
|
||||
"position": [440, -200],
|
||||
"id": "f29d6af7-9ded-421a-8ada-cea80eac9464",
|
||||
"name": "Send Input Data",
|
||||
"credentials": {
|
||||
"kafka": {
|
||||
"id": "JJBjHkOrIfcj91EX",
|
||||
"name": "Kafka account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"topic": "test-topic",
|
||||
"sendInputData": false,
|
||||
"message": "={{ JSON.stringify({foo: 'bar'}) }}",
|
||||
"jsonParameters": true,
|
||||
"useSchemaRegistry": true,
|
||||
"schemaRegistryUrl": "https://test-kafka-registry.local",
|
||||
"eventName": "test-event-name",
|
||||
"headerParametersJson": "{\n \"headerKey\": \"headerValue\"\n}",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.kafka",
|
||||
"typeVersion": 1,
|
||||
"position": [440, 0],
|
||||
"id": "d851834f-6b97-445d-8e69-cc2e873bdf80",
|
||||
"name": "Schema Registry",
|
||||
"credentials": {
|
||||
"kafka": {
|
||||
"id": "JJBjHkOrIfcj91EX",
|
||||
"name": "Kafka account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"data": [
|
||||
{
|
||||
"name": "First item",
|
||||
"code": 1
|
||||
},
|
||||
{
|
||||
"name": "Second item",
|
||||
"code": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "n8n-nodes-testing.testData",
|
||||
"typeVersion": 1,
|
||||
"position": [220, -100],
|
||||
"id": "50ce815c-cf9a-4d83-8739-c95f9c3d7ec6",
|
||||
"name": "Test Data"
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Send Input Data": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Schema Registry": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Test Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Test Data": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Schema Registry",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Send Input Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "be4cbb16-225f-41ed-b897-895aaa34ea34",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
|
||||
},
|
||||
"id": "r7XhZVcfhaGvCbgE",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import type {
|
||||
Consumer,
|
||||
RemoveInstrumentationEventListener,
|
||||
KafkaMessage,
|
||||
KafkaConfig,
|
||||
SASLOptions,
|
||||
ConsumerConfig,
|
||||
} from 'kafkajs';
|
||||
import { logLevel } from 'kafkajs';
|
||||
import { SchemaRegistry } from '@kafkajs/confluent-schema-registry';
|
||||
import type {
|
||||
Logger,
|
||||
ITriggerFunctions,
|
||||
IDataObject,
|
||||
IRun,
|
||||
IBinaryKeyData,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ensureError, jsonParse, NodeOperationError, sleep } from 'n8n-workflow';
|
||||
|
||||
// Default delay in milliseconds before retrying after a failed offset resolution.
|
||||
// This prevents rapid retry loops that could overwhelm the Kafka broker
|
||||
const DEFAULT_ERROR_RETRY_DELAY_MS = 5000;
|
||||
|
||||
export interface KafkaTriggerOptions {
|
||||
allowAutoTopicCreation?: boolean;
|
||||
autoCommitThreshold?: number;
|
||||
autoCommitInterval?: number;
|
||||
batchSize?: number;
|
||||
eachBatchAutoResolve?: boolean;
|
||||
errorRetryDelay?: number;
|
||||
fetchMaxBytes?: number;
|
||||
fetchMinBytes?: number;
|
||||
heartbeatInterval?: number;
|
||||
maxInFlightRequests?: number;
|
||||
fromBeginning?: boolean;
|
||||
jsonParseMessage?: boolean;
|
||||
keepBinaryData?: boolean;
|
||||
parallelProcessing?: boolean;
|
||||
partitionsConsumedConcurrently?: number;
|
||||
onlyMessage?: boolean;
|
||||
returnHeaders?: boolean;
|
||||
rebalanceTimeout?: number;
|
||||
sessionTimeout?: number;
|
||||
}
|
||||
|
||||
interface KafkaCredentials {
|
||||
clientId: string;
|
||||
brokers: string;
|
||||
ssl: boolean;
|
||||
authentication: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
saslMechanism?: 'plain' | 'scram-sha-256' | 'scram-sha-512';
|
||||
}
|
||||
|
||||
type ResolveOffsetMode = 'immediately' | 'onCompletion' | 'onSuccess' | 'onStatus';
|
||||
|
||||
/**
|
||||
* Creates Kafka client configuration from n8n credentials
|
||||
* @param ctx - The trigger function context
|
||||
* @returns Kafka configuration object with authentication settings
|
||||
*/
|
||||
export async function createConfig(ctx: ITriggerFunctions) {
|
||||
const credentials = (await ctx.getCredentials('kafka')) as KafkaCredentials;
|
||||
const clientId = credentials.clientId;
|
||||
const brokers = (credentials.brokers ?? '').split(',').map((item) => item.trim());
|
||||
const ssl = credentials.ssl;
|
||||
|
||||
const config: KafkaConfig = {
|
||||
clientId,
|
||||
brokers,
|
||||
ssl,
|
||||
logLevel: logLevel.ERROR,
|
||||
};
|
||||
|
||||
if (credentials.authentication) {
|
||||
if (!(credentials.username && credentials.password)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Username and password are required for authentication',
|
||||
);
|
||||
}
|
||||
config.sasl = {
|
||||
username: credentials.username as string,
|
||||
password: credentials.password as string,
|
||||
mechanism: credentials.saslMechanism as string,
|
||||
} as SASLOptions;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Kafka consumer configuration with session timeout and heartbeat settings
|
||||
* @param ctx - The trigger function context
|
||||
* @param options - Kafka trigger options from node parameters
|
||||
* @param nodeVersion - The version of the Kafka trigger node
|
||||
* @returns Consumer configuration object
|
||||
*/
|
||||
export function createConsumerConfig(
|
||||
ctx: ITriggerFunctions,
|
||||
options: KafkaTriggerOptions,
|
||||
nodeVersion: number,
|
||||
) {
|
||||
const groupId = ctx.getNodeParameter('groupId') as string;
|
||||
const maxInFlightRequests = (
|
||||
ctx.getNodeParameter('options.maxInFlightRequests', null) === 0
|
||||
? null
|
||||
: ctx.getNodeParameter('options.maxInFlightRequests', null)
|
||||
) as number;
|
||||
|
||||
const sessionTimeout = options.sessionTimeout ?? 30000;
|
||||
let heartbeatInterval: number;
|
||||
if (nodeVersion < 1.3) {
|
||||
heartbeatInterval = options.heartbeatInterval ?? 3000;
|
||||
} else {
|
||||
heartbeatInterval = options.heartbeatInterval ?? 10000;
|
||||
}
|
||||
|
||||
const rebalanceTimeout = options.rebalanceTimeout ?? 600000;
|
||||
const maxBytesPerPartition = options.fetchMaxBytes;
|
||||
const minBytes = options.fetchMinBytes;
|
||||
|
||||
const consumerConfig: ConsumerConfig = {
|
||||
groupId,
|
||||
maxInFlightRequests,
|
||||
sessionTimeout,
|
||||
heartbeatInterval,
|
||||
rebalanceTimeout,
|
||||
};
|
||||
|
||||
if (maxBytesPerPartition !== undefined) {
|
||||
consumerConfig.maxBytesPerPartition = maxBytesPerPartition;
|
||||
}
|
||||
|
||||
if (minBytes !== undefined) {
|
||||
consumerConfig.minBytes = minBytes;
|
||||
}
|
||||
|
||||
return consumerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures a message parser function that processes Kafka messages based on node options
|
||||
* @param options - Kafka trigger options for parsing behavior
|
||||
* @param logger - Logger instance for warnings
|
||||
* @param registry - Optional schema registry for message decoding
|
||||
* @param prepareBinaryData - Helper function to prepare binary data
|
||||
* @returns Async function that parses Kafka messages into n8n execution data
|
||||
*/
|
||||
export function configureMessageParser(
|
||||
options: KafkaTriggerOptions,
|
||||
logger: Logger,
|
||||
registry: SchemaRegistry | undefined,
|
||||
prepareBinaryData: ITriggerFunctions['helpers']['prepareBinaryData'],
|
||||
) {
|
||||
return async (message: KafkaMessage, messageTopic: string): Promise<INodeExecutionData> => {
|
||||
let data: IDataObject = {};
|
||||
let value = message.value?.toString() as string;
|
||||
const binary: IBinaryKeyData = {};
|
||||
|
||||
if (options.jsonParseMessage) {
|
||||
try {
|
||||
value = jsonParse(value);
|
||||
} catch (error) {
|
||||
logger.warn('Could not parse message to JSON, returning as string', { error });
|
||||
}
|
||||
}
|
||||
|
||||
if (registry) {
|
||||
try {
|
||||
value = await registry.decode(message.value as Buffer);
|
||||
} catch (error) {
|
||||
logger.warn('Could not decode message with Schema Registry, returning original message', {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve raw binary data for downstream processing (only in v1.2+)
|
||||
if (options.keepBinaryData && message.value) {
|
||||
const binaryData = await prepareBinaryData(
|
||||
message.value as Buffer,
|
||||
'message',
|
||||
'application/octet-stream',
|
||||
);
|
||||
binary.data = binaryData;
|
||||
}
|
||||
|
||||
if (options.returnHeaders && message.headers) {
|
||||
data.headers = Object.fromEntries(
|
||||
Object.entries(message.headers).map(([headerKey, headerValue]) => [
|
||||
headerKey,
|
||||
headerValue?.toString('utf8') ?? '',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
data.message = value;
|
||||
data.topic = messageTopic;
|
||||
|
||||
if (options.onlyMessage) {
|
||||
data = value as unknown as IDataObject;
|
||||
}
|
||||
|
||||
if (options.keepBinaryData && Object.keys(binary).length) {
|
||||
return { json: data, binary };
|
||||
}
|
||||
|
||||
return { json: data };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners to the Kafka consumer for monitoring and logging
|
||||
* @param consumer - The Kafka consumer instance
|
||||
* @param logger - Logger instance for event logging
|
||||
* @returns Array of listener removal functions
|
||||
*/
|
||||
export function connectEventListeners(consumer: Consumer, logger: Logger) {
|
||||
const onConnected = consumer.on(consumer.events.CONNECT, () => {
|
||||
logger.debug('Kafka consumer connected');
|
||||
});
|
||||
const onGroupJoin = consumer.on(consumer.events.GROUP_JOIN, () => {
|
||||
logger.debug('Consumer has joined the group');
|
||||
});
|
||||
const onRequestTimeout = consumer.on(consumer.events.REQUEST_TIMEOUT, () => {
|
||||
logger.error('Consumer request timed out');
|
||||
});
|
||||
const onUnsubscribedtopicsReceived = consumer.on(
|
||||
consumer.events.RECEIVED_UNSUBSCRIBED_TOPICS,
|
||||
() => {
|
||||
logger.warn('Consumer received messages for unsubscribed topics');
|
||||
},
|
||||
);
|
||||
const onStop = consumer.on(consumer.events.STOP, async (error) => {
|
||||
logger.error('Consumer has stopped', { error });
|
||||
});
|
||||
const onDisconnect = consumer.on(consumer.events.DISCONNECT, async (error) => {
|
||||
logger.error('Consumer has disconnected', { error });
|
||||
});
|
||||
const onCommitOffsets = consumer.on(consumer.events.COMMIT_OFFSETS, () => {
|
||||
logger.debug('Consumer offsets committed!');
|
||||
});
|
||||
const onRebalancing = consumer.on(consumer.events.REBALANCING, (payload) => {
|
||||
logger.debug('Consumer is rebalancing', { payload });
|
||||
});
|
||||
const onCrash = consumer.on(consumer.events.CRASH, async (error) => {
|
||||
logger.error('Consumer has crashed', { error });
|
||||
});
|
||||
|
||||
return [
|
||||
onConnected,
|
||||
onGroupJoin,
|
||||
onRequestTimeout,
|
||||
onUnsubscribedtopicsReceived,
|
||||
onStop,
|
||||
onDisconnect,
|
||||
onCommitOffsets,
|
||||
onRebalancing,
|
||||
onCrash,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event listeners from the Kafka consumer
|
||||
* @param listeners - Array of listener removal functions
|
||||
*/
|
||||
export function disconnectEventListeners(
|
||||
listeners: Array<RemoveInstrumentationEventListener<'consumer.connect'>>,
|
||||
) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes Confluent Schema Registry if enabled in node parameters
|
||||
* @param ctx - The trigger function context
|
||||
* @returns Schema registry instance or undefined if not configured
|
||||
*/
|
||||
export function setSchemaRegistry(ctx: ITriggerFunctions) {
|
||||
const useSchemaRegistry = ctx.getNodeParameter('useSchemaRegistry', 0) as boolean;
|
||||
|
||||
if (useSchemaRegistry) {
|
||||
try {
|
||||
const schemaRegistryUrl = ctx.getNodeParameter('schemaRegistryUrl', 0) as string;
|
||||
return new SchemaRegistry({ host: schemaRegistryUrl });
|
||||
} catch (error) {
|
||||
ctx.logger.warn('Could not connect to Schema Registry', { error });
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the offset resolution mode based on node version and configuration
|
||||
* @param ctx - The trigger function context
|
||||
* @param options - Kafka trigger options
|
||||
* @param nodeVersion - The version of the Kafka trigger node
|
||||
* @returns The offset resolution mode
|
||||
*/
|
||||
function getResolveOffsetMode(
|
||||
ctx: ITriggerFunctions,
|
||||
options: KafkaTriggerOptions,
|
||||
nodeVersion: number,
|
||||
): ResolveOffsetMode {
|
||||
if (nodeVersion === 1) return 'immediately';
|
||||
|
||||
if (nodeVersion === 1.1) {
|
||||
if (options.parallelProcessing) return 'immediately';
|
||||
return 'onCompletion';
|
||||
}
|
||||
return ctx.getNodeParameter('resolveOffset', 'immediately') as ResolveOffsetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures a data emitter function that handles workflow execution and offset resolution
|
||||
* @param ctx - The trigger function context
|
||||
* @param options - Kafka trigger options
|
||||
* @param nodeVersion - The version of the Kafka trigger node
|
||||
* @returns Async function that emits data and waits for execution completion based on resolve mode
|
||||
*/
|
||||
export function configureDataEmitter(
|
||||
ctx: ITriggerFunctions,
|
||||
options: KafkaTriggerOptions,
|
||||
nodeVersion: number,
|
||||
) {
|
||||
const resolveOffsetMode = getResolveOffsetMode(ctx, options, nodeVersion);
|
||||
|
||||
// For manual mode, always use immediate emit (no donePromise)
|
||||
if (ctx.getMode() === 'manual' || resolveOffsetMode === 'immediately') {
|
||||
return async (dataArray: INodeExecutionData[]) => {
|
||||
ctx.emit([dataArray]);
|
||||
return { success: true };
|
||||
};
|
||||
}
|
||||
|
||||
const executionTimeoutInSeconds = ctx.getWorkflowSettings().executionTimeout ?? 3600;
|
||||
const errorRetryDelay = options.errorRetryDelay ?? DEFAULT_ERROR_RETRY_DELAY_MS;
|
||||
|
||||
const allowedStatuses: string[] = [];
|
||||
if (resolveOffsetMode === 'onSuccess') {
|
||||
allowedStatuses.push('success');
|
||||
} else if (resolveOffsetMode === 'onStatus') {
|
||||
const selectedStatuses = ctx.getNodeParameter('allowedStatuses', []) as string[];
|
||||
|
||||
if (Array.isArray(selectedStatuses) && selectedStatuses.length) {
|
||||
allowedStatuses.push(...selectedStatuses);
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'At least one execution status must be selected to resolve offsets on selected statuses.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return async (dataArray: INodeExecutionData[]) => {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const responsePromise = ctx.helpers.createDeferredPromise<IRun>();
|
||||
ctx.emit([dataArray], undefined, responsePromise);
|
||||
|
||||
const timeoutPromise = new Promise<IRun>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Execution took longer than the configured workflow timeout of ${executionTimeoutInSeconds} seconds to complete, offsets not resolved.`,
|
||||
),
|
||||
);
|
||||
}, executionTimeoutInSeconds * 1000);
|
||||
});
|
||||
|
||||
const run = await Promise.race([responsePromise.promise, timeoutPromise]);
|
||||
|
||||
if (resolveOffsetMode !== 'onCompletion' && !allowedStatuses.includes(run.status)) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Execution status is not allowed for resolving offsets, current status: ' + run.status,
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
await sleep(errorRetryDelay);
|
||||
const error = ensureError(e);
|
||||
ctx.logger.error(error.message, { error });
|
||||
return { success: false };
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines auto-commit settings based on node's optons
|
||||
* @param options - Kafka trigger options
|
||||
* @returns Object with auto-commit configuration
|
||||
*/
|
||||
export function getAutoCommitSettings(options: KafkaTriggerOptions) {
|
||||
const eachBatchAutoResolve = options.eachBatchAutoResolve ?? false;
|
||||
|
||||
const autoCommitInterval = options.autoCommitInterval ?? undefined;
|
||||
const autoCommitThreshold = options.autoCommitThreshold ?? undefined;
|
||||
|
||||
return {
|
||||
autoCommit: true,
|
||||
eachBatchAutoResolve,
|
||||
autoCommitInterval,
|
||||
autoCommitThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a task while periodically invoking a heartbeat function
|
||||
* at specified intervals to prevent session timeout
|
||||
* @param task - The promise to execute
|
||||
* @param heartbeat - The heartbeat function to call periodically
|
||||
* @param intervalMs - The interval in milliseconds between heartbeat calls (default: 3000)
|
||||
* @returns The result of the task promise
|
||||
*/
|
||||
export async function runWithHeartbeat<T>(
|
||||
task: Promise<T>,
|
||||
heartbeat: () => Promise<void>,
|
||||
intervalMs = 3000,
|
||||
) {
|
||||
let timer;
|
||||
|
||||
try {
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
await heartbeat();
|
||||
} catch (error) {}
|
||||
}, intervalMs);
|
||||
|
||||
return await task;
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user