Skip to content

Commit

Permalink
Add a Redis cache to the REST API (#8187)
Browse files Browse the repository at this point in the history
* Add a Redis cache to `/api/v1/transactions`
* Add Redis configuration to REST chart
* Remove temporary snyk Gradle plugin workaround

---------

Signed-off-by: Steven Sheehy <steven.sheehy@swirldslabs.com>
  • Loading branch information
steven-sheehy committed Apr 29, 2024
1 parent f70bc44 commit 12f1570
Show file tree
Hide file tree
Showing 12 changed files with 445 additions and 101 deletions.
3 changes: 0 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ extra.apply {
set("vertxVersion", "4.5.7")
}

// Temporarily override json version until snyk/gradle-plugin has an update with a fix
configurations["dataFiles"].dependencies.add(dependencies.create("org.json:json:20240205"))

// Creates a platform/BOM with specific versions so subprojects don't need to specify a version when
// using a dependency
dependencies {
Expand Down
1 change: 1 addition & 0 deletions charts/hedera-mirror/templates/secret-redis.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ stringData:
{{- $redisHost := tpl .Values.redis.host . -}}
{{- $redisPassword := coalesce .Values.redis.auth.password ($passwords.SPRING_DATA_REDIS_PASSWORD | default "" | b64dec) (randAlphaNum 40) }}

REDIS_URI: "redis://:{{ $redisPassword }}@{{ $redisHost }}:{{ .Values.redis.master.service.ports.redis }}"
SPRING_DATA_REDIS_HOST: "{{ $redisHost }}"
SPRING_DATA_REDIS_PASSWORD: "{{ $redisPassword }}"

Expand Down
6 changes: 6 additions & 0 deletions charts/hedera-mirror/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ rest:
password: "" # Randomly generated if left blank
username: mirror_rest
enabled: true
env:
HEDERA_MIRROR_REST_REDIS_URI:
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-redis"
key: REDIS_URI
envFrom:
- secretRef:
name: mirror-passwords
Expand Down
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,14 @@ value, it is recommended to only populate overridden properties in the custom `a
| `hedera.mirror.rest.query.maxTransactionsTimestampRange` | 60d | The maximum timestamp range to list transactions. |
| `hedera.mirror.rest.query.strictTimestampParam` | true | Enables strict checking of timestamp query param (currently only effects /api/v1/accounts/{id}?timestamp={timestamp} |
| `hedera.mirror.rest.query.topicMessageLookup` | false | Enables topic message lookup querying |
| `hedera.mirror.rest.redis.commandTimeout` | 10000 | The amount of time in milliseconds to wait before a Redis command will timeout |
| `hedera.mirror.rest.redis.connectTimeout` | 10000 | The amount of time in milliseconds to wait for a connection to Redis |
| `hedera.mirror.rest.redis.enabled` | true | Whether Redis should be used as a caching layer for the database |
| `hedera.mirror.rest.redis.maxBackoff` | 128000 | The maximum amount of time in milliseconds to wait in between retrying Redis connection errors |
| `hedera.mirror.rest.redis.maxMemory` | 250Mb | The maximum amount of memory that Redis should be configured to use for caching |
| `hedera.mirror.rest.redis.maxMemoryPolicy` | allkeys-lfu | The key eviction policy Redis should use when the max memory threshold has been reached |
| `hedera.mirror.rest.redis.maxRetriesPerRequest` | 1 | The maximum number of times that the Redis command should be retried |
| `hedera.mirror.rest.redis.uri` | redis://127.0.0.1:6379 | The URI to use when connecting to Redis |
| `hedera.mirror.rest.response.compression` | true | Whether content negotiation should occur to compress response bodies if requested |
| `hedera.mirror.rest.response.headers.default` | See application.yml | The default headers to add to every response. |
| `hedera.mirror.rest.response.headers.path` | See application.yml | The per path headers to add to every response. The key is the route name and the value is a header map. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ private void processContractAction(ContractAction action, int index, RecordItem
case RECIPIENT_CONTRACT -> contractAction.setRecipientContract(
EntityId.of(action.getRecipientContract()));
case TARGETED_ADDRESS -> contractAction.setRecipientAddress(
action.getTargetedAddress().toByteArray());
DomainUtils.toBytes(action.getTargetedAddress()));
default -> {
// ContractCreate transaction has no recipient
}
Expand Down
2 changes: 2 additions & 0 deletions hedera-mirror-rest/__tests__/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ hedera:
rest:
metrics:
enabled: false
redis:
enabled: false
83 changes: 83 additions & 0 deletions hedera-mirror-rest/__tests__/cache.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import config from '../config';
import {Cache} from '../cache';
import {RedisContainer} from '@testcontainers/redis';
import {defaultBeforeAllTimeoutMillis} from './integrationUtils.js';

let cache;
let redisContainer;

beforeAll(async () => {
config.redis.enabled = true;
redisContainer = await new RedisContainer().withStartupTimeout(20000).start();
logger.info('Started Redis container');
}, defaultBeforeAllTimeoutMillis);

afterAll(async () => {
await redisContainer.stop({signal: 'SIGKILL', t: 5});
logger.info('Stopped Redis container');
});

beforeEach(async () => {
config.redis.uri = `0.0.0.0:${redisContainer.getMappedPort(6379)}`;
cache = new Cache();
await cache.clear();
});

afterEach(async () => {
await cache.stop();
});

const loader = (keys) => keys.map((key) => `v${key}`);
const keyMapper = (key) => `k${key}`;

describe('get', () => {
test('All keys from database', async () => {
const values = await cache.get(['1', '2', '3'], loader, keyMapper);
expect(values).toEqual(['v1', 'v2', 'v3']);
});

test('Some keys from database', async () => {
const values = await cache.get(['1', '2', '3'], loader, keyMapper);
expect(values).toEqual(['v1', 'v2', 'v3']);

const newValues = await cache.get(['2', '3', '4'], loader, keyMapper);
expect(newValues).toEqual(['v2', 'v3', 'v4']);
});

test('No keys from database', async () => {
const values = await cache.get(['1', '2', '3'], loader, keyMapper);
expect(values).toEqual(['v1', 'v2', 'v3']);

const newValues = await cache.get(['1', '2', '3'], (k) => [], keyMapper);
expect(newValues).toEqual(['v1', 'v2', 'v3']);
});

test('Disabled', async () => {
config.redis.enabled = false;
const values = await cache.get(['1', '2', '3'], loader, keyMapper);
expect(values).toEqual(['v1', 'v2', 'v3']);
});

test('Unable to connect', async () => {
config.redis.uri = 'redis://invalid:6379';
cache = new Cache();
const values = await cache.get(['1', '2', '3'], loader, keyMapper);
expect(values).toEqual(['v1', 'v2', 'v3']);
});
});
113 changes: 113 additions & 0 deletions hedera-mirror-rest/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Copyright (C) 2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Redis from 'ioredis';
import config from './config';
import _ from 'lodash';
import {JSONParse, JSONStringify} from './utils.js';

export class Cache {
constructor() {
const enabled = config?.redis?.enabled;
const uri = config?.redis?.uri;
const uriSanitized = uri.replaceAll(RegExp('(?<=//).*:.+@', 'g'), '***:***@');
this.ready = false;

this.redis = new Redis(uri, {
commandTimeout: config?.redis?.commandTimeout,
connectTimeout: config?.redis?.connectTimeout,
enableAutoPipelining: true,
enableOfflineQueue: true,
enableReadyCheck: true,
keepAlive: 30000,
lazyConnect: !enabled,
maxRetriesPerRequest: config?.redis?.maxRetriesPerRequest,
retryStrategy: (attempt) => {
this.ready = false;

if (!enabled) {
return null;
}

return Math.min(attempt * 2000, config?.redis?.maxBackoff);
},
});

this.redis.on('connect', () => logger.info(`Connected to ${uriSanitized}`));
this.redis.on('error', (err) => logger.error(`Error connecting to ${uriSanitized}: ${err.message}`));
this.redis.on('ready', () => {
this.#setConfig('maxmemory', config?.redis?.maxMemory);
this.#setConfig('maxmemory-policy', config?.redis?.maxMemoryPolicy);
this.ready = true;
});
}

#setConfig(key, value) {
this.redis
.config('SET', key, value)
.catch((e) => logger.warn(`Unable to set Redis ${key} to ${value}: ${e.message}`));
}

async clear() {
return this.redis.flushall();
}

async get(keys, loader, keyMapper = (k) => (k ? k.toString() : k)) {
if (!this.ready) {
return loader(keys);
}

const buffers =
(await this.redis
.mgetBuffer(_.map(keys, keyMapper))
.catch((err) => logger.warn(`Redis error during mget: ${err.message}`))) || new Array(keys.length);
const values = buffers.map((t) => JSONParse(t));

let i = 0;
const missingKeys = keys.filter(() => _.isNil(values[i++]));

if (missingKeys.length > 0) {
const missing = await loader(missingKeys);
const newValues = [];
let j = 0;

missing.forEach((value) => {
// Update missing values in Redis array
for (; j < values.length; j++) {
if (_.isNil(values[j])) {
values[j] = value;
newValues.push(keyMapper(keys[j]));
newValues.push(JSONStringify(value));
break;
}
}
});

this.redis.mset(newValues).catch((err) => logger.warn(`Redis error during mset: ${err.message}`));
}

if (logger.isDebugEnabled()) {
const count = keys.length - missingKeys.length;
logger.debug(`Redis returned ${count} of ${keys.length} keys`);
}

return values;
}

async stop() {
return this.redis.quit();
}
}
9 changes: 9 additions & 0 deletions hedera-mirror-rest/config/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ hedera:
maxTransactionsTimestampRange: 60d
strictTimestampParam: true
topicMessageLookup: false
redis:
commandTimeout: 10000
connectTimeout: 10000
enabled: true
maxBackoff: 128000
maxMemory: 250Mb
maxMemoryPolicy: allkeys-lfu
maxRetriesPerRequest: 1
uri: redis://127.0.0.1:6379
response:
compression: true
headers:
Expand Down

0 comments on commit 12f1570

Please sign in to comment.