Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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-nodejs": patch
"@credo-ts/didcomm": patch
"@credo-ts/core": patch
---

- Added a new package to use `redis` for caching in Node.js
- Add a new open `useCache` to a record, which allows to CRUD the cache before calling the storage service
- This is only set to `true` on the `connectionRecord` is it is used a couple times in a row a lot of the time
2 changes: 2 additions & 0 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ jobs:
with:
node-version: ${{ matrix.node-version }}

- uses: shogo82148/actions-setup-redis@v1

# See https://github.com/actions/setup-node/issues/641#issuecomment-1358859686
- name: pnpm cache path
id: pnpm-cache-path
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
9 changes: 9 additions & 0 deletions packages/core/src/agent/context/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,13 @@ export class AgentContext {
public resolve<T>(token: InjectionToken<T>): T {
return this.dependencyManager.resolve(token)
}

/**
* Resolve a dependency or return undefined
*/
public resolveOptionally<T>(token: InjectionToken<T>): T | undefined {
try {
return this.dependencyManager.resolve(token)
} catch {}
}
}
23 changes: 11 additions & 12 deletions packages/core/src/modules/cache/CacheModule.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
import { InjectionSymbols } from '../../constants'
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(InjectionSymbols.CachedStorageService, 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
28 changes: 18 additions & 10 deletions packages/core/src/storage/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { RecordDeletedEvent, RecordSavedEvent, RecordUpdatedEvent } from '.
import type { BaseRecordConstructor, Query, QueryOptions, StorageService } from './StorageService'

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

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

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
Expand All @@ -19,14 +19,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> {
try {
return agentContext.resolve(CachedStorageService<T>)
} catch {
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 +47,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 +60,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 +77,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 +89,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 +99,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,12 +109,12 @@ 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)
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/storage/StorageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type Query<T extends BaseRecord<any, any, any>> = AdvancedQuery<T> | Simp

export interface BaseRecordConstructor<T> extends Constructor<T> {
type: string
allowCache: boolean
}

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('0.3-0.3.1 | Did', () => {
expect(didRepository.save).toHaveBeenCalledTimes(1)

const [, didRecord] = mockFunction(didRepository.save).mock.calls[0]
expect(didRecord).toEqual({
expect(didRecord).toMatchObject({
type: 'DidRecord',
id: expect.any(String),
did: 'did:peer:123',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('0.3.1-0.4 | Did', () => {
expect(didRepository.findByQuery).toHaveBeenCalledTimes(1)

const [, didRecord] = mockFunction(didRepository.update).mock.calls[0]
expect(didRecord).toEqual({
expect(didRecord).toMatchObject({
type: 'DidRecord',
id: expect.any(String),
did: 'did:indy:local:123',
Expand Down
Loading
Loading