Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
9 changes: 9 additions & 0 deletions .changeset/full-nights-pick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@credo-ts/redis-cache": patch
"@credo-ts/didcomm": patch
"@credo-ts/core": patch
---

- Added a new package to use `redis` for caching in Node.js
- Add a new option `allowCache` to a record, which allows to CRUD the cache before calling the storage service
- This is only set to `true` on the `connectionRecord` and mediation records for now, improving the performance of the mediation flow
2 changes: 1 addition & 1 deletion DEVREADME.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ GENESIS_TXN_PATH=/work/network/genesis/local-genesis.txn

## Running tests

Test are executed using jest. E2E tests (ending in `.e2e.test.ts`) require the **indy ledger**, **cheqd ledger** or **postgres database** to be running.
Test are executed using jest. E2E tests (ending in `.e2e.test.ts`) require the **indy ledger**, **cheqd ledger**, **postgres database**, or **redis** to be running.

When running tests that require a connection to the indy ledger pool, you can set the `TEST_AGENT_PUBLIC_DID_SEED`, `ENDORSER_AGENT_PUBLIC_DID_SEED` and `GENESIS_TXN_PATH` environment variables.

Expand Down
5 changes: 5 additions & 0 deletions docker-compose.arm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ services:
ports:
- '5432:5432'

redis:
image: valkey/valkey
ports:
- '6379:6379'

indy-pool:
build:
context: .
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ services:
ports:
- '5432:5432'

redis:
image: valkey/valkey
ports:
- '6379:6379'

indy-pool:
build:
context: .
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent/AgentModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ApiModule, DependencyManager, Module } from '../plugins'
import type { IsAny } from '../types'
import type { Constructor } from '../utils/mixins'

import { CacheModule } from '../modules/cache'
import { CacheModule, SingleContextStorageLruCache } from '../modules/cache'
import { DcqlModule } from '../modules/dcql/DcqlModule'
import { DidsModule } from '../modules/dids'
import { DifPresentationExchangeModule } from '../modules/dif-presentation-exchange'
Expand Down Expand Up @@ -107,7 +107,7 @@ function getDefaultAgentModules() {
genericRecords: () => new GenericRecordsModule(),
dids: () => new DidsModule(),
w3cCredentials: () => new W3cCredentialsModule(),
cache: () => new CacheModule(),
cache: () => new CacheModule({ cache: new SingleContextStorageLruCache({ limit: 500 }) }),
pex: () => new DifPresentationExchangeModule(),
sdJwtVc: () => new SdJwtVcModule(),
x509: () => new X509Module(),
Expand Down
22 changes: 10 additions & 12 deletions packages/core/src/modules/cache/CacheModule.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
import type { DependencyManager, Module } from '../../plugins'
import { Optional } from '../../types'
import type { CacheModuleConfigOptions } from './CacheModuleConfig'
import { CachedStorageService } from './CachedStorageService'

import { CacheModuleConfig } from './CacheModuleConfig'
import { SingleContextLruCacheRepository } from './singleContextLruCache/SingleContextLruCacheRepository'
import { SingleContextStorageLruCache } from './singleContextLruCache/SingleContextStorageLruCache'

// CacheModuleOptions makes the credentialProtocols property optional from the config, as it will set it when not provided.
export type CacheModuleOptions = Optional<CacheModuleConfigOptions, 'cache'>
export type CacheModuleOptions = CacheModuleConfigOptions

export class CacheModule implements Module {
public readonly config: CacheModuleConfig

public constructor(config?: CacheModuleOptions) {
this.config = new CacheModuleConfig({
...config,
cache:
config?.cache ??
new SingleContextStorageLruCache({
limit: 500,
}),
})
public constructor(config: CacheModuleOptions) {
this.config = new CacheModuleConfig(config)
}

public register(dependencyManager: DependencyManager) {
dependencyManager.registerInstance(CacheModuleConfig, this.config)

// Allows us to use the `CachedStorageService` instead of the `StorageService`
// This first checks the local cache to return a record
if (this.config.useCachedStorageService) {
dependencyManager.registerSingleton(CachedStorageService)
}

// Custom handling for when we're using the SingleContextStorageLruCache
if (this.config.cache instanceof SingleContextStorageLruCache) {
dependencyManager.registerSingleton(SingleContextLruCacheRepository)
Expand Down
29 changes: 26 additions & 3 deletions packages/core/src/modules/cache/CacheModuleConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@ import type { Cache } from './Cache'
*/
export interface CacheModuleConfigOptions {
/**
*
* Implementation of the {@link Cache} interface.
*
* NOTE: Starting from Credo 0.4.0 the default cache implementation will be {@link InMemoryLruCache}
* @default SingleContextStorageLruCache - with a limit of 500
*/
cache: Cache

/**
*
* @default 60
*
*/
cache: Cache
defaultExpiryInSeconds?: number

/**
*
* Uses a caching registry before talking to the storage service when a Record has the `useCache` set to `true`
*
* @default false
*
*/
useCachedStorageService?: boolean
}

export class CacheModuleConfig {
Expand All @@ -26,4 +39,14 @@ export class CacheModuleConfig {
public get cache() {
return this.options.cache
}

/** See {@link CacheModuleConfigOptions.defaultExpiryInSeconds} */
public get defaultExpiryInSeconds() {
return this.options.defaultExpiryInSeconds ?? 60
}

/** See {@link CacheModuleConfigOptions.useCachedStorageService} */
public get useCachedStorageService() {
return this.options.useCachedStorageService ?? false
}
}
93 changes: 93 additions & 0 deletions packages/core/src/modules/cache/CachedStorageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AgentContext } from '../../agent'
import { InjectionSymbols } from '../../constants'
import { inject, injectable } from '../../plugins'
import { BaseRecord } from '../../storage/BaseRecord'
import { BaseRecordConstructor, Query, QueryOptions, StorageService } from '../../storage/StorageService'
import { JsonTransformer } from '../../utils'
import { CacheModuleConfig } from './CacheModuleConfig'

@injectable()
// biome-ignore lint/suspicious/noExplicitAny:
export class CachedStorageService<T extends BaseRecord<any, any, any>> implements StorageService<T> {
public constructor(@inject(InjectionSymbols.StorageService) private storageService: StorageService<T>) {}

private cache(agentContext: AgentContext) {
return agentContext.resolve(CacheModuleConfig).cache
}

private getCacheKey(options: { type: string; id: string }) {
return `${options.type}:${options.id}`
}

public async save(agentContext: AgentContext, record: T): Promise<void> {
if (record.allowCache) {
await this.cache(agentContext).set(agentContext, this.getCacheKey(record), record.toJSON())
}

return await this.storageService.save(agentContext, record)
}

public async update(agentContext: AgentContext, record: T): Promise<void> {
if (record.allowCache) {
await this.cache(agentContext).set(agentContext, this.getCacheKey(record), record.toJSON())
}

return await this.storageService.update(agentContext, record)
}

public async delete(agentContext: AgentContext, record: T): Promise<void> {
if (record.allowCache) {
await this.cache(agentContext).remove(agentContext, this.getCacheKey(record))
}
return await this.storageService.delete(agentContext, record)
}

public async deleteById(
agentContext: AgentContext,
recordClass: BaseRecordConstructor<T>,
id: string
): Promise<void> {
if (recordClass.allowCache) {
await this.cache(agentContext).remove(agentContext, this.getCacheKey({ ...recordClass, id }))
}
return await this.storageService.deleteById(agentContext, recordClass, id)
}

public async getById(agentContext: AgentContext, recordClass: BaseRecordConstructor<T>, id: string): Promise<T> {
if (recordClass.allowCache) {
const cachedValue = await this.cache(agentContext).get<T>(
agentContext,
this.getCacheKey({ type: recordClass.type, id })
)

if (cachedValue) return JsonTransformer.fromJSON<T>(cachedValue, recordClass)
}

const record = await this.storageService.getById(agentContext, recordClass, id)

if (recordClass.allowCache) {
await this.cache(agentContext).set(
agentContext,
this.getCacheKey({ type: recordClass.type, id }),
record.toJSON()
)
}

return record
}

// TODO: not in caching interface, yet
public async getAll(agentContext: AgentContext, recordClass: BaseRecordConstructor<T>): Promise<T[]> {
return await this.storageService.getAll(agentContext, recordClass)
}

// TODO: not in caching interface, yet
public async findByQuery(
agentContext: AgentContext,
recordClass: BaseRecordConstructor<T>,
query: Query<T>,
queryOptions?: QueryOptions
): Promise<T[]> {
return await this.storageService.findByQuery(agentContext, recordClass, query, queryOptions)
}
}
2 changes: 2 additions & 0 deletions packages/core/src/modules/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export { Cache } from './Cache'
// Cache Implementations
export { InMemoryLruCache, InMemoryLruCacheOptions } from './InMemoryLruCache'
export { SingleContextStorageLruCache, SingleContextStorageLruCacheOptions } from './singleContextLruCache'

export { CachedStorageService } from './CachedStorageService'
4 changes: 4 additions & 0 deletions packages/core/src/storage/BaseRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export abstract class BaseRecord<
public readonly type = BaseRecord.type
public static readonly type: string = 'BaseRecord'

@Exclude()
public readonly allowCache = BaseRecord.allowCache
public static readonly allowCache: boolean = false

/** @inheritdoc {Metadata#Metadata} */
@MetadataTransformer()
public metadata: Metadata<MetadataValues> = new Metadata({})
Expand Down
55 changes: 43 additions & 12 deletions packages/core/src/storage/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type { RecordDeletedEvent, RecordSavedEvent, RecordUpdatedEvent } from '.
import type { BaseRecordConstructor, Query, QueryOptions, StorageService } from './StorageService'

import { RecordDuplicateError, RecordNotFoundError } from '../error'

import { CacheModuleConfig } from '../modules/cache/CacheModuleConfig'
import { CachedStorageService } from '../modules/cache/CachedStorageService'
import { JsonTransformer } from '../utils'
import { RepositoryEventTypes } from './RepositoryEvents'

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
Expand All @@ -19,14 +21,22 @@ export class Repository<T extends BaseRecord<any, any, any>> {
storageService: StorageService<T>,
eventEmitter: EventEmitter
) {
this.storageService = storageService
this.recordClass = recordClass
this.storageService = storageService
this.eventEmitter = eventEmitter
}

private getStorageService(agentContext: AgentContext): StorageService<T> {
if (agentContext.dependencyManager.isRegistered(CachedStorageService, true)) {
return agentContext.resolve(CachedStorageService<T>)
}

return this.storageService
}

/** @inheritDoc {StorageService#save} */
public async save(agentContext: AgentContext, record: T): Promise<void> {
await this.storageService.save(agentContext, record)
await this.getStorageService(agentContext).save(agentContext, record)

this.eventEmitter.emit<RecordSavedEvent<T>>(agentContext, {
type: RepositoryEventTypes.RecordSaved,
Expand All @@ -39,7 +49,7 @@ export class Repository<T extends BaseRecord<any, any, any>> {

/** @inheritDoc {StorageService#update} */
public async update(agentContext: AgentContext, record: T): Promise<void> {
await this.storageService.update(agentContext, record)
await this.getStorageService(agentContext).update(agentContext, record)

this.eventEmitter.emit<RecordUpdatedEvent<T>>(agentContext, {
type: RepositoryEventTypes.RecordUpdated,
Expand All @@ -52,7 +62,7 @@ export class Repository<T extends BaseRecord<any, any, any>> {

/** @inheritDoc {StorageService#delete} */
public async delete(agentContext: AgentContext, record: T): Promise<void> {
await this.storageService.delete(agentContext, record)
await this.getStorageService(agentContext).delete(agentContext, record)

this.eventEmitter.emit<RecordDeletedEvent<T>>(agentContext, {
type: RepositoryEventTypes.RecordDeleted,
Expand All @@ -69,7 +79,7 @@ export class Repository<T extends BaseRecord<any, any, any>> {
* @returns
*/
public async deleteById(agentContext: AgentContext, id: string): Promise<void> {
await this.storageService.deleteById(agentContext, this.recordClass, id)
await this.getStorageService(agentContext).deleteById(agentContext, this.recordClass, id)

this.eventEmitter.emit<RecordDeletedEvent<T>>(agentContext, {
type: RepositoryEventTypes.RecordDeleted,
Expand All @@ -81,7 +91,7 @@ export class Repository<T extends BaseRecord<any, any, any>> {

/** @inheritDoc {StorageService#getById} */
public async getById(agentContext: AgentContext, id: string): Promise<T> {
return this.storageService.getById(agentContext, this.recordClass, id)
return this.getStorageService(agentContext).getById(agentContext, this.recordClass, id)
}

/**
Expand All @@ -91,7 +101,7 @@ export class Repository<T extends BaseRecord<any, any, any>> {
*/
public async findById(agentContext: AgentContext, id: string): Promise<T | null> {
try {
return await this.storageService.getById(agentContext, this.recordClass, id)
return await this.getStorageService(agentContext).getById(agentContext, this.recordClass, id)
} catch (error) {
if (error instanceof RecordNotFoundError) return null

Expand All @@ -101,23 +111,39 @@ export class Repository<T extends BaseRecord<any, any, any>> {

/** @inheritDoc {StorageService#getAll} */
public async getAll(agentContext: AgentContext): Promise<T[]> {
return this.storageService.getAll(agentContext, this.recordClass)
return this.getStorageService(agentContext).getAll(agentContext, this.recordClass)
}

/** @inheritDoc {StorageService#findByQuery} */
public async findByQuery(agentContext: AgentContext, query: Query<T>, queryOptions?: QueryOptions): Promise<T[]> {
return this.storageService.findByQuery(agentContext, this.recordClass, query, queryOptions)
return this.getStorageService(agentContext).findByQuery(agentContext, this.recordClass, query, queryOptions)
}

/**
* Find a single record by query. Returns null if not found.
* @param query the query
* @param cacheKey optional cache key to use for caching. By default query results are not cached, but if a cache key is provided
* as well as the record allows caching and the agent has a cached storage service enabled it will use the cache.
* @returns the record, or null if not found
* @throws {RecordDuplicateError} if multiple records are found for the given query
*/
public async findSingleByQuery(agentContext: AgentContext, query: Query<T>): Promise<T | null> {
const records = await this.findByQuery(agentContext, query)
public async findSingleByQuery(
agentContext: AgentContext,
query: Query<T>,
{ cacheKey }: { cacheKey?: string } = {}
): Promise<T | null> {
const cache = agentContext.resolve(CacheModuleConfig)
const useCacheStorage = cache.useCachedStorageService ?? false

if (useCacheStorage && cacheKey) {
const recordId = (await cache?.cache.get<string>(agentContext, cacheKey)) ?? null
if (recordId !== null) {
const recordJson = await cache?.cache.get<T>(agentContext, recordId)
if (recordJson) return JsonTransformer.fromJSON(recordJson, this.recordClass)
}
}

const records = await this.findByQuery(agentContext, query)
if (records.length > 1) {
throw new RecordDuplicateError(`Multiple records found for given query '${JSON.stringify(query)}'`, {
recordType: this.recordClass.type,
Expand All @@ -128,6 +154,11 @@ export class Repository<T extends BaseRecord<any, any, any>> {
return null
}

if (useCacheStorage && cacheKey) {
await cache?.cache.set(agentContext, cacheKey, records[0].id)
await cache?.cache.set(agentContext, records[0].id, records[0].toJSON())
}

return records[0]
}

Expand Down
Loading
Loading