From 285632a983f2ae83d4f6250c339898dda75bb14e Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 16 Nov 2021 12:39:25 +0100 Subject: [PATCH] feat: stx addr encoding LRU cache --- .env | 3 ++ package-lock.json | 9 ++-- package.json | 2 + src/c32-addr-cache.ts | 90 ++++++++++++++++++++++++++++++++ src/index.ts | 3 ++ src/tests/helpers-tests.ts | 48 +++++++++++++++++ utils/package.json | 3 +- utils/src/addr-lru-cache-test.ts | 63 ++++++++++++++++++++++ 8 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/c32-addr-cache.ts create mode 100644 utils/src/addr-lru-cache-test.ts diff --git a/.env b/.env index ce2f44ed..c2cefeda 100644 --- a/.env +++ b/.env @@ -84,3 +84,6 @@ MAINNET_SEND_MANY_CONTRACT_ID=SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.send-man # IMGIX_DOMAIN=https://.imgix.net # IMGIX_TOKEN= +# Specify max number of STX address to store in an in-memory LRU cache (CPU optimization). +# Defaults to 50,000, which should result in around 25 megabytes of additional memory usage. +# STACKS_ADDRESS_CACHE_SIZE=10000 diff --git a/package-lock.json b/package-lock.json index e55d9ff4..2e4f47a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2489,6 +2489,11 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==" + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -12436,7 +12441,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -18025,8 +18029,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yaml": { "version": "1.10.2", diff --git a/package.json b/package.json index e6029406..7776c9bd 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "@stacks/transactions": "^v2.0.1", "@types/dockerode": "^2.5.34", "@types/express-list-endpoints": "^4.0.1", + "@types/lru-cache": "^5.1.1", "@types/ws": "^7.2.5", "big-integer": "^1.6.48", "bignumber.js": "^9.0.1", @@ -131,6 +132,7 @@ "http-proxy-middleware": "^2.0.1", "jsonc-parser": "^3.0.0", "jsonrpc-lite": "^2.1.0", + "lru-cache": "^6.0.0", "micro-base58": "^0.5.0", "nock": "^13.1.1", "node-fetch": "^2.6.0", diff --git a/src/c32-addr-cache.ts b/src/c32-addr-cache.ts new file mode 100644 index 00000000..ad7a32c8 --- /dev/null +++ b/src/c32-addr-cache.ts @@ -0,0 +1,90 @@ +/* +This module is hacky and does things that should generally be avoided in this codebase. We are using a procedure to +"re-export" a function on an existing module in order to add some functionality specific to the API. In this case, +the `c32check.c32address` function is difficult to override within this codebase. Many entry points into the function +are from calls into stacks.js libs. A cleaner solution would involve implementing optional params to many stacks.js +functions to provide a stx address encoding function. However, that would be a significant change to the stacks.js libs, +and for now this approach is much easier and faster. +*/ + +import * as c32check from 'c32check'; +import * as LruCache from 'lru-cache'; + +type c32AddressFn = typeof c32check.c32address; + +const MAX_ADDR_CACHE_SIZE = 50_000; +export const ADDR_CACHE_ENV_VAR = 'STACKS_ADDRESS_CACHE_SIZE'; + +let addressLruCache: LruCache | undefined; +export function getAddressLruCache() { + if (addressLruCache === undefined) { + let cacheSize = MAX_ADDR_CACHE_SIZE; + const envAddrCacheVar = process.env[ADDR_CACHE_ENV_VAR]; + if (envAddrCacheVar) { + cacheSize = Number.parseInt(envAddrCacheVar); + } + addressLruCache = new LruCache({ max: cacheSize }); + } + return addressLruCache; +} + +const c32EncodeInjectedSymbol = Symbol(); +let origC32AddressProp: PropertyDescriptor | undefined; +let origC32AddressFn: c32AddressFn | undefined; + +function createC32AddressCache(origFn: c32AddressFn): c32AddressFn { + const c32addressCached: c32AddressFn = (version, hash160hex) => { + const cacheKey = `${version}${hash160hex}`; + const addrCache = getAddressLruCache(); + let addrVal = addrCache.get(cacheKey); + if (addrVal === undefined) { + addrVal = origFn(version, hash160hex); + addrCache.set(cacheKey, addrVal); + } + return addrVal; + }; + Object.defineProperty(c32addressCached, c32EncodeInjectedSymbol, { value: true }); + return c32addressCached; +} + +/** + * Override the `c32address` function on the `c32check` module to use an LRU cache + * where commonly used encoded address strings can be cached. + */ +export function injectC32addressEncodeCache() { + // Skip if already injected + if (c32EncodeInjectedSymbol in c32check.c32address) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-var-requires + const c32checkModule = require('c32check'); + const origProp = Object.getOwnPropertyDescriptor(c32checkModule, 'c32address'); + if (!origProp) { + throw new Error(`Could not get property descriptor for 'c32address' on module 'c32check'`); + } + origC32AddressProp = origProp; + const origFn = origProp.get?.(); + if (!origFn) { + throw new Error(`Falsy result for 'c32address' property getter on 'c32check' module`); + } + origC32AddressFn = origFn; + const newFn = createC32AddressCache(origFn); + + // The exported module object specifies a property with a getter and setter (rather than simple indexer value), + // so use `defineProperty` to work around errors from trying to set/re-define the property with `c32checkModule.c32address = newFn`. + Object.defineProperty(c32checkModule, 'c32address', { get: () => newFn }); +} + +export function restoreC32AddressModule() { + if (addressLruCache !== undefined) { + addressLruCache.reset(); + addressLruCache = undefined; + } + + if (origC32AddressProp !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const c32checkModule = require('c32check'); + Object.defineProperty(c32checkModule, 'c32address', origC32AddressProp); + origC32AddressProp = undefined; + } +} diff --git a/src/index.ts b/src/index.ts index 07870017..6225e39d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,11 +31,14 @@ import * as getopts from 'getopts'; import * as fs from 'fs'; import * as path from 'path'; import * as net from 'net'; +import { injectC32addressEncodeCache } from './c32-addr-cache'; loadDotEnv(); sourceMapSupport.install({ handleUncaughtExceptions: false }); +injectC32addressEncodeCache(); + registerShutdownConfig(); async function monitorCoreRpcConnection(): Promise { diff --git a/src/tests/helpers-tests.ts b/src/tests/helpers-tests.ts index 750b7d55..778aa186 100644 --- a/src/tests/helpers-tests.ts +++ b/src/tests/helpers-tests.ts @@ -1,4 +1,6 @@ import * as c32check from 'c32check'; +import * as c32AddrCache from '../c32-addr-cache'; +import { ADDR_CACHE_ENV_VAR } from '../c32-addr-cache'; import { getCurrentGitTag, has0xPrefix, isValidBitcoinAddress } from '../helpers'; test('get git tag', () => { @@ -16,6 +18,52 @@ describe('has0xPrefix()', () => { }); }); +test('c32address lru caching', () => { + c32AddrCache.restoreC32AddressModule(); + const origAddrCacheEnvVar = process.env[ADDR_CACHE_ENV_VAR]; + process.env[ADDR_CACHE_ENV_VAR] = '5'; + try { + // No LRU cache used for c32address fn + expect(c32AddrCache.getAddressLruCache().itemCount).toBe(0); + const stxAddr1 = 'SP2JKEZC09WVMR33NBSCWQAJC5GS590RP1FR9CK55'; + const decodedAddr1 = c32check.c32addressDecode(stxAddr1); + const encodeResult1 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]); + expect(encodeResult1).toBe(stxAddr1); + expect(c32AddrCache.getAddressLruCache().itemCount).toBe(0); + + // Inject LRU cache into c32address fn, ensure it gets used + c32AddrCache.injectC32addressEncodeCache(); + expect(c32AddrCache.getAddressLruCache().max).toBe(5); + + const encodeResult2 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]); + expect(encodeResult2).toBe(stxAddr1); + expect(c32AddrCache.getAddressLruCache().itemCount).toBe(1); + + const encodeResult3 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]); + expect(encodeResult3).toBe(stxAddr1); + expect(c32AddrCache.getAddressLruCache().itemCount).toBe(1); + + // Test max cache size + c32AddrCache.getAddressLruCache().reset(); + for (let i = 1; i < 10; i++) { + // hash160 hex string + const buff = Buffer.alloc(20); + buff[i] = i; + c32check.c32address(1, buff.toString('hex')); + expect(c32AddrCache.getAddressLruCache().itemCount).toBe(Math.min(i, 5)); + } + + // Sanity check: reset c32 lib to original state, ensure no LRU cache used + c32AddrCache.restoreC32AddressModule(); + const encodeResult4 = c32check.c32address(decodedAddr1[0], decodedAddr1[1]); + expect(encodeResult4).toBe(stxAddr1); + expect(c32AddrCache.getAddressLruCache().itemCount).toBe(0); + } finally { + process.env[ADDR_CACHE_ENV_VAR] = origAddrCacheEnvVar; + c32AddrCache.restoreC32AddressModule(); + } +}); + test('bitcoin<->stacks address', () => { const mainnetStxAddr = 'SP2JKEZC09WVMR33NBSCWQAJC5GS590RP1FR9CK55'; const mainnetBtcAddr = '1G4ayBXJvxZMoZpaNdZG6VyWwWq2mHpMjQ'; diff --git a/utils/package.json b/utils/package.json index f1ea1f7a..0204126b 100644 --- a/utils/package.json +++ b/utils/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "rimraf ./lib && npm run build:node", "build:node": "tsc", - "start": "node ./lib/utils/src/index.js" + "start": "node ./lib/utils/src/index.js", + "address-cache-test": "npm run build && NODE_ENV=production node --expose-gc ./lib/utils/src/addr-lru-cache-test.js" }, "prettier": "@stacks/prettier-config", "dependencies": { diff --git a/utils/src/addr-lru-cache-test.ts b/utils/src/addr-lru-cache-test.ts new file mode 100644 index 00000000..e6abc8db --- /dev/null +++ b/utils/src/addr-lru-cache-test.ts @@ -0,0 +1,63 @@ +import * as util from 'util'; +import * as assert from 'assert'; +import * as c32check from 'c32check'; +import * as c32AddrCache from '../../src/c32-addr-cache'; + +if (!global.gc) { + throw new Error('Enable --expose-gc'); +} + +const iters = 500_000; +process.env[c32AddrCache.ADDR_CACHE_ENV_VAR] = iters.toString(); + +c32AddrCache.injectC32addressEncodeCache(); + +const buff = Buffer.alloc(20); +c32check.c32address(1, buff.toString('hex')); +const startMemory = process.memoryUsage(); +const startRss = startMemory.rss; +const startMemoryStr = util.inspect(startMemory); + +for (let i = 0; i < iters; i++) { + // hash160 hex string + buff.writeInt32LE(i); + c32check.c32address(1, buff.toString('hex')); +} + +global.gc(); + +const endMemory = process.memoryUsage(); +const endRss = endMemory.rss; +const endMemoryStr = util.inspect(endMemory); +console.log('Start memory', startMemoryStr); +console.log('End memory', endMemoryStr); + +assert.equal(c32AddrCache.getAddressLruCache().itemCount, iters); + +const rn = (num: number) => Math.round(num * 100) / 100; +const megabytes = (bytes: number) => rn(bytes / 1024 / 1024); + +const byteDiff = (endRss - startRss) / (iters / 10_000); + +console.log(`Start RSS: ${megabytes(startRss)}, end RSS: ${megabytes(endRss)}`); +console.log(`Around ${megabytes(byteDiff)} megabytes per 10k cache entries`); + +/* +Several rounds of running this benchmark show "Around 4.44 megabytes per 10k cache entries": + +Start memory { + rss: 26202112, + heapTotal: 5578752, + heapUsed: 3642392, + external: 1147316, + arrayBuffers: 59931 +} +End memory { + rss: 259125248, + heapTotal: 216875008, + heapUsed: 181636328, + external: 1261038, + arrayBuffers: 18090 +} +Start RSS: 24.99, end RSS: 247.12 +*/