mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Make Redis available for backend communication (#6719)
* support redis cluster * cleanup, fix config schema * set default prefix to bull * initial commit * improve logging * improve types and refactor * list support and refactor * fix redis service and tests * add comment * add redis and cache prefix * use injection * lint fix * clean schema comments * improve naming, tests, cluster client * merge master * cache returns unknown instead of T * update cache service, tests and doc * remove console.log * do not cache null or undefined values * fix merge * lint fix
This commit is contained in:
parent
4ac4b850dd
commit
3cad60e918
|
@ -70,9 +70,9 @@
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/compression": "1.0.1",
|
"@types/compression": "1.0.1",
|
||||||
"@types/connect-history-api-fallback": "^1.3.1",
|
"@types/connect-history-api-fallback": "^1.3.1",
|
||||||
"@types/convict": "^6.1.1",
|
|
||||||
"@types/content-disposition": "^0.5.5",
|
"@types/content-disposition": "^0.5.5",
|
||||||
"@types/content-type": "^1.1.5",
|
"@types/content-type": "^1.1.5",
|
||||||
|
"@types/convict": "^6.1.1",
|
||||||
"@types/cookie-parser": "^1.4.2",
|
"@types/cookie-parser": "^1.4.2",
|
||||||
"@types/express": "^4.17.6",
|
"@types/express": "^4.17.6",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
|
@ -96,6 +96,7 @@
|
||||||
"@types/yamljs": "^0.2.31",
|
"@types/yamljs": "^0.2.31",
|
||||||
"chokidar": "^3.5.2",
|
"chokidar": "^3.5.2",
|
||||||
"concurrently": "^8.2.0",
|
"concurrently": "^8.2.0",
|
||||||
|
"ioredis-mock": "^8.8.1",
|
||||||
"ts-essentials": "^7.0.3"
|
"ts-essentials": "^7.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -119,9 +120,9 @@
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"connect-history-api-fallback": "^1.6.0",
|
"connect-history-api-fallback": "^1.6.0",
|
||||||
"convict": "^6.2.4",
|
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^1.0.4",
|
||||||
|
"convict": "^6.2.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"crypto-js": "~4.1.1",
|
"crypto-js": "~4.1.1",
|
||||||
"csrf": "^3.1.0",
|
"csrf": "^3.1.0",
|
||||||
|
|
|
@ -3,9 +3,6 @@ import { readFile } from 'fs/promises';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import type { RedisOptions } from 'ioredis';
|
|
||||||
|
|
||||||
import { LoggerProxy } from 'n8n-workflow';
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { N8N_VERSION, inDevelopment, inTest } from '@/constants';
|
import { N8N_VERSION, inDevelopment, inTest } from '@/constants';
|
||||||
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
||||||
|
@ -16,8 +13,17 @@ import { send, sendErrorResponse, ServiceUnavailableError } from '@/ResponseHelp
|
||||||
import { rawBody, jsonParser, corsMiddleware } from '@/middlewares';
|
import { rawBody, jsonParser, corsMiddleware } from '@/middlewares';
|
||||||
import { TestWebhooks } from '@/TestWebhooks';
|
import { TestWebhooks } from '@/TestWebhooks';
|
||||||
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
||||||
import { getRedisClusterNodes } from './GenericHelpers';
|
|
||||||
import { webhookRequestHandler } from '@/WebhookHelpers';
|
import { webhookRequestHandler } from '@/WebhookHelpers';
|
||||||
|
import { RedisService } from '@/services/redis.service';
|
||||||
|
import { jsonParse } from 'n8n-workflow';
|
||||||
|
import { eventBus } from './eventbus';
|
||||||
|
import type { AbstractEventMessageOptions } from './eventbus/EventMessageClasses/AbstractEventMessageOptions';
|
||||||
|
import { getEventMessageObjectByType } from './eventbus/EventMessageClasses/Helpers';
|
||||||
|
import type { RedisServiceWorkerResponseObject } from './services/redis/RedisServiceCommands';
|
||||||
|
import {
|
||||||
|
EVENT_BUS_REDIS_CHANNEL,
|
||||||
|
WORKER_RESPONSE_REDIS_CHANNEL,
|
||||||
|
} from './services/redis/RedisServiceHelper';
|
||||||
|
|
||||||
export abstract class AbstractServer {
|
export abstract class AbstractServer {
|
||||||
protected server: Server;
|
protected server: Server;
|
||||||
|
@ -110,80 +116,76 @@ export abstract class AbstractServer {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.getEnv('executions.mode') === 'queue') {
|
if (config.getEnv('executions.mode') === 'queue') {
|
||||||
await this.setupRedisChecks();
|
await this.setupRedis();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This connection is going to be our heartbeat
|
// This connection is going to be our heartbeat
|
||||||
// IORedis automatically pings redis and tries to reconnect
|
// IORedis automatically pings redis and tries to reconnect
|
||||||
// We will be using a retryStrategy to control how and when to exit.
|
// We will be using a retryStrategy to control how and when to exit.
|
||||||
private async setupRedisChecks() {
|
// We are also subscribing to the event log channel to receive events from workers
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
private async setupRedis() {
|
||||||
const { default: Redis } = await import('ioredis');
|
const redisService = Container.get(RedisService);
|
||||||
|
const redisSubscriber = await redisService.getPubSubSubscriber();
|
||||||
|
|
||||||
let lastTimer = 0;
|
// TODO: these are all proof of concept implementations for the moment
|
||||||
let cumulativeTimeout = 0;
|
// until worker communication is implemented
|
||||||
const { host, port, username, password, db }: RedisOptions = config.getEnv('queue.bull.redis');
|
// #region proof of concept
|
||||||
const clusterNodes = getRedisClusterNodes();
|
await redisSubscriber.subscribeToEventLog();
|
||||||
const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold');
|
await redisSubscriber.subscribeToWorkerResponseChannel();
|
||||||
const usesRedisCluster = clusterNodes.length > 0;
|
redisSubscriber.addMessageHandler(
|
||||||
LoggerProxy.debug(
|
'AbstractServerReceiver',
|
||||||
usesRedisCluster
|
async (channel: string, message: string) => {
|
||||||
? `Initialising Redis cluster connection with nodes: ${clusterNodes
|
// TODO: this is a proof of concept implementation to forward events to the main instance's event bus
|
||||||
.map((e) => `${e.host}:${e.port}`)
|
// Events are arriving through a pub/sub channel and are forwarded to the eventBus
|
||||||
.join(',')}`
|
// In the future, a stream should probably replace this implementation entirely
|
||||||
: `Initialising Redis client connection with host: ${host ?? 'localhost'} and port: ${
|
if (channel === EVENT_BUS_REDIS_CHANNEL) {
|
||||||
port ?? '6379'
|
const eventData = jsonParse<AbstractEventMessageOptions>(message);
|
||||||
}`,
|
if (eventData) {
|
||||||
);
|
const eventMessage = getEventMessageObjectByType(eventData);
|
||||||
const sharedRedisOptions: RedisOptions = {
|
if (eventMessage) {
|
||||||
username,
|
await eventBus.send(eventMessage);
|
||||||
password,
|
|
||||||
db,
|
|
||||||
enableReadyCheck: false,
|
|
||||||
maxRetriesPerRequest: null,
|
|
||||||
};
|
|
||||||
const redis = usesRedisCluster
|
|
||||||
? new Redis.Cluster(
|
|
||||||
clusterNodes.map((node) => ({ host: node.host, port: node.port })),
|
|
||||||
{
|
|
||||||
redisOptions: sharedRedisOptions,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: new Redis({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
...sharedRedisOptions,
|
|
||||||
retryStrategy: (): number | null => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastTimer > 30000) {
|
|
||||||
// Means we had no timeout at all or last timeout was temporary and we recovered
|
|
||||||
lastTimer = now;
|
|
||||||
cumulativeTimeout = 0;
|
|
||||||
} else {
|
|
||||||
cumulativeTimeout += now - lastTimer;
|
|
||||||
lastTimer = now;
|
|
||||||
if (cumulativeTimeout > redisConnectionTimeoutLimit) {
|
|
||||||
LoggerProxy.error(
|
|
||||||
`Unable to connect to Redis after ${redisConnectionTimeoutLimit}. Exiting process.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 500;
|
}
|
||||||
},
|
} else if (channel === WORKER_RESPONSE_REDIS_CHANNEL) {
|
||||||
});
|
// The back channel from the workers as a pub/sub channel
|
||||||
|
const workerResponse = jsonParse<RedisServiceWorkerResponseObject>(message);
|
||||||
|
if (workerResponse) {
|
||||||
|
// TODO: Handle worker response
|
||||||
|
console.log('Received worker response', workerResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// TODO: Leave comments for now as implementation example
|
||||||
|
// const redisStreamListener = await redisService.getStreamConsumer();
|
||||||
|
// void redisStreamListener.listenToStream('teststream');
|
||||||
|
// redisStreamListener.addMessageHandler(
|
||||||
|
// 'MessageLogger',
|
||||||
|
// async (stream: string, id: string, message: string[]) => {
|
||||||
|
// // TODO: this is a proof of concept implementation of a stream consumer
|
||||||
|
// switch (stream) {
|
||||||
|
// case EVENT_BUS_REDIS_STREAM:
|
||||||
|
// case COMMAND_REDIS_STREAM:
|
||||||
|
// case WORKER_RESPONSE_REDIS_STREAM:
|
||||||
|
// default:
|
||||||
|
// LoggerProxy.debug(
|
||||||
|
// `Received message from stream ${stream} with id ${id} and message ${message.join(
|
||||||
|
// ',',
|
||||||
|
// )}`,
|
||||||
|
// );
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
|
||||||
redis.on('close', () => {
|
// const redisListReceiver = await redisService.getListReceiver();
|
||||||
LoggerProxy.warn('Redis unavailable - trying to reconnect...');
|
// await redisListReceiver.init();
|
||||||
});
|
|
||||||
|
|
||||||
redis.on('error', (error) => {
|
// setInterval(async () => {
|
||||||
if (!String(error).includes('ECONNREFUSED')) {
|
// await redisListReceiver.popLatestWorkerResponse();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
// }, 1000);
|
||||||
LoggerProxy.warn('Error with Redis: ', error);
|
// #endregion
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
|
@ -192,28 +192,4 @@ export async function createErrorExecution(
|
||||||
await Container.get(ExecutionRepository).createNewExecution(fullExecutionData);
|
await Container.get(ExecutionRepository).createNewExecution(fullExecutionData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRedisClusterNodes(): Array<{ host: string; port: number }> {
|
|
||||||
const clusterNodePairs = config
|
|
||||||
.getEnv('queue.bull.redis.clusterNodes')
|
|
||||||
.split(',')
|
|
||||||
.filter((e) => e);
|
|
||||||
return clusterNodePairs.map((pair) => {
|
|
||||||
const [host, port] = pair.split(':');
|
|
||||||
return { host, port: parseInt(port) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRedisPrefix(): string {
|
|
||||||
let prefix = config.getEnv('queue.bull.prefix');
|
|
||||||
if (prefix && getRedisClusterNodes().length > 0) {
|
|
||||||
if (!prefix.startsWith('{')) {
|
|
||||||
prefix = '{' + prefix;
|
|
||||||
}
|
|
||||||
if (!prefix.endsWith('}')) {
|
|
||||||
prefix += '}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20;
|
export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20;
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import type Bull from 'bull';
|
import type Bull from 'bull';
|
||||||
import { type RedisOptions } from 'ioredis';
|
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import { LoggerProxy, type IExecuteResponsePromiseData } from 'n8n-workflow';
|
import { type IExecuteResponsePromiseData } from 'n8n-workflow';
|
||||||
import config from '@/config';
|
|
||||||
import { ActiveExecutions } from '@/ActiveExecutions';
|
import { ActiveExecutions } from '@/ActiveExecutions';
|
||||||
import * as WebhookHelpers from '@/WebhookHelpers';
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
import { getRedisClusterNodes, getRedisPrefix } from './GenericHelpers';
|
import {
|
||||||
|
getRedisClusterClient,
|
||||||
|
getRedisClusterNodes,
|
||||||
|
getRedisPrefix,
|
||||||
|
getRedisStandardClient,
|
||||||
|
} from './services/redis/RedisServiceHelper';
|
||||||
|
import type { RedisClientType } from './services/redis/RedisServiceBaseClasses';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
export type JobId = Bull.JobId;
|
export type JobId = Bull.JobId;
|
||||||
export type Job = Bull.Job<JobData>;
|
export type Job = Bull.Job<JobData>;
|
||||||
|
@ -32,10 +37,10 @@ export class Queue {
|
||||||
constructor(private activeExecutions: ActiveExecutions) {}
|
constructor(private activeExecutions: ActiveExecutions) {}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const prefix = getRedisPrefix();
|
const bullPrefix = config.getEnv('queue.bull.prefix');
|
||||||
|
const prefix = getRedisPrefix(bullPrefix);
|
||||||
const clusterNodes = getRedisClusterNodes();
|
const clusterNodes = getRedisClusterNodes();
|
||||||
const usesRedisCluster = clusterNodes.length > 0;
|
const usesRedisCluster = clusterNodes.length > 0;
|
||||||
const { host, port, username, password, db }: RedisOptions = config.getEnv('queue.bull.redis');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
const { default: Bull } = await import('bull');
|
const { default: Bull } = await import('bull');
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
@ -45,40 +50,12 @@ export class Queue {
|
||||||
// for some time. With it enabled, worker might take minutes to realize
|
// for some time. With it enabled, worker might take minutes to realize
|
||||||
// redis is back up and resume working.
|
// redis is back up and resume working.
|
||||||
// More here: https://github.com/OptimalBits/bull/issues/890
|
// More here: https://github.com/OptimalBits/bull/issues/890
|
||||||
|
|
||||||
LoggerProxy.debug(
|
|
||||||
usesRedisCluster
|
|
||||||
? `Initialising Redis cluster connection with nodes: ${clusterNodes
|
|
||||||
.map((e) => `${e.host}:${e.port}`)
|
|
||||||
.join(',')}`
|
|
||||||
: `Initialising Redis client connection with host: ${host ?? 'localhost'} and port: ${
|
|
||||||
port ?? '6379'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
const sharedRedisOptions: RedisOptions = {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
db,
|
|
||||||
enableReadyCheck: false,
|
|
||||||
maxRetriesPerRequest: null,
|
|
||||||
};
|
|
||||||
this.jobQueue = new Bull('jobs', {
|
this.jobQueue = new Bull('jobs', {
|
||||||
prefix,
|
prefix,
|
||||||
createClient: (type, clientConfig) =>
|
createClient: (type, clientConfig) =>
|
||||||
usesRedisCluster
|
usesRedisCluster
|
||||||
? new Redis.Cluster(
|
? getRedisClusterClient(Redis, clientConfig, (type + '(bull)') as RedisClientType)
|
||||||
clusterNodes.map((node) => ({ host: node.host, port: node.port })),
|
: getRedisStandardClient(Redis, clientConfig, (type + '(bull)') as RedisClientType),
|
||||||
{
|
|
||||||
...clientConfig,
|
|
||||||
redisOptions: sharedRedisOptions,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: new Redis({
|
|
||||||
...clientConfig,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
...sharedRedisOptions,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.jobQueue.on('global:progress', (jobId, progress: WebhookResponse) => {
|
this.jobQueue.on('global:progress', (jobId, progress: WebhookResponse) => {
|
||||||
|
|
|
@ -351,7 +351,7 @@ export const schema = {
|
||||||
},
|
},
|
||||||
bull: {
|
bull: {
|
||||||
prefix: {
|
prefix: {
|
||||||
doc: 'Prefix for all queue keys (wrap in {} for cluster mode)',
|
doc: 'Prefix for all bull queue keys',
|
||||||
format: String,
|
format: String,
|
||||||
default: 'bull',
|
default: 'bull',
|
||||||
env: 'QUEUE_BULL_PREFIX',
|
env: 'QUEUE_BULL_PREFIX',
|
||||||
|
@ -1112,6 +1112,15 @@ export const schema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
redis: {
|
||||||
|
prefix: {
|
||||||
|
doc: 'Prefix for all n8n related keys',
|
||||||
|
format: String,
|
||||||
|
default: 'n8n',
|
||||||
|
env: 'N8N_REDIS_KEY_PREFIX',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
cache: {
|
cache: {
|
||||||
enabled: {
|
enabled: {
|
||||||
doc: 'Whether caching is enabled',
|
doc: 'Whether caching is enabled',
|
||||||
|
@ -1140,10 +1149,16 @@ export const schema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
|
prefix: {
|
||||||
|
doc: 'Prefix for all cache keys',
|
||||||
|
format: String,
|
||||||
|
default: 'cache',
|
||||||
|
env: 'N8N_CACHE_REDIS_KEY_PREFIX',
|
||||||
|
},
|
||||||
ttl: {
|
ttl: {
|
||||||
doc: 'Time to live for cached items in redis (in ms), 0 for no TTL',
|
doc: 'Time to live for cached items in redis (in ms), 0 for no TTL',
|
||||||
format: Number,
|
format: Number,
|
||||||
default: 0,
|
default: 3600 * 1000, // 1 hour
|
||||||
env: 'N8N_CACHE_REDIS_TTL',
|
env: 'N8N_CACHE_REDIS_TTL',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,9 +3,8 @@ import config from '@/config';
|
||||||
import { caching } from 'cache-manager';
|
import { caching } from 'cache-manager';
|
||||||
import type { MemoryCache } from 'cache-manager';
|
import type { MemoryCache } from 'cache-manager';
|
||||||
import type { RedisCache } from 'cache-manager-ioredis-yet';
|
import type { RedisCache } from 'cache-manager-ioredis-yet';
|
||||||
import type { RedisOptions } from 'ioredis';
|
import { jsonStringify } from 'n8n-workflow';
|
||||||
import { getRedisClusterNodes } from '../GenericHelpers';
|
import { getDefaultRedisClient, getRedisPrefix } from './redis/RedisServiceHelper';
|
||||||
import { LoggerProxy, jsonStringify } from 'n8n-workflow';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
|
@ -15,80 +14,33 @@ export class CacheService {
|
||||||
*/
|
*/
|
||||||
private cache: RedisCache | MemoryCache | undefined;
|
private cache: RedisCache | MemoryCache | undefined;
|
||||||
|
|
||||||
async init() {
|
isRedisCache(): boolean {
|
||||||
|
return (this.cache as RedisCache)?.store?.isCacheable !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the cache service.
|
||||||
|
*
|
||||||
|
* If the cache is enabled, it will initialize the cache from the provided config options. By default, it will use
|
||||||
|
* the `memory` backend and create a simple in-memory cache. If running in `queue` mode, or if `redis` backend is selected,
|
||||||
|
* it use Redis as the cache backend (either a local Redis instance or a Redis cluster, depending on the config)
|
||||||
|
*
|
||||||
|
* If the cache is disabled, this does nothing.
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
if (!config.getEnv('cache.enabled')) {
|
if (!config.getEnv('cache.enabled')) {
|
||||||
throw new Error('Cache is disabled');
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backend = config.getEnv('cache.backend');
|
const backend = config.getEnv('cache.backend');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
backend === 'redis' ||
|
backend === 'redis' ||
|
||||||
(backend === 'auto' && config.getEnv('executions.mode') === 'queue')
|
(backend === 'auto' && config.getEnv('executions.mode') === 'queue')
|
||||||
) {
|
) {
|
||||||
const { redisInsStore } = await import('cache-manager-ioredis-yet');
|
const { redisInsStore } = await import('cache-manager-ioredis-yet');
|
||||||
|
const redisPrefix = getRedisPrefix(config.getEnv('redis.prefix'));
|
||||||
// #region TEMPORARY Redis Client Code
|
const cachePrefix = config.getEnv('cache.redis.prefix');
|
||||||
/*
|
const keyPrefix = `${redisPrefix}:${cachePrefix}:`;
|
||||||
* TODO: remove once redis service is ready
|
const redisClient = await getDefaultRedisClient({ keyPrefix }, 'client(cache)');
|
||||||
* this code is just temporary
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
const { default: Redis } = await import('ioredis');
|
|
||||||
let lastTimer = 0;
|
|
||||||
let cumulativeTimeout = 0;
|
|
||||||
const { host, port, username, password, db }: RedisOptions =
|
|
||||||
config.getEnv('queue.bull.redis');
|
|
||||||
const clusterNodes = getRedisClusterNodes();
|
|
||||||
const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold');
|
|
||||||
const usesRedisCluster = clusterNodes.length > 0;
|
|
||||||
LoggerProxy.debug(
|
|
||||||
usesRedisCluster
|
|
||||||
? `(Cache Service) Initialising Redis cluster connection with nodes: ${clusterNodes
|
|
||||||
.map((e) => `${e.host}:${e.port}`)
|
|
||||||
.join(',')}`
|
|
||||||
: `(Cache Service) Initialising Redis client connection with host: ${
|
|
||||||
host ?? 'localhost'
|
|
||||||
} and port: ${port ?? '6379'}`,
|
|
||||||
);
|
|
||||||
const sharedRedisOptions: RedisOptions = {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
db,
|
|
||||||
enableReadyCheck: false,
|
|
||||||
maxRetriesPerRequest: null,
|
|
||||||
};
|
|
||||||
const redisClient = usesRedisCluster
|
|
||||||
? new Redis.Cluster(
|
|
||||||
clusterNodes.map((node) => ({ host: node.host, port: node.port })),
|
|
||||||
{
|
|
||||||
redisOptions: sharedRedisOptions,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: new Redis({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
...sharedRedisOptions,
|
|
||||||
retryStrategy: (): number | null => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastTimer > 30000) {
|
|
||||||
// Means we had no timeout at all or last timeout was temporary and we recovered
|
|
||||||
lastTimer = now;
|
|
||||||
cumulativeTimeout = 0;
|
|
||||||
} else {
|
|
||||||
cumulativeTimeout += now - lastTimer;
|
|
||||||
lastTimer = now;
|
|
||||||
if (cumulativeTimeout > redisConnectionTimeoutLimit) {
|
|
||||||
LoggerProxy.error(
|
|
||||||
`Unable to connect to Redis after ${redisConnectionTimeoutLimit}. Exiting process.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 500;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// #endregion TEMPORARY Redis Client Code
|
|
||||||
const redisStore = redisInsStore(redisClient, {
|
const redisStore = redisInsStore(redisClient, {
|
||||||
ttl: config.getEnv('cache.redis.ttl'),
|
ttl: config.getEnv('cache.redis.ttl'),
|
||||||
});
|
});
|
||||||
|
@ -106,6 +58,163 @@ export class CacheService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from the cache by key.
|
||||||
|
*
|
||||||
|
* If the value is not in the cache or expired, the refreshFunction is called if defined,
|
||||||
|
* which will set the key with the function's result and returns it. If no refreshFunction is set, the fallback value is returned.
|
||||||
|
*
|
||||||
|
* If the cache is disabled, refreshFunction's result or fallbackValue is returned.
|
||||||
|
*
|
||||||
|
* If cache is not hit, and neither refreshFunction nor fallbackValue are provided, `undefined` is returned.
|
||||||
|
* @param key The key to fetch from the cache
|
||||||
|
* @param options.refreshFunction Optional function to call to set the cache if the key is not found
|
||||||
|
* @param options.refreshTtl Optional ttl for the refreshFunction's set call
|
||||||
|
* @param options.fallbackValue Optional value returned is cache is not hit and refreshFunction is not provided
|
||||||
|
*/
|
||||||
|
async get(
|
||||||
|
key: string,
|
||||||
|
options: {
|
||||||
|
fallbackValue?: unknown;
|
||||||
|
refreshFunction?: (key: string) => Promise<unknown>;
|
||||||
|
refreshTtl?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
const value = await this.cache?.store.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (options.refreshFunction) {
|
||||||
|
const refreshValue = await options.refreshFunction(key);
|
||||||
|
await this.set(key, refreshValue, options.refreshTtl);
|
||||||
|
return refreshValue;
|
||||||
|
}
|
||||||
|
return options.fallbackValue ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get many values from a list of keys.
|
||||||
|
*
|
||||||
|
* If a value is not in the cache or expired, the returned list will have `undefined` at that index.
|
||||||
|
* If the cache is disabled, refreshFunction's result or fallbackValue is returned.
|
||||||
|
* If cache is not hit, and neither refreshFunction nor fallbackValue are provided, a list of `undefined` is returned.
|
||||||
|
* @param keys A list of keys to fetch from the cache
|
||||||
|
* @param options.refreshFunctionEach Optional, if defined, undefined values will be replaced with the result of the refreshFunctionEach call and the cache will be updated
|
||||||
|
* @param options.refreshFunctionMany Optional, if defined, all values will be replaced with the result of the refreshFunctionMany call and the cache will be updated
|
||||||
|
* @param options.refreshTtl Optional ttl for the refreshFunction's set call
|
||||||
|
* @param options.fallbackValue Optional value returned is cache is not hit and refreshFunction is not provided
|
||||||
|
*/
|
||||||
|
async getMany(
|
||||||
|
keys: string[],
|
||||||
|
options: {
|
||||||
|
fallbackValues?: unknown[];
|
||||||
|
refreshFunctionEach?: (key: string) => Promise<unknown>;
|
||||||
|
refreshFunctionMany?: (keys: string[]) => Promise<unknown[]>;
|
||||||
|
refreshTtl?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
let values = await this.cache?.store.mget(...keys);
|
||||||
|
if (values === undefined) {
|
||||||
|
values = keys.map(() => undefined);
|
||||||
|
}
|
||||||
|
if (!values.includes(undefined)) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
if (options.refreshFunctionEach) {
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
if (values[i] === undefined) {
|
||||||
|
const key = keys[i];
|
||||||
|
let fallback = undefined;
|
||||||
|
if (options.fallbackValues && options.fallbackValues.length > i) {
|
||||||
|
fallback = options.fallbackValues[i];
|
||||||
|
}
|
||||||
|
const refreshValue = await this.get(key, {
|
||||||
|
refreshFunction: options.refreshFunctionEach,
|
||||||
|
refreshTtl: options.refreshTtl,
|
||||||
|
fallbackValue: fallback,
|
||||||
|
});
|
||||||
|
values[i] = refreshValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
if (options.refreshFunctionMany) {
|
||||||
|
const refreshValues: unknown[] = await options.refreshFunctionMany(keys);
|
||||||
|
if (keys.length !== refreshValues.length) {
|
||||||
|
throw new Error('refreshFunctionMany must return the same number of values as keys');
|
||||||
|
}
|
||||||
|
const newKV: Array<[string, unknown]> = [];
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
newKV.push([keys[i], refreshValues[i]]);
|
||||||
|
}
|
||||||
|
await this.setMany(newKV, options.refreshTtl);
|
||||||
|
return refreshValues;
|
||||||
|
}
|
||||||
|
return options.fallbackValues ?? values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache by key.
|
||||||
|
* @param key The key to set
|
||||||
|
* @param value The value to set
|
||||||
|
* @param ttl Optional time to live in ms
|
||||||
|
*/
|
||||||
|
async set(key: string, value: unknown, ttl?: number): Promise<void> {
|
||||||
|
if (!this.cache) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isRedisCache()) {
|
||||||
|
if (!(this.cache as RedisCache)?.store?.isCacheable(value)) {
|
||||||
|
throw new Error('Value is not cacheable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.cache?.store.set(key, value, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a multiple values in the cache at once.
|
||||||
|
* @param values An array of [key, value] tuples to set
|
||||||
|
* @param ttl Optional time to live in ms
|
||||||
|
*/
|
||||||
|
async setMany(values: Array<[string, unknown]>, ttl?: number): Promise<void> {
|
||||||
|
if (!this.cache) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const nonNullValues = values.filter(([_key, value]) => value !== undefined && value !== null);
|
||||||
|
if (this.isRedisCache()) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
nonNullValues.forEach(([_key, value]) => {
|
||||||
|
if (!(this.cache as RedisCache)?.store?.isCacheable(value)) {
|
||||||
|
throw new Error('Value is not cacheable');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.cache?.store.mset(nonNullValues, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a value from the cache by key.
|
||||||
|
* @param key The key to delete
|
||||||
|
*/
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
await this.cache?.store.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple values from the cache.
|
||||||
|
* @param keys List of keys to delete
|
||||||
|
*/
|
||||||
|
async deleteMany(keys: string[]): Promise<void> {
|
||||||
|
return this.cache?.store.mdel(...keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all values and uninitialized the cache.
|
||||||
|
*/
|
||||||
async destroy() {
|
async destroy() {
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
await this.reset();
|
await this.reset();
|
||||||
|
@ -113,6 +222,22 @@ export class CacheService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable and initialize the cache.
|
||||||
|
*/
|
||||||
|
async enable() {
|
||||||
|
config.set('cache.enabled', true);
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable and destroy the cache.
|
||||||
|
*/
|
||||||
|
async disable() {
|
||||||
|
config.set('cache.enabled', false);
|
||||||
|
await this.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
async getCache(): Promise<RedisCache | MemoryCache | undefined> {
|
async getCache(): Promise<RedisCache | MemoryCache | undefined> {
|
||||||
if (!this.cache) {
|
if (!this.cache) {
|
||||||
await this.init();
|
await this.init();
|
||||||
|
@ -120,59 +245,35 @@ export class CacheService {
|
||||||
return this.cache;
|
return this.cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T> {
|
/**
|
||||||
if (!this.cache) {
|
* Delete all values from the cache, but leave the cache initialized.
|
||||||
await this.init();
|
*/
|
||||||
}
|
|
||||||
return this.cache?.store.get(key) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
||||||
if (!this.cache) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
return this.cache?.store.set(key, value, ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
if (!this.cache) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
return this.cache?.store.del(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<void> {
|
||||||
if (!this.cache) {
|
await this.cache?.store.reset();
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
return this.cache?.store.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all keys in the cache.
|
||||||
|
*/
|
||||||
async keys(): Promise<string[]> {
|
async keys(): Promise<string[]> {
|
||||||
if (!this.cache) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
return this.cache?.store.keys() ?? [];
|
return this.cache?.store.keys() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMany<T>(values: Array<[string, T]>, ttl?: number): Promise<void> {
|
/**
|
||||||
if (!this.cache) {
|
* Return all key/value pairs in the cache. This is a potentially very expensive operation and is only meant to be used for debugging
|
||||||
await this.init();
|
*/
|
||||||
|
async keyValues(): Promise<Map<string, unknown>> {
|
||||||
|
const keys = await this.keys();
|
||||||
|
const values = await this.getMany(keys);
|
||||||
|
const map = new Map<string, unknown>();
|
||||||
|
if (keys.length === values.length) {
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
map.set(keys[i], values[i]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
return this.cache?.store.mset(values, ttl);
|
throw new Error(
|
||||||
}
|
'Keys and values do not match, this should not happen and appears to result from some cache corruption.',
|
||||||
|
);
|
||||||
async getMany<T>(keys: string[]): Promise<Array<[string, T]>> {
|
|
||||||
if (!this.cache) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
return this.cache?.store.mget(...keys) as Promise<Array<[string, T]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMany(keys: string[]): Promise<void> {
|
|
||||||
if (!this.cache) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
return this.cache?.store.mdel(...keys);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ export class OwnershipService {
|
||||||
* Retrieve the user who owns the workflow. Note that workflow ownership is **immutable**.
|
* Retrieve the user who owns the workflow. Note that workflow ownership is **immutable**.
|
||||||
*/
|
*/
|
||||||
async getWorkflowOwnerCached(workflowId: string) {
|
async getWorkflowOwnerCached(workflowId: string) {
|
||||||
const cachedValue = await this.cacheService.get<User>(`cache:workflow-owner:${workflowId}`);
|
const cachedValue = (await this.cacheService.get(`cache:workflow-owner:${workflowId}`)) as User;
|
||||||
|
|
||||||
if (cachedValue) return this.userRepository.create(cachedValue);
|
if (cachedValue) return this.userRepository.create(cachedValue);
|
||||||
|
|
||||||
|
|
52
packages/cli/src/services/redis.service.ts
Normal file
52
packages/cli/src/services/redis.service.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { RedisServicePubSubSubscriber } from './redis/RedisServicePubSubSubscriber';
|
||||||
|
import { RedisServicePubSubPublisher } from './redis/RedisServicePubSubPublisher';
|
||||||
|
import { RedisServiceListReceiver } from './redis/RedisServiceListReceiver';
|
||||||
|
import { RedisServiceListSender } from './redis/RedisServiceListSender';
|
||||||
|
import { RedisServiceStreamConsumer } from './redis/RedisServiceStreamConsumer';
|
||||||
|
import { RedisServiceStreamProducer } from './redis/RedisServiceStreamProducer';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is a convenience service that provides access to all the Redis clients.
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class RedisService {
|
||||||
|
constructor(
|
||||||
|
private redisServicePubSubSubscriber: RedisServicePubSubSubscriber,
|
||||||
|
private redisServicePubSubPublisher: RedisServicePubSubPublisher,
|
||||||
|
private redisServiceListReceiver: RedisServiceListReceiver,
|
||||||
|
private redisServiceListSender: RedisServiceListSender,
|
||||||
|
private redisServiceStreamConsumer: RedisServiceStreamConsumer,
|
||||||
|
private redisServiceStreamProducer: RedisServiceStreamProducer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getPubSubSubscriber() {
|
||||||
|
await this.redisServicePubSubSubscriber.init();
|
||||||
|
return this.redisServicePubSubSubscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPubSubPublisher() {
|
||||||
|
await this.redisServicePubSubPublisher.init();
|
||||||
|
return this.redisServicePubSubPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListSender() {
|
||||||
|
await this.redisServiceListSender.init();
|
||||||
|
return this.redisServiceListSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListReceiver() {
|
||||||
|
await this.redisServiceListReceiver.init();
|
||||||
|
return this.redisServiceListReceiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreamProducer() {
|
||||||
|
await this.redisServiceStreamProducer.init();
|
||||||
|
return this.redisServiceStreamProducer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreamConsumer() {
|
||||||
|
await this.redisServiceStreamConsumer.init();
|
||||||
|
return this.redisServiceStreamConsumer;
|
||||||
|
}
|
||||||
|
}
|
74
packages/cli/src/services/redis/RedisServiceBaseClasses.ts
Normal file
74
packages/cli/src/services/redis/RedisServiceBaseClasses.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
import type { Cluster } from 'ioredis';
|
||||||
|
import { getDefaultRedisClient } from './RedisServiceHelper';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export type RedisClientType =
|
||||||
|
| 'subscriber'
|
||||||
|
| 'client'
|
||||||
|
| 'bclient'
|
||||||
|
| 'subscriber(bull)'
|
||||||
|
| 'client(bull)'
|
||||||
|
| 'bclient(bull)'
|
||||||
|
| 'client(cache)'
|
||||||
|
| 'publisher'
|
||||||
|
| 'consumer'
|
||||||
|
| 'producer'
|
||||||
|
| 'list-sender'
|
||||||
|
| 'list-receiver';
|
||||||
|
|
||||||
|
export type RedisServiceMessageHandler =
|
||||||
|
| ((channel: string, message: string) => void)
|
||||||
|
| ((stream: string, id: string, message: string[]) => void);
|
||||||
|
|
||||||
|
class RedisServiceBase {
|
||||||
|
redisClient: Redis | Cluster | undefined;
|
||||||
|
|
||||||
|
isInitialized = false;
|
||||||
|
|
||||||
|
async init(type: RedisClientType = 'client'): Promise<void> {
|
||||||
|
if (this.redisClient && this.isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.redisClient = await getDefaultRedisClient(undefined, type);
|
||||||
|
|
||||||
|
this.redisClient.on('close', () => {
|
||||||
|
LoggerProxy.warn('Redis unavailable - trying to reconnect...');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redisClient.on('error', (error) => {
|
||||||
|
if (!String(error).includes('ECONNREFUSED')) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
LoggerProxy.warn('Error with Redis: ', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.redisClient.quit();
|
||||||
|
this.redisClient = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class RedisServiceBaseSender extends RedisServiceBase {
|
||||||
|
senderId: string;
|
||||||
|
|
||||||
|
setSenderId(senderId?: string): void {
|
||||||
|
this.senderId = senderId ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class RedisServiceBaseReceiver extends RedisServiceBase {
|
||||||
|
messageHandlers: Map<string, RedisServiceMessageHandler> = new Map();
|
||||||
|
|
||||||
|
addMessageHandler(handlerName: string, handler: RedisServiceMessageHandler): void {
|
||||||
|
this.messageHandlers.set(handlerName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMessageHandler(handlerName: string): void {
|
||||||
|
this.messageHandlers.delete(handlerName);
|
||||||
|
}
|
||||||
|
}
|
22
packages/cli/src/services/redis/RedisServiceCommands.ts
Normal file
22
packages/cli/src/services/redis/RedisServiceCommands.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export type RedisServiceCommand = 'getStatus' | 'restartEventBus' | 'stopWorker'; // TODO: add more commands
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object to be sent via Redis pub/sub from the main process to the workers.
|
||||||
|
* @field command: The command to be executed.
|
||||||
|
* @field targets: The targets to execute the command on. Leave empty to execute on all workers or specify worker ids.
|
||||||
|
* @field args: Optional arguments to be passed to the command.
|
||||||
|
*/
|
||||||
|
type RedisServiceBaseCommand = {
|
||||||
|
command: RedisServiceCommand;
|
||||||
|
payload?: {
|
||||||
|
[key: string]: string | number | boolean | string[] | number[] | boolean[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedisServiceWorkerResponseObject = {
|
||||||
|
workerId: string;
|
||||||
|
} & RedisServiceBaseCommand;
|
||||||
|
|
||||||
|
export type RedisServiceCommandObject = {
|
||||||
|
targets?: string[];
|
||||||
|
} & RedisServiceBaseCommand;
|
146
packages/cli/src/services/redis/RedisServiceHelper.ts
Normal file
146
packages/cli/src/services/redis/RedisServiceHelper.ts
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import type Redis from 'ioredis';
|
||||||
|
import type { Cluster, RedisOptions } from 'ioredis';
|
||||||
|
import config from '@/config';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
import type { RedisClientType } from './RedisServiceBaseClasses';
|
||||||
|
|
||||||
|
export const EVENT_BUS_REDIS_STREAM = 'n8n:eventstream';
|
||||||
|
export const COMMAND_REDIS_STREAM = 'n8n:commandstream';
|
||||||
|
export const WORKER_RESPONSE_REDIS_STREAM = 'n8n:workerstream';
|
||||||
|
export const EVENT_BUS_REDIS_CHANNEL = 'n8n.events';
|
||||||
|
export const COMMAND_REDIS_CHANNEL = 'n8n.commands';
|
||||||
|
export const WORKER_RESPONSE_REDIS_CHANNEL = 'n8n.worker-response';
|
||||||
|
export const WORKER_RESPONSE_REDIS_LIST = 'n8n:list:worker-response';
|
||||||
|
|
||||||
|
export function getRedisClusterNodes(): Array<{ host: string; port: number }> {
|
||||||
|
const clusterNodePairs = config
|
||||||
|
.getEnv('queue.bull.redis.clusterNodes')
|
||||||
|
.split(',')
|
||||||
|
.filter((e) => e);
|
||||||
|
return clusterNodePairs.map((pair) => {
|
||||||
|
const [host, port] = pair.split(':');
|
||||||
|
return { host, port: parseInt(port) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedisPrefix(customPrefix?: string): string {
|
||||||
|
let prefix = customPrefix ?? config.getEnv('redis.prefix');
|
||||||
|
if (prefix && getRedisClusterNodes().length > 0) {
|
||||||
|
if (!prefix.startsWith('{')) {
|
||||||
|
prefix = '{' + prefix;
|
||||||
|
}
|
||||||
|
if (!prefix.endsWith('}')) {
|
||||||
|
prefix += '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedisStandardClient(
|
||||||
|
redis: typeof Redis,
|
||||||
|
redisOptions?: RedisOptions,
|
||||||
|
redisType?: RedisClientType,
|
||||||
|
): Redis | Cluster {
|
||||||
|
let lastTimer = 0;
|
||||||
|
let cumulativeTimeout = 0;
|
||||||
|
const { host, port, username, password, db }: RedisOptions = config.getEnv('queue.bull.redis');
|
||||||
|
const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold');
|
||||||
|
const sharedRedisOptions: RedisOptions = {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
db,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
...redisOptions,
|
||||||
|
};
|
||||||
|
LoggerProxy.debug(
|
||||||
|
`Initialising Redis client${redisType ? ` of type ${redisType}` : ''} connection with host: ${
|
||||||
|
host ?? 'localhost'
|
||||||
|
} and port: ${port ?? '6379'}`,
|
||||||
|
);
|
||||||
|
return new redis({
|
||||||
|
...sharedRedisOptions,
|
||||||
|
retryStrategy: (): number | null => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTimer > 30000) {
|
||||||
|
// Means we had no timeout at all or last timeout was temporary and we recovered
|
||||||
|
lastTimer = now;
|
||||||
|
cumulativeTimeout = 0;
|
||||||
|
} else {
|
||||||
|
cumulativeTimeout += now - lastTimer;
|
||||||
|
lastTimer = now;
|
||||||
|
if (cumulativeTimeout > redisConnectionTimeoutLimit) {
|
||||||
|
LoggerProxy.error(
|
||||||
|
`Unable to connect to Redis after ${redisConnectionTimeoutLimit}. Exiting process.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 500;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedisClusterClient(
|
||||||
|
redis: typeof Redis,
|
||||||
|
redisOptions?: RedisOptions,
|
||||||
|
redisType?: RedisClientType,
|
||||||
|
): Cluster {
|
||||||
|
let lastTimer = 0;
|
||||||
|
let cumulativeTimeout = 0;
|
||||||
|
const clusterNodes = getRedisClusterNodes();
|
||||||
|
const { username, password, db }: RedisOptions = config.getEnv('queue.bull.redis');
|
||||||
|
const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold');
|
||||||
|
const sharedRedisOptions: RedisOptions = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
db,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
...redisOptions,
|
||||||
|
};
|
||||||
|
LoggerProxy.debug(
|
||||||
|
`Initialising Redis cluster${
|
||||||
|
redisType ? ` of type ${redisType}` : ''
|
||||||
|
} connection with nodes: ${clusterNodes.map((e) => `${e.host}:${e.port}`).join(',')}`,
|
||||||
|
);
|
||||||
|
return new redis.Cluster(
|
||||||
|
clusterNodes.map((node) => ({ host: node.host, port: node.port })),
|
||||||
|
{
|
||||||
|
redisOptions: sharedRedisOptions,
|
||||||
|
clusterRetryStrategy: (): number | null => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTimer > 30000) {
|
||||||
|
// Means we had no timeout at all or last timeout was temporary and we recovered
|
||||||
|
lastTimer = now;
|
||||||
|
cumulativeTimeout = 0;
|
||||||
|
} else {
|
||||||
|
cumulativeTimeout += now - lastTimer;
|
||||||
|
lastTimer = now;
|
||||||
|
if (cumulativeTimeout > redisConnectionTimeoutLimit) {
|
||||||
|
LoggerProxy.error(
|
||||||
|
`Unable to connect to Redis after ${redisConnectionTimeoutLimit}. Exiting process.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 500;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDefaultRedisClient(
|
||||||
|
additionalRedisOptions?: RedisOptions,
|
||||||
|
redisType?: RedisClientType,
|
||||||
|
): Promise<Redis | Cluster> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
const { default: Redis } = await import('ioredis');
|
||||||
|
const clusterNodes = getRedisClusterNodes();
|
||||||
|
const usesRedisCluster = clusterNodes.length > 0;
|
||||||
|
return usesRedisCluster
|
||||||
|
? getRedisClusterClient(Redis, additionalRedisOptions, redisType)
|
||||||
|
: getRedisStandardClient(Redis, additionalRedisOptions, redisType);
|
||||||
|
}
|
57
packages/cli/src/services/redis/RedisServiceListReceiver.ts
Normal file
57
packages/cli/src/services/redis/RedisServiceListReceiver.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { WORKER_RESPONSE_REDIS_LIST } from './RedisServiceHelper';
|
||||||
|
import type { RedisServiceWorkerResponseObject } from './RedisServiceCommands';
|
||||||
|
import { RedisServiceBaseReceiver } from './RedisServiceBaseClasses';
|
||||||
|
import { LoggerProxy, jsonParse } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RedisServiceListReceiver extends RedisServiceBaseReceiver {
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await super.init('list-receiver');
|
||||||
|
}
|
||||||
|
|
||||||
|
async popFromHead(list: string): Promise<string | null | undefined> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
return this.redisClient?.lpop(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async popFromTail(list: string): Promise<string | null | undefined> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
return this.redisClient?.rpop(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private poppedResultToWorkerResponse(
|
||||||
|
poppedResult: string | null | undefined,
|
||||||
|
list: string = WORKER_RESPONSE_REDIS_LIST,
|
||||||
|
): RedisServiceWorkerResponseObject | null {
|
||||||
|
if (poppedResult) {
|
||||||
|
try {
|
||||||
|
const workerResponse = jsonParse<RedisServiceWorkerResponseObject>(poppedResult);
|
||||||
|
if (workerResponse) {
|
||||||
|
// TODO: Handle worker response
|
||||||
|
console.log('Received worker response', workerResponse);
|
||||||
|
}
|
||||||
|
return workerResponse;
|
||||||
|
} catch (error) {
|
||||||
|
LoggerProxy.warn(
|
||||||
|
`Error parsing worker response on list ${list}: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async popOldestWorkerResponse(): Promise<RedisServiceWorkerResponseObject | null> {
|
||||||
|
const poppedResult = await this.popFromTail(WORKER_RESPONSE_REDIS_LIST);
|
||||||
|
return this.poppedResultToWorkerResponse(poppedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
async popLatestWorkerResponse(): Promise<RedisServiceWorkerResponseObject | null> {
|
||||||
|
const poppedResult = await this.popFromHead(WORKER_RESPONSE_REDIS_LIST);
|
||||||
|
return this.poppedResultToWorkerResponse(poppedResult);
|
||||||
|
}
|
||||||
|
}
|
30
packages/cli/src/services/redis/RedisServiceListSender.ts
Normal file
30
packages/cli/src/services/redis/RedisServiceListSender.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { WORKER_RESPONSE_REDIS_LIST } from './RedisServiceHelper';
|
||||||
|
import type { RedisServiceWorkerResponseObject } from './RedisServiceCommands';
|
||||||
|
import { RedisServiceBaseSender } from './RedisServiceBaseClasses';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RedisServiceListSender extends RedisServiceBaseSender {
|
||||||
|
async init(senderId?: string): Promise<void> {
|
||||||
|
await super.init('list-sender');
|
||||||
|
this.setSenderId(senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepend(list: string, message: string): Promise<void> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
await this.redisClient?.lpush(list, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async append(list: string, message: string): Promise<void> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
await this.redisClient?.rpush(list, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendWorkerResponse(message: RedisServiceWorkerResponseObject): Promise<void> {
|
||||||
|
await this.prepend(WORKER_RESPONSE_REDIS_LIST, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import type { AbstractEventMessage } from '@/eventbus/EventMessageClasses/AbstractEventMessage';
|
||||||
|
import {
|
||||||
|
COMMAND_REDIS_CHANNEL,
|
||||||
|
EVENT_BUS_REDIS_CHANNEL,
|
||||||
|
WORKER_RESPONSE_REDIS_CHANNEL,
|
||||||
|
} from './RedisServiceHelper';
|
||||||
|
import type {
|
||||||
|
RedisServiceCommandObject,
|
||||||
|
RedisServiceWorkerResponseObject,
|
||||||
|
} from './RedisServiceCommands';
|
||||||
|
import { RedisServiceBaseSender } from './RedisServiceBaseClasses';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RedisServicePubSubPublisher extends RedisServiceBaseSender {
|
||||||
|
async init(senderId?: string): Promise<void> {
|
||||||
|
await super.init('publisher');
|
||||||
|
this.setSenderId(senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(channel: string, message: string): Promise<void> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
await this.redisClient?.publish(channel, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishToEventLog(message: AbstractEventMessage): Promise<void> {
|
||||||
|
await this.publish(EVENT_BUS_REDIS_CHANNEL, message.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishToCommandChannel(message: RedisServiceCommandObject): Promise<void> {
|
||||||
|
await this.publish(COMMAND_REDIS_CHANNEL, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishToWorkerChannel(message: RedisServiceWorkerResponseObject): Promise<void> {
|
||||||
|
await this.publish(WORKER_RESPONSE_REDIS_CHANNEL, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { LoggerProxy as Logger } from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
COMMAND_REDIS_CHANNEL,
|
||||||
|
EVENT_BUS_REDIS_CHANNEL,
|
||||||
|
WORKER_RESPONSE_REDIS_CHANNEL,
|
||||||
|
} from './RedisServiceHelper';
|
||||||
|
import { RedisServiceBaseReceiver } from './RedisServiceBaseClasses';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RedisServicePubSubSubscriber extends RedisServiceBaseReceiver {
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await super.init('subscriber');
|
||||||
|
|
||||||
|
this.redisClient?.on('message', (channel: string, message: string) => {
|
||||||
|
this.messageHandlers.forEach((handler: (channel: string, message: string) => void) =>
|
||||||
|
handler(channel, message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(channel: string): Promise<void> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
await this.redisClient?.subscribe(channel, (error, count: number) => {
|
||||||
|
if (error) {
|
||||||
|
Logger.error(`Error subscribing to channel ${channel}`);
|
||||||
|
} else {
|
||||||
|
Logger.debug(`Subscribed ${count.toString()} to eventlog channel`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToEventLog(): Promise<void> {
|
||||||
|
await this.subscribe(EVENT_BUS_REDIS_CHANNEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToCommandChannel(): Promise<void> {
|
||||||
|
await this.subscribe(COMMAND_REDIS_CHANNEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeToWorkerResponseChannel(): Promise<void> {
|
||||||
|
await this.subscribe(WORKER_RESPONSE_REDIS_CHANNEL);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
import { RedisServiceBaseReceiver } from './RedisServiceBaseClasses';
|
||||||
|
|
||||||
|
type LastId = string;
|
||||||
|
|
||||||
|
type StreamName = string;
|
||||||
|
|
||||||
|
type StreamDetails = {
|
||||||
|
lastId: LastId;
|
||||||
|
pollingInterval: number;
|
||||||
|
waiter: NodeJS.Timer | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RedisServiceStreamConsumer extends RedisServiceBaseReceiver {
|
||||||
|
// while actively listening, the stream name and last id are stored here
|
||||||
|
// removing the entry will stop the listener
|
||||||
|
streams: Map<StreamName, StreamDetails> = new Map();
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await super.init('consumer');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listenToStream(stream: StreamName, lastId = '$'): Promise<void> {
|
||||||
|
if (!this.redisClient) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
LoggerProxy.debug(`Redis client now listening to stream ${stream} starting with id ${lastId}`);
|
||||||
|
this.setLastId(stream, lastId);
|
||||||
|
const interval = this.streams.get(stream)?.pollingInterval ?? 1000;
|
||||||
|
const waiter = setInterval(async () => {
|
||||||
|
const currentLastId = this.streams.get(stream)?.lastId ?? '$';
|
||||||
|
const results = await this.redisClient?.xread(
|
||||||
|
'BLOCK',
|
||||||
|
interval,
|
||||||
|
'STREAMS',
|
||||||
|
stream,
|
||||||
|
currentLastId,
|
||||||
|
);
|
||||||
|
if (results && results.length > 0) {
|
||||||
|
const [_key, messages] = results[0];
|
||||||
|
if (messages.length > 0) {
|
||||||
|
messages.forEach(([id, message]) => {
|
||||||
|
this.messageHandlers.forEach((handler) => handler(stream, id, message));
|
||||||
|
});
|
||||||
|
// Pass the last id of the results to the next round.
|
||||||
|
const newLastId = messages[messages.length - 1][0];
|
||||||
|
this.setLastId(stream, newLastId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
this.setWaiter(stream, waiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopListeningToStream(stream: StreamName): void {
|
||||||
|
LoggerProxy.debug(`Redis client stopped listening to stream ${stream}`);
|
||||||
|
const existing = this.streams.get(stream);
|
||||||
|
if (existing?.waiter) {
|
||||||
|
clearInterval(existing.waiter);
|
||||||
|
}
|
||||||
|
this.streams.delete(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStreamDetails(stream: StreamName, details: Partial<StreamDetails>): void {
|
||||||
|
const existing = this.streams.get(stream);
|
||||||
|
this.streams.set(stream, {
|
||||||
|
lastId: details.lastId ?? existing?.lastId ?? '$',
|
||||||
|
waiter: details.waiter ?? existing?.waiter,
|
||||||
|
pollingInterval: details.pollingInterval ?? existing?.pollingInterval ?? 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPollingInterval(stream: StreamName, pollingInterval: number): Promise<void> {
|
||||||
|
this.updateStreamDetails(stream, { pollingInterval });
|
||||||
|
if (this.streams.get(stream)?.waiter) {
|
||||||
|
this.stopListeningToStream(stream);
|
||||||
|
await this.listenToStream(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastId(stream: StreamName, lastId: string): void {
|
||||||
|
this.updateStreamDetails(stream, { lastId });
|
||||||
|
}
|
||||||
|
|
||||||
|
setWaiter(stream: StreamName, waiter: NodeJS.Timeout): void {
|
||||||
|
// only update the waiter if the stream is still being listened to
|
||||||
|
if (this.streams.get(stream)) {
|
||||||
|
this.updateStreamDetails(stream, { waiter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { RedisValue } from 'ioredis';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import type { AbstractEventMessage } from '@/eventbus/EventMessageClasses/AbstractEventMessage';
|
||||||
|
import {
|
||||||
|
COMMAND_REDIS_STREAM,
|
||||||
|
EVENT_BUS_REDIS_STREAM,
|
||||||
|
WORKER_RESPONSE_REDIS_STREAM,
|
||||||
|
} from './RedisServiceHelper';
|
||||||
|
import type {
|
||||||
|
RedisServiceCommandObject,
|
||||||
|
RedisServiceWorkerResponseObject,
|
||||||
|
} from './RedisServiceCommands';
|
||||||
|
import { RedisServiceBaseSender } from './RedisServiceBaseClasses';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RedisServiceStreamProducer extends RedisServiceBaseSender {
|
||||||
|
async init(senderId?: string): Promise<void> {
|
||||||
|
await super.init('producer');
|
||||||
|
this.setSenderId(senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(streamName: string, values: RedisValue[]): Promise<void> {
|
||||||
|
await this.redisClient?.xadd(streamName, '*', 'senderId', this.senderId, ...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToEventStream(message: AbstractEventMessage): Promise<void> {
|
||||||
|
await this.add(EVENT_BUS_REDIS_STREAM, [
|
||||||
|
'message',
|
||||||
|
message.eventName,
|
||||||
|
'event',
|
||||||
|
message.toString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToCommandChannel(message: RedisServiceCommandObject): Promise<void> {
|
||||||
|
await this.add(COMMAND_REDIS_STREAM, ['command', JSON.stringify(message)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToWorkerChannel(message: RedisServiceWorkerResponseObject): Promise<void> {
|
||||||
|
await this.add(WORKER_RESPONSE_REDIS_STREAM, ['response', JSON.stringify(message)]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +1,59 @@
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
import { CacheService } from '@/services/cache.service';
|
import { CacheService } from '@/services/cache.service';
|
||||||
import type { MemoryCache } from 'cache-manager';
|
import type { MemoryCache } from 'cache-manager';
|
||||||
// import type { RedisCache } from 'cache-manager-ioredis-yet';
|
import type { RedisCache } from 'cache-manager-ioredis-yet';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
import { getLogger } from '@/Logger';
|
||||||
|
|
||||||
const cacheService = Container.get(CacheService);
|
const cacheService = Container.get(CacheService);
|
||||||
|
|
||||||
function setDefaultConfig() {
|
function setDefaultConfig() {
|
||||||
config.set('executions.mode', 'regular');
|
config.set('executions.mode', 'regular');
|
||||||
config.set('cache.backend', 'auto');
|
config.set('cache.enabled', true);
|
||||||
|
config.set('cache.backend', 'memory');
|
||||||
config.set('cache.memory.maxSize', 1 * 1024 * 1024);
|
config.set('cache.memory.maxSize', 1 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TestObject {
|
||||||
|
test: string;
|
||||||
|
test2: number;
|
||||||
|
test3?: TestObject & { test4: TestObject };
|
||||||
|
}
|
||||||
|
|
||||||
|
const testObject: TestObject = {
|
||||||
|
test: 'test',
|
||||||
|
test2: 123,
|
||||||
|
test3: {
|
||||||
|
test: 'test3',
|
||||||
|
test2: 123,
|
||||||
|
test4: {
|
||||||
|
test: 'test4',
|
||||||
|
test2: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe('cacheService', () => {
|
describe('cacheService', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
LoggerProxy.init(getLogger());
|
||||||
|
jest.mock('ioredis', () => {
|
||||||
|
const Redis = require('ioredis-mock');
|
||||||
|
if (typeof Redis === 'object') {
|
||||||
|
// the first mock is an ioredis shim because ioredis-mock depends on it
|
||||||
|
// https://github.com/stipsan/ioredis-mock/blob/master/src/index.js#L101-L111
|
||||||
|
return {
|
||||||
|
Command: { _transformer: { argument: {}, reply: {} } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// second mock for our code
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return function (...args: any) {
|
||||||
|
return new Redis(args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
setDefaultConfig();
|
setDefaultConfig();
|
||||||
await Container.get(CacheService).destroy();
|
await Container.get(CacheService).destroy();
|
||||||
|
@ -29,43 +70,43 @@ describe('cacheService', () => {
|
||||||
test('should cache and retrieve a value', async () => {
|
test('should cache and retrieve a value', async () => {
|
||||||
await cacheService.init();
|
await cacheService.init();
|
||||||
await expect(cacheService.getCache()).resolves.toBeDefined();
|
await expect(cacheService.getCache()).resolves.toBeDefined();
|
||||||
await cacheService.set<string>('testString', 'test');
|
await cacheService.set('testString', 'test');
|
||||||
await cacheService.set<number>('testNumber', 123);
|
await cacheService.set('testNumber1', 123);
|
||||||
|
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBe('test');
|
await expect(cacheService.get('testString')).resolves.toBe('test');
|
||||||
expect(typeof (await cacheService.get<string>('testString'))).toBe('string');
|
expect(typeof (await cacheService.get('testString'))).toBe('string');
|
||||||
await expect(cacheService.get<number>('testNumber')).resolves.toBe(123);
|
await expect(cacheService.get('testNumber1')).resolves.toBe(123);
|
||||||
expect(typeof (await cacheService.get<number>('testNumber'))).toBe('number');
|
expect(typeof (await cacheService.get('testNumber1'))).toBe('number');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should honour ttl values', async () => {
|
test('should honour ttl values', async () => {
|
||||||
// set default TTL to 10ms
|
// set default TTL to 10ms
|
||||||
config.set('cache.memory.ttl', 10);
|
config.set('cache.memory.ttl', 10);
|
||||||
|
|
||||||
await cacheService.set<string>('testString', 'test');
|
await cacheService.set('testString', 'test');
|
||||||
await cacheService.set<number>('testNumber', 123, 1000);
|
await cacheService.set('testNumber1', 123, 1000);
|
||||||
|
|
||||||
const store = (await cacheService.getCache())?.store;
|
const store = (await cacheService.getCache())?.store;
|
||||||
|
|
||||||
expect(store).toBeDefined();
|
expect(store).toBeDefined();
|
||||||
|
|
||||||
await expect(store!.ttl('testString')).resolves.toBeLessThanOrEqual(100);
|
await expect(store!.ttl('testString')).resolves.toBeLessThanOrEqual(100);
|
||||||
await expect(store!.ttl('testNumber')).resolves.toBeLessThanOrEqual(1000);
|
await expect(store!.ttl('testNumber1')).resolves.toBeLessThanOrEqual(1000);
|
||||||
|
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBe('test');
|
await expect(cacheService.get('testString')).resolves.toBe('test');
|
||||||
await expect(cacheService.get<number>('testNumber')).resolves.toBe(123);
|
await expect(cacheService.get('testNumber1')).resolves.toBe(123);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBeUndefined();
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
await expect(cacheService.get<number>('testNumber')).resolves.toBe(123);
|
await expect(cacheService.get('testNumber1')).resolves.toBe(123);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should set and remove values', async () => {
|
test('should set and remove values', async () => {
|
||||||
await cacheService.set<string>('testString', 'test');
|
await cacheService.set('testString', 'test');
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBe('test');
|
await expect(cacheService.get('testString')).resolves.toBe('test');
|
||||||
await cacheService.delete('testString');
|
await cacheService.delete('testString');
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBeUndefined();
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should calculate maxSize', async () => {
|
test('should calculate maxSize', async () => {
|
||||||
|
@ -73,65 +114,228 @@ describe('cacheService', () => {
|
||||||
await cacheService.destroy();
|
await cacheService.destroy();
|
||||||
|
|
||||||
// 16 bytes because stringify wraps the string in quotes, so 2 bytes for the quotes
|
// 16 bytes because stringify wraps the string in quotes, so 2 bytes for the quotes
|
||||||
await cacheService.set<string>('testString', 'withoutUnicode');
|
await cacheService.set('testString', 'withoutUnicode');
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBe('withoutUnicode');
|
await expect(cacheService.get('testString')).resolves.toBe('withoutUnicode');
|
||||||
|
|
||||||
await cacheService.destroy();
|
await cacheService.destroy();
|
||||||
|
|
||||||
// should not fit!
|
// should not fit!
|
||||||
await cacheService.set<string>('testString', 'withUnicodeԱԲԳ');
|
await cacheService.set('testString', 'withUnicodeԱԲԳ');
|
||||||
await expect(cacheService.get<string>('testString')).resolves.toBeUndefined();
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should set and get complex objects', async () => {
|
test('should set and get complex objects', async () => {
|
||||||
interface TestObject {
|
await cacheService.set('testObject', testObject);
|
||||||
test: string;
|
await expect(cacheService.get('testObject')).resolves.toMatchObject(testObject);
|
||||||
test2: number;
|
|
||||||
test3?: TestObject & { test4: TestObject };
|
|
||||||
}
|
|
||||||
|
|
||||||
const testObject: TestObject = {
|
|
||||||
test: 'test',
|
|
||||||
test2: 123,
|
|
||||||
test3: {
|
|
||||||
test: 'test3',
|
|
||||||
test2: 123,
|
|
||||||
test4: {
|
|
||||||
test: 'test4',
|
|
||||||
test2: 123,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await cacheService.set<TestObject>('testObject', testObject);
|
|
||||||
await expect(cacheService.get<TestObject>('testObject')).resolves.toMatchObject(testObject);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should set and get multiple values', async () => {
|
test('should set and get multiple values', async () => {
|
||||||
config.set('executions.mode', 'regular');
|
await cacheService.destroy();
|
||||||
config.set('cache.backend', 'auto');
|
expect(cacheService.isRedisCache()).toBe(false);
|
||||||
|
|
||||||
await cacheService.setMany<string>([
|
await cacheService.setMany([
|
||||||
['testString', 'test'],
|
['testString', 'test'],
|
||||||
['testString2', 'test2'],
|
['testString2', 'test2'],
|
||||||
]);
|
]);
|
||||||
await cacheService.setMany<number>([
|
await cacheService.setMany([
|
||||||
['testNumber', 123],
|
['testNumber1', 123],
|
||||||
|
['testNumber2', 456],
|
||||||
|
]);
|
||||||
|
await expect(cacheService.getMany(['testString', 'testString2'])).resolves.toStrictEqual([
|
||||||
|
'test',
|
||||||
|
'test2',
|
||||||
|
]);
|
||||||
|
await expect(cacheService.getMany(['testNumber1', 'testNumber2'])).resolves.toStrictEqual([
|
||||||
|
123, 456,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a redis in queue mode', async () => {
|
||||||
|
config.set('cache.backend', 'auto');
|
||||||
|
config.set('executions.mode', 'queue');
|
||||||
|
await cacheService.destroy();
|
||||||
|
await cacheService.init();
|
||||||
|
|
||||||
|
const cache = await cacheService.getCache();
|
||||||
|
await expect(cacheService.getCache()).resolves.toBeDefined();
|
||||||
|
const candidate = (await cacheService.getCache()) as RedisCache;
|
||||||
|
expect(candidate.store.client).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a redis cache if asked', async () => {
|
||||||
|
config.set('cache.backend', 'redis');
|
||||||
|
config.set('executions.mode', 'queue');
|
||||||
|
await cacheService.destroy();
|
||||||
|
await cacheService.init();
|
||||||
|
|
||||||
|
const cache = await cacheService.getCache();
|
||||||
|
await expect(cacheService.getCache()).resolves.toBeDefined();
|
||||||
|
const candidate = (await cacheService.getCache()) as RedisCache;
|
||||||
|
expect(candidate.store.client).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get/set/delete redis cache', async () => {
|
||||||
|
config.set('cache.backend', 'redis');
|
||||||
|
config.set('executions.mode', 'queue');
|
||||||
|
await cacheService.destroy();
|
||||||
|
await cacheService.init();
|
||||||
|
|
||||||
|
await cacheService.set('testObject', testObject);
|
||||||
|
await expect(cacheService.get('testObject')).resolves.toMatchObject(testObject);
|
||||||
|
await cacheService.delete('testObject');
|
||||||
|
await expect(cacheService.get('testObject')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: mset and mget are not supported by ioredis-mock
|
||||||
|
// test('should set and get multiple values with redis', async () => {
|
||||||
|
// });
|
||||||
|
|
||||||
|
test('should return fallback value if key is not set', async () => {
|
||||||
|
await cacheService.reset();
|
||||||
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
cacheService.get('testString', {
|
||||||
|
fallbackValue: 'fallback',
|
||||||
|
}),
|
||||||
|
).resolves.toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call refreshFunction if key is not set', async () => {
|
||||||
|
await cacheService.reset();
|
||||||
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
cacheService.get('testString', {
|
||||||
|
refreshFunction: async () => 'refreshed',
|
||||||
|
fallbackValue: 'this should not be returned',
|
||||||
|
}),
|
||||||
|
).resolves.toBe('refreshed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should transparently handle disabled cache', async () => {
|
||||||
|
await cacheService.disable();
|
||||||
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
|
await cacheService.set('testString', 'whatever');
|
||||||
|
await expect(cacheService.get('testString')).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
cacheService.get('testString', {
|
||||||
|
fallbackValue: 'fallback',
|
||||||
|
}),
|
||||||
|
).resolves.toBe('fallback');
|
||||||
|
await expect(
|
||||||
|
cacheService.get('testString', {
|
||||||
|
refreshFunction: async () => 'refreshed',
|
||||||
|
fallbackValue: 'this should not be returned',
|
||||||
|
}),
|
||||||
|
).resolves.toBe('refreshed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set and get partial results', async () => {
|
||||||
|
await cacheService.setMany([
|
||||||
|
['testNumber1', 123],
|
||||||
|
['testNumber2', 456],
|
||||||
|
]);
|
||||||
|
await expect(cacheService.getMany(['testNumber1', 'testNumber2'])).resolves.toStrictEqual([
|
||||||
|
123, 456,
|
||||||
|
]);
|
||||||
|
await expect(cacheService.getMany(['testNumber3', 'testNumber2'])).resolves.toStrictEqual([
|
||||||
|
undefined,
|
||||||
|
456,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should getMany and fix partial results and set single key', async () => {
|
||||||
|
await cacheService.setMany([
|
||||||
|
['testNumber1', 123],
|
||||||
['testNumber2', 456],
|
['testNumber2', 456],
|
||||||
]);
|
]);
|
||||||
await expect(
|
await expect(
|
||||||
cacheService.getMany<string>(['testString', 'testString2']),
|
cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3']),
|
||||||
).resolves.toStrictEqual(['test', 'test2']);
|
).resolves.toStrictEqual([123, 456, undefined]);
|
||||||
|
await expect(cacheService.get('testNumber3')).resolves.toBeUndefined();
|
||||||
await expect(
|
await expect(
|
||||||
cacheService.getMany<number>(['testNumber', 'testNumber2']),
|
cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3'], {
|
||||||
|
async refreshFunctionEach(key) {
|
||||||
|
return key === 'testNumber3' ? 789 : undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toStrictEqual([123, 456, 789]);
|
||||||
|
await expect(cacheService.get('testNumber3')).resolves.toBe(789);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should getMany and set all keys', async () => {
|
||||||
|
await cacheService.setMany([
|
||||||
|
['testNumber1', 123],
|
||||||
|
['testNumber2', 456],
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3']),
|
||||||
|
).resolves.toStrictEqual([123, 456, undefined]);
|
||||||
|
await expect(cacheService.get('testNumber3')).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3'], {
|
||||||
|
async refreshFunctionMany(keys) {
|
||||||
|
return [111, 222, 333];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toStrictEqual([111, 222, 333]);
|
||||||
|
await expect(cacheService.get('testNumber1')).resolves.toBe(111);
|
||||||
|
await expect(cacheService.get('testNumber2')).resolves.toBe(222);
|
||||||
|
await expect(cacheService.get('testNumber3')).resolves.toBe(333);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set and get multiple values with fallbackValue', async () => {
|
||||||
|
await cacheService.disable();
|
||||||
|
await cacheService.setMany([
|
||||||
|
['testNumber1', 123],
|
||||||
|
['testNumber2', 456],
|
||||||
|
]);
|
||||||
|
await expect(cacheService.getMany(['testNumber1', 'testNumber2'])).resolves.toStrictEqual([
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
cacheService.getMany(['testNumber1', 'testNumber2'], {
|
||||||
|
fallbackValues: [123, 456],
|
||||||
|
}),
|
||||||
|
).resolves.toStrictEqual([123, 456]);
|
||||||
|
await expect(
|
||||||
|
cacheService.getMany(['testNumber1', 'testNumber2'], {
|
||||||
|
refreshFunctionMany: async () => [123, 456],
|
||||||
|
fallbackValues: [0, 1],
|
||||||
|
}),
|
||||||
).resolves.toStrictEqual([123, 456]);
|
).resolves.toStrictEqual([123, 456]);
|
||||||
});
|
});
|
||||||
// This test is skipped because it requires the Redis service
|
|
||||||
// test('should create a redis cache if asked', async () => {
|
test('should deal with unicode keys', async () => {
|
||||||
// config.set('cache.backend', 'redis');
|
const key = '? > ":< ! withUnicodeԱԲԳ';
|
||||||
// await cacheService.init();
|
await cacheService.set(key, 'test');
|
||||||
// expect(cacheService.getCacheInstance()).toBeDefined();
|
await expect(cacheService.get(key)).resolves.toBe('test');
|
||||||
// const candidate = cacheService.getCacheInstance() as RedisCache;
|
await cacheService.delete(key);
|
||||||
// expect(candidate.store.client).toBeDefined();
|
await expect(cacheService.get(key)).resolves.toBeUndefined();
|
||||||
// });
|
});
|
||||||
|
|
||||||
|
test('should deal with unicode keys in redis', async () => {
|
||||||
|
config.set('cache.backend', 'redis');
|
||||||
|
config.set('executions.mode', 'queue');
|
||||||
|
await cacheService.destroy();
|
||||||
|
await cacheService.init();
|
||||||
|
const key = '? > ":< ! withUnicodeԱԲԳ';
|
||||||
|
|
||||||
|
expect(((await cacheService.getCache()) as RedisCache).store.client).toBeDefined();
|
||||||
|
|
||||||
|
await cacheService.set(key, 'test');
|
||||||
|
await expect(cacheService.get(key)).resolves.toBe('test');
|
||||||
|
await cacheService.delete(key);
|
||||||
|
await expect(cacheService.get(key)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not cache null or undefined values', async () => {
|
||||||
|
await cacheService.set('nullValue', null);
|
||||||
|
await cacheService.set('undefValue', undefined);
|
||||||
|
await cacheService.set('normalValue', 'test');
|
||||||
|
|
||||||
|
await expect(cacheService.get('normalValue')).resolves.toBe('test');
|
||||||
|
await expect(cacheService.get('undefValue')).resolves.toBeUndefined();
|
||||||
|
await expect(cacheService.get('nullValue')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
138
packages/cli/test/unit/services/redis.service.test.ts
Normal file
138
packages/cli/test/unit/services/redis.service.test.ts
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import Container from 'typedi';
|
||||||
|
import config from '@/config';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
import { getLogger } from '@/Logger';
|
||||||
|
import { RedisService } from '@/services/redis.service';
|
||||||
|
|
||||||
|
const redisService = Container.get(RedisService);
|
||||||
|
|
||||||
|
function setDefaultConfig() {
|
||||||
|
config.set('executions.mode', 'queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestObject {
|
||||||
|
test: string;
|
||||||
|
test2: number;
|
||||||
|
test3?: TestObject & { test4: TestObject };
|
||||||
|
}
|
||||||
|
|
||||||
|
const testObject: TestObject = {
|
||||||
|
test: 'test',
|
||||||
|
test2: 123,
|
||||||
|
test3: {
|
||||||
|
test: 'test3',
|
||||||
|
test2: 123,
|
||||||
|
test4: {
|
||||||
|
test: 'test4',
|
||||||
|
test2: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBSUB_CHANNEL = 'testchannel';
|
||||||
|
const LIST_CHANNEL = 'testlist';
|
||||||
|
const STREAM_CHANNEL = 'teststream';
|
||||||
|
|
||||||
|
describe('cacheService', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
LoggerProxy.init(getLogger());
|
||||||
|
jest.mock('ioredis', () => {
|
||||||
|
const Redis = require('ioredis-mock');
|
||||||
|
if (typeof Redis === 'object') {
|
||||||
|
// the first mock is an ioredis shim because ioredis-mock depends on it
|
||||||
|
// https://github.com/stipsan/ioredis-mock/blob/master/src/index.js#L101-L111
|
||||||
|
return {
|
||||||
|
Command: { _transformer: { argument: {}, reply: {} } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// second mock for our code
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return function (...args: any) {
|
||||||
|
return new Redis(args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setDefaultConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create pubsub publisher and subscriber with handler', async () => {
|
||||||
|
const pub = await redisService.getPubSubPublisher();
|
||||||
|
const sub = await redisService.getPubSubSubscriber();
|
||||||
|
expect(pub).toBeDefined();
|
||||||
|
expect(sub).toBeDefined();
|
||||||
|
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
mockHandler.mockImplementation((channel: string, message: string) => {});
|
||||||
|
sub.addMessageHandler(PUBSUB_CHANNEL, mockHandler);
|
||||||
|
await sub.subscribe(PUBSUB_CHANNEL);
|
||||||
|
await pub.publish(PUBSUB_CHANNEL, 'test');
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(async () => {
|
||||||
|
resolve(0);
|
||||||
|
}, 50),
|
||||||
|
);
|
||||||
|
expect(mockHandler).toHaveBeenCalled();
|
||||||
|
await sub.destroy();
|
||||||
|
await pub.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create list sender and receiver', async () => {
|
||||||
|
const sender = await redisService.getListSender();
|
||||||
|
const receiver = await redisService.getListReceiver();
|
||||||
|
expect(sender).toBeDefined();
|
||||||
|
expect(receiver).toBeDefined();
|
||||||
|
await sender.prepend(LIST_CHANNEL, 'middle');
|
||||||
|
await sender.prepend(LIST_CHANNEL, 'first');
|
||||||
|
await sender.append(LIST_CHANNEL, 'end');
|
||||||
|
let popResult = await receiver.popFromHead(LIST_CHANNEL);
|
||||||
|
expect(popResult).toBe('first');
|
||||||
|
popResult = await receiver.popFromTail(LIST_CHANNEL);
|
||||||
|
expect(popResult).toBe('end');
|
||||||
|
await sender.prepend(LIST_CHANNEL, 'somevalue');
|
||||||
|
popResult = await receiver.popFromTail(LIST_CHANNEL);
|
||||||
|
expect(popResult).toBe('middle');
|
||||||
|
await sender.destroy();
|
||||||
|
await receiver.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: This test is failing because the mock Redis client does not support streams apparently
|
||||||
|
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||||
|
test.skip('should create stream producer and consumer', async () => {
|
||||||
|
const consumer = await redisService.getStreamConsumer();
|
||||||
|
const producer = await redisService.getStreamProducer();
|
||||||
|
|
||||||
|
expect(consumer).toBeDefined();
|
||||||
|
expect(producer).toBeDefined();
|
||||||
|
|
||||||
|
const mockHandler = jest.fn();
|
||||||
|
mockHandler.mockImplementation((stream: string, id: string, message: string[]) => {
|
||||||
|
console.log('Received message', stream, id, message);
|
||||||
|
});
|
||||||
|
consumer.addMessageHandler('some handler', mockHandler);
|
||||||
|
|
||||||
|
await consumer.setPollingInterval(STREAM_CHANNEL, 50);
|
||||||
|
await consumer.listenToStream(STREAM_CHANNEL);
|
||||||
|
|
||||||
|
let timeout;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
timeout = setTimeout(async () => {
|
||||||
|
await producer.add(STREAM_CHANNEL, ['message', 'testMessage', 'event', 'testEveny']);
|
||||||
|
resolve(0);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(async () => {
|
||||||
|
resolve(0);
|
||||||
|
}, 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
clearInterval(timeout);
|
||||||
|
|
||||||
|
consumer.stopListeningToStream(STREAM_CHANNEL);
|
||||||
|
|
||||||
|
expect(mockHandler).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await consumer.destroy();
|
||||||
|
await producer.destroy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -557,6 +557,9 @@ importers:
|
||||||
concurrently:
|
concurrently:
|
||||||
specifier: ^8.2.0
|
specifier: ^8.2.0
|
||||||
version: 8.2.0
|
version: 8.2.0
|
||||||
|
ioredis-mock:
|
||||||
|
specifier: ^8.8.1
|
||||||
|
version: 8.8.1(@types/ioredis-mock@8.2.2)(ioredis@5.2.4)
|
||||||
ts-essentials:
|
ts-essentials:
|
||||||
specifier: ^7.0.3
|
specifier: ^7.0.3
|
||||||
version: 7.0.3(typescript@5.1.6)
|
version: 7.0.3(typescript@5.1.6)
|
||||||
|
@ -3599,9 +3602,12 @@ packages:
|
||||||
'@intlify/shared': 9.2.2
|
'@intlify/shared': 9.2.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@ioredis/as-callback@3.0.0:
|
||||||
|
resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@ioredis/commands@1.2.0:
|
/@ioredis/commands@1.2.0:
|
||||||
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
|
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@isaacs/cliui@8.0.2:
|
/@isaacs/cliui@8.0.2:
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
|
@ -6199,6 +6205,14 @@ packages:
|
||||||
rxjs: 6.6.7
|
rxjs: 6.6.7
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/ioredis-mock@8.2.2:
|
||||||
|
resolution: {integrity: sha512-bnbPHOjxy4TUDjRh61MMoK2QvDNZqrMDXJYrEDZP/HPFvBubR24CQ0DBi5lgWhLxG4lvVsXPRDXtZ03+JgonoQ==}
|
||||||
|
dependencies:
|
||||||
|
ioredis: 5.3.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/is-function@1.0.1:
|
/@types/is-function@1.0.1:
|
||||||
resolution: {integrity: sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==}
|
resolution: {integrity: sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -8060,7 +8074,7 @@ packages:
|
||||||
/axios@0.21.4:
|
/axios@0.21.4:
|
||||||
resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
|
resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.2(debug@4.3.4)
|
follow-redirects: 1.15.2(debug@3.2.7)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -8089,6 +8103,7 @@ packages:
|
||||||
form-data: 4.0.0
|
form-data: 4.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
dev: true
|
||||||
|
|
||||||
/babel-core@7.0.0-bridge.0(@babel/core@7.22.9):
|
/babel-core@7.0.0-bridge.0(@babel/core@7.22.9):
|
||||||
resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==}
|
resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==}
|
||||||
|
@ -9027,7 +9042,6 @@ packages:
|
||||||
/cluster-key-slot@1.1.1:
|
/cluster-key-slot@1.1.1:
|
||||||
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/co@4.6.0:
|
/co@4.6.0:
|
||||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||||
|
@ -9943,7 +9957,6 @@ packages:
|
||||||
/denque@2.1.0:
|
/denque@2.1.0:
|
||||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/depd@1.1.2:
|
/depd@1.1.2:
|
||||||
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
||||||
|
@ -11415,6 +11428,22 @@ packages:
|
||||||
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
|
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/fengari-interop@0.1.3(fengari@0.1.4):
|
||||||
|
resolution: {integrity: sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==}
|
||||||
|
peerDependencies:
|
||||||
|
fengari: ^0.1.0
|
||||||
|
dependencies:
|
||||||
|
fengari: 0.1.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/fengari@0.1.4:
|
||||||
|
resolution: {integrity: sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==}
|
||||||
|
dependencies:
|
||||||
|
readline-sync: 1.4.10
|
||||||
|
sprintf-js: 1.1.2
|
||||||
|
tmp: 0.0.33
|
||||||
|
dev: true
|
||||||
|
|
||||||
/fetch-retry@5.0.3:
|
/fetch-retry@5.0.3:
|
||||||
resolution: {integrity: sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==}
|
resolution: {integrity: sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -11661,6 +11690,7 @@ packages:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.4(supports-color@8.1.1)
|
debug: 4.3.4(supports-color@8.1.1)
|
||||||
|
dev: true
|
||||||
|
|
||||||
/for-each@0.3.3:
|
/for-each@0.3.3:
|
||||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||||
|
@ -12837,6 +12867,22 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/ioredis-mock@8.8.1(@types/ioredis-mock@8.2.2)(ioredis@5.2.4):
|
||||||
|
resolution: {integrity: sha512-zXSaDf86EcDFVf8jMOirWU6Js4WcwLd/cxwJiCh9EbD1GoHfeE/fVqLhLz/l1MkyL85Fb6MwfF2Fr/9819Ul9Q==}
|
||||||
|
engines: {node: '>=12.22'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/ioredis-mock': ^8
|
||||||
|
ioredis: ^5
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/as-callback': 3.0.0
|
||||||
|
'@ioredis/commands': 1.2.0
|
||||||
|
'@types/ioredis-mock': 8.2.2
|
||||||
|
fengari: 0.1.4
|
||||||
|
fengari-interop: 0.1.3(fengari@0.1.4)
|
||||||
|
ioredis: 5.2.4
|
||||||
|
semver: 7.5.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
/ioredis@4.28.5:
|
/ioredis@4.28.5:
|
||||||
resolution: {integrity: sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==}
|
resolution: {integrity: sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -12871,7 +12917,6 @@ packages:
|
||||||
standard-as-callback: 2.1.0
|
standard-as-callback: 2.1.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: false
|
|
||||||
|
|
||||||
/ioredis@5.3.2:
|
/ioredis@5.3.2:
|
||||||
resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==}
|
resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==}
|
||||||
|
@ -12888,7 +12933,6 @@ packages:
|
||||||
standard-as-callback: 2.1.0
|
standard-as-callback: 2.1.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: false
|
|
||||||
|
|
||||||
/ip@1.1.8:
|
/ip@1.1.8:
|
||||||
resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
|
resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
|
||||||
|
@ -14712,7 +14756,6 @@ packages:
|
||||||
|
|
||||||
/lodash.defaults@4.2.0:
|
/lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/lodash.find@4.6.0:
|
/lodash.find@4.6.0:
|
||||||
resolution: {integrity: sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg==}
|
resolution: {integrity: sha512-yaRZoAV3Xq28F1iafWN1+a0rflOej93l1DQUejs3SZ41h2O9UJBoS9aueGjPDgAl4B6tPC0NuuchLKaDQQ3Isg==}
|
||||||
|
@ -14738,7 +14781,6 @@ packages:
|
||||||
|
|
||||||
/lodash.isarguments@3.1.0:
|
/lodash.isarguments@3.1.0:
|
||||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/lodash.isempty@4.4.0:
|
/lodash.isempty@4.4.0:
|
||||||
resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==}
|
resolution: {integrity: sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==}
|
||||||
|
@ -16222,7 +16264,6 @@ packages:
|
||||||
/os-tmpdir@1.0.2:
|
/os-tmpdir@1.0.2:
|
||||||
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/ospath@1.2.2:
|
/ospath@1.2.2:
|
||||||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||||
|
@ -16990,7 +17031,7 @@ packages:
|
||||||
resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==}
|
resolution: {integrity: sha512-aXYe/D+28kF63W8Cz53t09ypEORz+ULeDCahdAqhVrRm2scbOXFbtnn0GGhvMpYe45grepLKuwui9KxrZ2ZuMw==}
|
||||||
engines: {node: '>=14.17.0'}
|
engines: {node: '>=14.17.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 0.27.2(debug@4.3.4)
|
axios: 0.27.2(debug@3.2.7)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -17624,6 +17665,11 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
/readline-sync@1.4.10:
|
||||||
|
resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/recast@0.21.5:
|
/recast@0.21.5:
|
||||||
resolution: {integrity: sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg==}
|
resolution: {integrity: sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
@ -17681,14 +17727,12 @@ packages:
|
||||||
/redis-errors@1.2.0:
|
/redis-errors@1.2.0:
|
||||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/redis-parser@3.0.0:
|
/redis-parser@3.0.0:
|
||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
dependencies:
|
dependencies:
|
||||||
redis-errors: 1.2.0
|
redis-errors: 1.2.0
|
||||||
dev: false
|
|
||||||
|
|
||||||
/redis@3.1.2:
|
/redis@3.1.2:
|
||||||
resolution: {integrity: sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==}
|
resolution: {integrity: sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==}
|
||||||
|
@ -18755,7 +18799,6 @@ packages:
|
||||||
|
|
||||||
/sprintf-js@1.1.2:
|
/sprintf-js@1.1.2:
|
||||||
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
|
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/sqlite3@5.1.6:
|
/sqlite3@5.1.6:
|
||||||
resolution: {integrity: sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==}
|
resolution: {integrity: sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==}
|
||||||
|
@ -18843,7 +18886,6 @@ packages:
|
||||||
|
|
||||||
/standard-as-callback@2.1.0:
|
/standard-as-callback@2.1.0:
|
||||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/start-server-and-test@2.0.0:
|
/start-server-and-test@2.0.0:
|
||||||
resolution: {integrity: sha512-UqKLw0mJbfrsG1jcRLTUlvuRi9sjNuUiDOLI42r7R5fA9dsFoywAy9DoLXNYys9B886E4RCKb+qM1Gzu96h7DQ==}
|
resolution: {integrity: sha512-UqKLw0mJbfrsG1jcRLTUlvuRi9sjNuUiDOLI42r7R5fA9dsFoywAy9DoLXNYys9B886E4RCKb+qM1Gzu96h7DQ==}
|
||||||
|
@ -19561,7 +19603,6 @@ packages:
|
||||||
engines: {node: '>=0.6.0'}
|
engines: {node: '>=0.6.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
os-tmpdir: 1.0.2
|
os-tmpdir: 1.0.2
|
||||||
dev: false
|
|
||||||
|
|
||||||
/tmp@0.1.0:
|
/tmp@0.1.0:
|
||||||
resolution: {integrity: sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==}
|
resolution: {integrity: sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==}
|
||||||
|
|
Loading…
Reference in a new issue