Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a Redis cache to the REST API #8187

Merged
merged 3 commits into from
Apr 29, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);

Check warning on line 45 in hedera-mirror-rest/cache.js

View check run for this annotation

Codecov / codecov/patch

hedera-mirror-rest/cache.js#L45

Added line #L45 was not covered by tests
},
});

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) {
jascks marked this conversation as resolved.
Show resolved Hide resolved
this.redis
.config('SET', key, value)
.catch((e) => logger.warn(`Unable to set Redis ${key} to ${value}: ${e.message}`));

Check warning on line 61 in hedera-mirror-rest/cache.js

View check run for this annotation

Codecov / codecov/patch

hedera-mirror-rest/cache.js#L61

Added line #L61 was not covered by tests
}

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);

Check warning on line 76 in hedera-mirror-rest/cache.js

View check run for this annotation

Codecov / codecov/patch

hedera-mirror-rest/cache.js#L76

Added line #L76 was not covered by tests
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`);

Check warning on line 104 in hedera-mirror-rest/cache.js

View check run for this annotation

Codecov / codecov/patch

hedera-mirror-rest/cache.js#L103-L104

Added lines #L103 - L104 were not covered by tests
}

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