Skip to content

Commit

Permalink
feat(express): add lru cache for wallets in v2 /sendcoins
Browse files Browse the repository at this point in the history
B2B2C clients exprience high latencies for get wallet requests.
To mitigate, we will cache these requests

TICKET: CS-4503
  • Loading branch information
joeybaibg committed Jan 30, 2025
1 parent fa245c4 commit 426499c
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 1 deletion.
91 changes: 91 additions & 0 deletions modules/express/src/LRUCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export interface LRUCacheOptions {
// maximum number of records this cache can hold
maxSize: number;

// duration in milliseconds after which a record is considered expired
ttl: number;
}

export default class LRUCache<K, V> {
private cache: Map<K, { value: V; expiry: number }>;
private readonly maxSize: number;
private readonly ttl: number;

/**
* Creates a new LRU cache
* @param options configurable options for this cache
*/
constructor(options: LRUCacheOptions = { maxSize: 10, ttl: 60000 }) {
this.cache = new Map();
this.maxSize = options.maxSize;
this.ttl = options.ttl;
}

/**
* Retrieves value from cache if it exists and has not expired
* @param key key
*/
get(key: K): V | undefined {
const cacheItem = this.cache.get(key);
if (cacheItem) {
// If expired, delete the cache item
if (Date.now() > cacheItem.expiry) {
this.cache.delete(key);
return undefined;
}

// Move the item to the end to mark it as recently used
this.cache.delete(key);
this.cache.set(key, cacheItem);
return cacheItem.value;
}
return undefined;
}

/**
* Adds a new item to the cache.
* If the cache is full, it will try to evict expired items first.
* If no expired items are found, it will evict the least recently used item.
*
* @param key key
* @param value value
*/
set(key: K, value: V): void {
const expiry = Date.now() + this.ttl;

if (this.cache.has(key)) {
this.cache.delete(key);
}

// If cache is full, remove expired items, then remove the least recently used (LRU) item
if (this.cache.size >= this.maxSize) {
const numExpiredEvicted = this.cleanUpExpiredItems();
if (numExpiredEvicted === 0) {
const firstKey = this.cache.keys().next().value; // Get the first key (LRU)
this.cache.delete(firstKey);
}
}

// Add the new item to the cache
this.cache.set(key, { value, expiry });
}

delete(key: K): boolean {
return this.cache.delete(key);
}

// Check if an item has expired
private cleanUpExpiredItems(): number {
const now = Date.now();

let numDeleted = 0;
for (const [key, { expiry }] of this.cache.entries()) {
if (now > expiry) {
this.cache.delete(key);
numDeleted++;
}
}

return numDeleted;
}
}
18 changes: 17 additions & 1 deletion modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,16 @@ import {
handleUnlockLightningWallet,
} from './lightning/lightningSignerRoutes';
import { ProxyAgent } from 'proxy-agent';
import LRUCache from './LRUCache';

const { version } = require('bitgo/package.json');
const pjson = require('../package.json');
const debug = debugLib('bitgo:express');

const BITGOEXPRESS_USER_AGENT = `BitGoExpress/${pjson.version} BitGoJS/${version}`;

const walletCache = new LRUCache<string, Wallet>();

function handlePing(req: express.Request, res: express.Response, next: express.NextFunction) {
return req.bitgo.ping();
}
Expand Down Expand Up @@ -916,13 +919,26 @@ async function handleV2SendOne(req: express.Request) {
const bitgo = req.bitgo;
const coin = bitgo.coin(req.params.coin);
const reqId = new RequestTracer();
const wallet = await coin.wallets().get({ id: req.params.id, reqId });
const cachedWallet = walletCache.get(req.params.id);
let wallet: Wallet;

if (cachedWallet) {
wallet = cachedWallet;
} else {
wallet = await coin.wallets().get({ id: req.params.id, reqId });

if (req.config.walletCacheEnabled) {
walletCache.set(req.params.id, wallet);
}
}

req.body.reqId = reqId;

let result;
try {
result = await wallet.send(createSendParams(req));
} catch (err) {
walletCache.delete(req.params.id);
err.status = 400;
throw err;
}
Expand Down
15 changes: 15 additions & 0 deletions modules/express/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export interface Config {
signerMode?: boolean;
signerFileSystemPath?: string;
lightningSignerFileSystemPath?: string;
walletCacheMaxSize: number;
walletCacheTTL: number;
walletCacheEnabled: boolean;
}

export const ArgConfig = (args): Partial<Config> => ({
Expand All @@ -65,6 +68,9 @@ export const ArgConfig = (args): Partial<Config> => ({
signerMode: args.signerMode,
signerFileSystemPath: args.signerFileSystemPath,
lightningSignerFileSystemPath: args.lightningSignerFileSystemPath,
walletCacheMaxSize: args.walletCacheMaxSize,
walletCacheTTL: args.walletCacheTTL,
walletCacheEnabled: args.walletCacheEnabled,
});

export const EnvConfig = (): Partial<Config> => ({
Expand All @@ -89,6 +95,9 @@ export const EnvConfig = (): Partial<Config> => ({
signerMode: readEnvVar('BITGO_SIGNER_MODE') ? true : undefined,
signerFileSystemPath: readEnvVar('BITGO_SIGNER_FILE_SYSTEM_PATH'),
lightningSignerFileSystemPath: readEnvVar('BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH'),
walletCacheEnabled: Boolean(readEnvVar('BITGO_WALLET_CACHE_ENABLED')),
walletCacheMaxSize: Number(readEnvVar('BITGO_WALLET_CACHE_MAX_SIZE')),
walletCacheTTL: Number(readEnvVar('BITGO_WALLET_CACHE_TTL_MS')),
});

export const DefaultConfig: Config = {
Expand All @@ -104,6 +113,9 @@ export const DefaultConfig: Config = {
disableEnvCheck: true,
timeout: 305 * 1000,
authVersion: 2,
walletCacheEnabled: false,
walletCacheMaxSize: 10,
walletCacheTTL: 60_000,
};

/**
Expand Down Expand Up @@ -173,6 +185,9 @@ function mergeConfigs(...configs: Partial<Config>[]): Config {
signerMode: get('signerMode'),
signerFileSystemPath: get('signerFileSystemPath'),
lightningSignerFileSystemPath: get('lightningSignerFileSystemPath'),
walletCacheEnabled: get('walletCacheEnabled'),
walletCacheMaxSize: get('walletCacheMaxSize'),
walletCacheTTL: get('walletCacheTTL'),
};
}

Expand Down
9 changes: 9 additions & 0 deletions modules/express/test/unit/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ describe('Config:', () => {
signerMode: 'argsignerMode',
signerFileSystemPath: 'argsignerFileSystemPath',
lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath',
walletCacheEnabled: true,
walletCacheMaxSize: 1000,
walletCacheTTL: 2000,
});
const envStub = sinon.stub(process, 'env').value({
BITGO_PORT: 'env12345',
Expand All @@ -108,6 +111,9 @@ describe('Config:', () => {
BITGO_SIGNER_MODE: 'envsignerMode',
BITGO_SIGNER_FILE_SYSTEM_PATH: 'envsignerFileSystemPath',
BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH: 'envlightningSignerFileSystemPath',
BITGO_WALLET_CACHE_ENABLED: '1',
BITGO_WALLET_CACHE_MAX_SIZE: '1000',
BITGO_WALLET_CACHE_TTL_MS: '2000',
});
config().should.eql({
port: 23456,
Expand All @@ -131,6 +137,9 @@ describe('Config:', () => {
signerMode: 'argsignerMode',
signerFileSystemPath: 'argsignerFileSystemPath',
lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath',
walletCacheEnabled: true,
walletCacheMaxSize: 1000,
walletCacheTTL: 2000,
});
argStub.restore();
envStub.restore();
Expand Down
37 changes: 37 additions & 0 deletions modules/express/test/unit/lruCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as should from 'should';
import LRUCache from '../../src/LRUCache';

describe('LRUCache', function () {
let cache: LRUCache<string, string>;

beforeEach(() => {
cache = new LRUCache<string, string>({
maxSize: 3,
ttl: 5000,
});
});

it('should store and retrieve values correctly', () => {
cache.set('a', 'apple');
cache.set('b', 'banana');

'apple'.should.equal(cache.get('a'));
'banana'.should.equal(cache.get('b'));
});

it('should return undefined for non-existing keys', () => {
should.equal(cache.get('does-not-exist'), undefined);
});

it('should evict the least recently used item when the cache exceeds the max size', () => {
cache.set('a', 'apple');
cache.set('b', 'banana');
cache.set('c', 'cherry');
cache.set('d', 'date');

should.equal(cache.get('a'), undefined);
'banana'.should.equal(cache.get('b'));
'cherry'.should.equal(cache.get('c'));
'date'.should.equal(cache.get('d'));
});
});

0 comments on commit 426499c

Please sign in to comment.