Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 29 additions & 18 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
import { createServer } from 'node:http';
import * as de from 'descript';
import { Redis } from 'ioredis';
import { createSentinel } from '@redis/client';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { Cache } from './index';
import type { Server } from 'http';

vi.mock('ioredis', () => {
const Redis = vi.fn();
Redis.prototype.get = vi.fn().mockResolvedValue(undefined);
Redis.prototype.set = vi.fn().mockResolvedValue(undefined);
vi.mock('@redis/client', () => {
const createSentinel = vi.fn(() => {
return {
'get': vi.fn().mockResolvedValue(undefined),
'set': vi.fn().mockResolvedValue(undefined),
on() { return this;},
connect() {return this;},
};
});
return {
Redis,
createSentinel,
};
});

const redisGet = Redis.prototype.get as Mock;
const redisSet = Redis.prototype.set as Mock;

let redisGet: Mock;
let redisSet: Mock;
let cache: Cache<never>;
let server: Server;
beforeEach(() => {
const client = createSentinel({
name: 'sentinel-db',
sentinelRootNodes: [{
host: 'example',
port: 1234,
}],
masterPoolSize: 10,
replicaPoolSize: 10,
});

redisGet = client.get as Mock;
redisSet = client.set as Mock;

cache = new Cache<never>({
redis: {},
client,
});

server = createServer((req, res) => {
Expand Down Expand Up @@ -56,16 +73,13 @@ it('de.func', async() => {
expect(redisGet).toHaveBeenCalledOnce();
expect(redisGet).toHaveBeenCalledWith(
'e33d0afae8e08752efa1a467653931ae9ba60c6a3ea693e684a6a56ef3b18ba3c7e711edee33f99471d9bb2d02302e92512cc3c8513ab473bbe71d52b8f7e39a',
expect.anything(),
);

expect(redisSet).toHaveBeenCalledOnce();
expect(redisSet).toHaveBeenCalledWith(
'e33d0afae8e08752efa1a467653931ae9ba60c6a3ea693e684a6a56ef3b18ba3c7e711edee33f99471d9bb2d02302e92512cc3c8513ab473bbe71d52b8f7e39a',
'"somevalue"',
'EX',
86400,
expect.anything(),
{ expiration: { 'type': 'EX', value: 86400 } },
);
});

Expand All @@ -87,15 +101,12 @@ it('de.http', async() => {
expect(redisGet).toHaveBeenCalledOnce();
expect(redisGet).toHaveBeenCalledWith(
'e33d0afae8e08752efa1a467653931ae9ba60c6a3ea693e684a6a56ef3b18ba3c7e711edee33f99471d9bb2d02302e92512cc3c8513ab473bbe71d52b8f7e39a',
expect.anything(),
);

expect(redisSet).toHaveBeenCalledOnce();
expect(redisSet).toHaveBeenCalledWith(
'e33d0afae8e08752efa1a467653931ae9ba60c6a3ea693e684a6a56ef3b18ba3c7e711edee33f99471d9bb2d02302e92512cc3c8513ab473bbe71d52b8f7e39a',
'{"statusCode":200,"headers":{"date":"Thu, 01 Jan 1970 00:00:00 GMT","connection":"keep-alive","keep-alive":"timeout=5","content-length":"15"},"result":"some http value"}',
'EX',
86400,
expect.anything(),
{ expiration: { 'type': 'EX', value: 86400 } },
);
});
176 changes: 74 additions & 102 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { hash } from 'node:crypto';
import type { CacheInterface, LoggerInterface } from 'descript';
import { error as deError } from 'descript';
import type { ClusterNode, ClusterOptions, RedisOptions } from 'ioredis';
import { Cluster, Redis } from 'ioredis';
import type { RedisClientType, RedisClusterType, RedisSentinelType } from '@redis/client';

type RedisClient = RedisClientType | RedisSentinelType | RedisClusterType;

export interface Options {
/** key TTL in seconds (default: 60 * 60 * 24) */
Expand All @@ -11,10 +12,8 @@ export interface Options {
generation?: number;
/** read timeout in milliseconds (default: 100) */
readTimeout?: number;
/** use two clients (reader and writer) with Sentinel (default: false) */
useReaderAndWriterWithSentinel?: boolean;
/** redis config */
redis: RedisOptions | { startupNodes: ClusterNode[], options?: ClusterOptions };
/** @redis/client */
client: RedisClient;
}

interface InnerOptions extends Options {
Expand Down Expand Up @@ -110,8 +109,7 @@ export type LoggerEvent = (
);

export class Cache<Result> implements CacheInterface<Result> {
#writer: Cluster | Redis;
#reader: Cluster | Redis;
#client: RedisClient;
#logger?: Logger;
#options: InnerOptions;

Expand All @@ -124,47 +122,14 @@ export class Cache<Result> implements CacheInterface<Result> {
};

this.#logger = logger;

if ('startupNodes' in this.#options.redis) {
this.#reader = new Cluster(
this.#options.redis.startupNodes,
this.#options.redis.options,
);
this.#writer = this.#reader;
} else {
if (this.#options.useReaderAndWriterWithSentinel) {
// Client for write (always on master)
this.#writer = new Redis({
...this.#options.redis,
role: 'master',
});

// Client for read (replica, only read-only commands)
this.#reader = new Redis({
...this.#options.redis,
role: 'slave',
readOnly: true,
});
} else {
this.#reader = new Redis(this.#options.redis);
this.#writer = this.#reader;
}

}
this.#client = options.client;

this.#log({
'type': EVENT.REDIS_CACHE_INITIALIZED,
options: { ...this.#options },
});
}

getClient() {
return {
reader: this.#reader,
writer: this.#writer,
};
}

get({ key }: { key: string }): Promise<Result | undefined> {
const normalizedKey = this.#normalizeKey(key);

Expand Down Expand Up @@ -196,43 +161,30 @@ export class Cache<Result> implements CacheInterface<Result> {
}));
}, this.#options.readTimeout);

this.#reader.get(normalizedKey, (error, data) => {
if (isTimeout) {
return;
}

clearTimeout(timer);
this.#client.get(normalizedKey)
.then((data) => {
clearTimeout(timer);
if (isTimeout) {
return;
}

if (error) {
this.#log({
'type': EVENT.REDIS_CACHE_READ_ERROR,
error,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});
if (!data) {
this.#log({
'type': EVENT.REDIS_CACHE_READ_KEY_NOT_FOUND,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});

reject(deError({
id: EVENT.REDIS_CACHE_READ_ERROR,
}));
} else if (!data) {
this.#log({
'type': EVENT.REDIS_CACHE_READ_KEY_NOT_FOUND,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});
reject(deError({
id: EVENT.REDIS_CACHE_READ_KEY_NOT_FOUND,
}));
return;
}

reject(deError({
id: EVENT.REDIS_CACHE_READ_KEY_NOT_FOUND,
}));
} else {
let parsedValue;
try {
parsedValue = JSON.parse(data);
Expand Down Expand Up @@ -267,8 +219,24 @@ export class Cache<Result> implements CacheInterface<Result> {
});

resolve(parsedValue);
}
});
})
.catch((error) => {
clearTimeout(timer);

this.#log({
'type': EVENT.REDIS_CACHE_READ_ERROR,
error,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});
reject(deError({
id: EVENT.REDIS_CACHE_READ_ERROR,
}));
});
});
}

Expand Down Expand Up @@ -308,25 +276,42 @@ export class Cache<Result> implements CacheInterface<Result> {
return;
}

// https://redis.io/docs/latest/commands/getex/
// maxage - seconds
this.#writer.set(normalizedKey, json, 'EX', maxage, (error, done) => {
if (error) {
this.#client.set(normalizedKey, json, { expiration: { 'type': 'EX', value: maxage } })
.then(( done) => {
if (!done) {
this.#log({
'type': EVENT.REDIS_CACHE_WRITE_FAILED,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});
reject(deError({
id: EVENT.REDIS_CACHE_WRITE_FAILED,
}));
return;
}

this.#log({
'type': EVENT.REDIS_CACHE_WRITE_ERROR,
error,
'type': EVENT.REDIS_CACHE_WRITE_DONE,
data: json,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});
reject(deError({
id: EVENT.REDIS_CACHE_WRITE_ERROR,
}));
} else if (!done) {
resolve();
})
.catch((error) => {
this.#log({
'type': EVENT.REDIS_CACHE_WRITE_FAILED,
'type': EVENT.REDIS_CACHE_WRITE_ERROR,
error,
key,
normalizedKey,
timers: {
Expand All @@ -335,22 +320,9 @@ export class Cache<Result> implements CacheInterface<Result> {
},
});
reject(deError({
id: EVENT.REDIS_CACHE_WRITE_FAILED,
id: EVENT.REDIS_CACHE_WRITE_ERROR,
}));
} else {
this.#log({
'type': EVENT.REDIS_CACHE_WRITE_DONE,
data: json,
key,
normalizedKey,
timers: {
start,
end: Date.now(),
},
});
resolve();
}
});
});
});
}

Expand Down
Loading