Skip to content

Commit b5ad6ca

Browse files
fix: acs cache improvements (#842)
* use singleton cache Signed-off-by: rukmini-basu-da <[email protected]> * fix cache stats Signed-off-by: rukmini-basu-da <[email protected]> * use activeContracts cached call in listContractsByInterface and update tests Signed-off-by: rukmini-basu-da <[email protected]> * use activeContracts cached call in listContractsByInterface and update tests Signed-off-by: rukmini-basu-da <[email protected]> * use activeContracts cached call in listContractsByInterface and update tests Signed-off-by: rukmini-basu-da <[email protected]> * use activeContracts cached call in listContractsByInterface and update tests Signed-off-by: rukmini-basu-da <[email protected]> --------- Signed-off-by: rukmini-basu-da <[email protected]>
1 parent c7fab75 commit b5ad6ca

File tree

5 files changed

+173
-68
lines changed

5 files changed

+173
-68
lines changed

core/ledger-client/src/acs/acs-helper.ts

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { LedgerClient } from '../ledger-client'
55

66
import { LRUCache } from 'typescript-lru-cache'
77
import { ACSContainer, ACSKey } from './acs-container.js'
8+
import {
9+
DEFAULT_MAX_CACHE_SIZE,
10+
DEFAULT_ENTRY_EXPIRATION_TIME,
11+
SharedACSCacheStats,
12+
} from './acs-shared-cache.js'
813
import { WSSupport } from './ws-support.js'
914
import { PartyId } from '@canton-network/core-types'
1015
import { Logger } from 'pino'
@@ -17,54 +22,55 @@ export type AcsHelperOptions = {
1722
includeCreatedEventBlob?: boolean
1823
}
1924

20-
const DEFAULT_MAX_CACHE_SIZE = 50
21-
const DEFAULT_ENTRY_EXPIRATION_TIME = 10 * 60 * 1000
22-
2325
export class ACSHelper {
2426
private contractsSet: LRUCache<string, ACSContainer>
2527
private readonly apiInstance: LedgerClient
2628
private readonly wsSupport: WSSupport | undefined
2729
private readonly logger: Logger
2830
private includeCreatedEventBlob: boolean
29-
private hits = 0
30-
private misses = 0
31-
private evictions = 0
32-
private totalLookuptime = 0
3331
private totalCacheServeTime = 0
3432

3533
constructor(
3634
apiInstance: LedgerClient,
3735
_logger: Logger,
38-
options?: AcsHelperOptions
36+
options?: AcsHelperOptions,
37+
sharedCache?: LRUCache<string, ACSContainer>
3938
) {
40-
this.contractsSet = new LRUCache({
41-
maxSize: options?.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE,
42-
entryExpirationTimeInMS:
43-
options?.entryExpirationTime ?? DEFAULT_ENTRY_EXPIRATION_TIME, // 10 minutes
44-
onEntryEvicted: (entry) => {
45-
this.logger.debug(
46-
`entry ${entry.key} isExpired = ${entry.isExpired}. evicting entry.`
47-
)
48-
this.evictions++
49-
},
50-
})
39+
this.contractsSet =
40+
sharedCache ??
41+
new LRUCache({
42+
maxSize: options?.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE,
43+
entryExpirationTimeInMS:
44+
options?.entryExpirationTime ??
45+
DEFAULT_ENTRY_EXPIRATION_TIME, // 10 minutes
46+
onEntryEvicted: (entry) => {
47+
this.logger.debug(
48+
`entry ${entry.key} isExpired = ${entry.isExpired}. evicting entry.`
49+
)
50+
SharedACSCacheStats.evictions++
51+
},
52+
})
5153
this.apiInstance = apiInstance
5254
this.wsSupport = options?.wsSupport
5355
this.logger = _logger.child({ component: 'ACSHelper' })
5456
this.includeCreatedEventBlob = options?.includeCreatedEventBlob ?? true
5557
}
5658

5759
getCacheStats() {
58-
const totalCalls = this.hits + this.misses
59-
const hitRate = totalCalls ? (this.hits / totalCalls) * 100 : 0
60+
const totalCalls = SharedACSCacheStats.hits + SharedACSCacheStats.misses
61+
const hitRate = totalCalls
62+
? (SharedACSCacheStats.hits / totalCalls) * 100
63+
: 0
6064
const avgLookupTime =
61-
totalCalls > 0 ? this.totalLookuptime / totalCalls : 0
65+
totalCalls > 0
66+
? SharedACSCacheStats.totalLookupTime / totalCalls
67+
: 0
6268

6369
return {
6470
totalCalls,
65-
hits: this.hits,
66-
misses: this.misses,
67-
evictions: this.evictions,
71+
hits: SharedACSCacheStats.hits,
72+
misses: SharedACSCacheStats.misses,
73+
evictions: SharedACSCacheStats.evictions,
6874
cacheSize: this.contractsSet.size,
6975
hitRate: hitRate.toFixed(2) + '%',
7076
averageLookupTime: avgLookupTime.toFixed(3) + ' ms',
@@ -80,25 +86,26 @@ export class ACSHelper {
8086
return { party, templateId, interfaceId }
8187
}
8288

83-
private static keyToString(key: ACSKey): string {
84-
return `${key.party ? key.party : 'ANY'}_T:${key.templateId ?? '()'}_I:${key.interfaceId ?? '()'}`
89+
private static keyToString(key: ACSKey, ledgerBaseUrl: string): string {
90+
return `${ledgerBaseUrl}_${key.party ? key.party : 'ANY'}_T:${key.templateId ?? '()'}_I:${key.interfaceId ?? '()'}`
8591
}
8692

8793
private findACSContainer(key: ACSKey): ACSContainer {
88-
const keyStr = ACSHelper.keyToString(key)
94+
const keyStr = ACSHelper.keyToString(key, this.apiInstance.baseUrl.href)
95+
// this.logger.info(`ACS KEY ${keyStr}`)
8996
const start = performance.now()
9097
const existing = this.contractsSet.get(keyStr)
9198
const end = performance.now()
92-
this.totalLookuptime += end - start
99+
SharedACSCacheStats.totalLookupTime += end - start
93100

94101
if (existing) {
95-
this.hits++
102+
SharedACSCacheStats.hits++
96103
this.logger.debug('cache hit')
97104
return existing
98105
}
99106

100107
this.logger.debug('cache miss')
101-
this.misses++
108+
SharedACSCacheStats.misses++
102109
const newContainer = new ACSContainer(undefined, {
103110
includeCreatedEventBlob: this.includeCreatedEventBlob,
104111
})
@@ -123,8 +130,12 @@ export class ACSHelper {
123130
)
124131
const end = performance.now()
125132

126-
if (this.contractsSet.has(ACSHelper.keyToString(key))) {
127-
this.totalCacheServeTime += end - start
133+
if (
134+
this.contractsSet.has(
135+
ACSHelper.keyToString(key, this.apiInstance.baseUrl.href)
136+
)
137+
) {
138+
SharedACSCacheStats.totalCacheServeTime += end - start
128139
}
129140

130141
return result
@@ -148,8 +159,12 @@ export class ACSHelper {
148159

149160
const end = performance.now()
150161

151-
if (this.contractsSet.has(ACSHelper.keyToString(key))) {
152-
this.totalCacheServeTime += end - start
162+
if (
163+
this.contractsSet.has(
164+
ACSHelper.keyToString(key, this.apiInstance.baseUrl.href)
165+
)
166+
) {
167+
SharedACSCacheStats.totalCacheServeTime += end - start
153168
}
154169

155170
return result
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { LRUCache } from 'typescript-lru-cache'
5+
import { ACSContainer } from './acs-container.js'
6+
7+
export const DEFAULT_MAX_CACHE_SIZE = 100
8+
export const DEFAULT_ENTRY_EXPIRATION_TIME = 10 * 60 * 1000
9+
10+
export const SharedACSCache = new LRUCache<string, ACSContainer>({
11+
maxSize: DEFAULT_MAX_CACHE_SIZE,
12+
entryExpirationTimeInMS: DEFAULT_ENTRY_EXPIRATION_TIME,
13+
onEntryEvicted: (entry) => {
14+
console.debug(`${entry} has expired`)
15+
SharedACSCacheStats.evictions++
16+
},
17+
})
18+
19+
export const SharedACSCacheStats = {
20+
hits: 0,
21+
misses: 0,
22+
evictions: 0,
23+
cacheSize: 0,
24+
totalLookupTime: 0,
25+
totalCacheServeTime: 0,
26+
}

core/ledger-client/src/ledger-client.ts

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from './ledger-api-utils.js'
1616

1717
import { ACSHelper, AcsHelperOptions } from './acs/acs-helper.js'
18+
import { SharedACSCache } from './acs/acs-shared-cache.js'
1819
import { AccessTokenProvider } from '@canton-network/core-wallet-auth'
1920
export const supportedVersions = ['3.3', '3.4'] as const
2021

@@ -23,7 +24,6 @@ export type SupportedVersions = (typeof supportedVersions)[number]
2324
export type Types = v3_3.components['schemas'] | v3_4.components['schemas']
2425

2526
type paths = v3_3.paths | v3_4.paths
26-
2727
// A conditional type that filters the set of OpenAPI path names to those that actually have a defined POST operation.
2828
// Any path without a POST is excluded via the `never` branch of the conditional
2929
export type PostEndpoint = {
@@ -90,6 +90,7 @@ export class LedgerClient {
9090
private accessTokenProvider: AccessTokenProvider | undefined
9191
private acsHelper: ACSHelper
9292
private readonly logger: Logger
93+
baseUrl: URL
9394

9495
constructor(
9596
baseUrl: URL,
@@ -142,7 +143,13 @@ export class LedgerClient {
142143

143144
this.clientVersion = version ?? this.clientVersion
144145
this.currentClient = this.clients[this.clientVersion]
145-
this.acsHelper = new ACSHelper(this, _logger, acsHelperOptions)
146+
this.baseUrl = baseUrl
147+
this.acsHelper = new ACSHelper(
148+
this,
149+
_logger,
150+
acsHelperOptions,
151+
SharedACSCache
152+
)
146153
}
147154

148155
public async init() {
@@ -480,8 +487,17 @@ export class LedgerClient {
480487
templateIds?: string[]
481488
parties?: string[] //TODO: Figure out if this should use this.partyId by default and not allow cross party filtering
482489
filterByParty?: boolean
490+
interfaceIds?: string[]
491+
limit?: number
483492
}): Promise<Array<Types['JsGetActiveContractsResponse']>> {
484-
const { offset, templateIds, parties, filterByParty } = options
493+
const {
494+
offset,
495+
templateIds,
496+
parties,
497+
filterByParty,
498+
interfaceIds,
499+
limit,
500+
} = options
485501

486502
this.logger.debug(options, 'options for active contracts')
487503

@@ -495,29 +511,51 @@ export class LedgerClient {
495511
)
496512
}
497513

498-
if (filterByParty && !templateIds?.length && parties?.length === 1) {
514+
if (interfaceIds?.length === 1 && parties?.length === 1) {
515+
const party = parties[0]
516+
const interfaceId = interfaceIds[0]
517+
return this.acsHelper.activeContractsForInterface(
518+
offset,
519+
party,
520+
interfaceId
521+
)
522+
}
523+
524+
if (
525+
filterByParty &&
526+
!templateIds?.length &&
527+
!interfaceIds?.length &&
528+
parties?.length === 1
529+
) {
499530
const party = parties[0]
500531
const r = this.acsHelper.activeContractsForInterface(
501532
offset,
502533
party,
503534
''
504535
)
505-
this.logger.info(r)
506536
return r
507537
}
508538

509-
const filter = this.buildActiveContractsFilter(options)
510-
539+
const filter = this.buildActiveContractFilter(options)
511540
this.logger.debug('falling back to post request')
512541

513-
return await this.postWithRetry('/v2/state/active-contracts', filter)
542+
return await this.postWithRetry(
543+
'/v2/state/active-contracts',
544+
filter,
545+
defaultRetryableOptions,
546+
{
547+
query: limit ? { limit: limit.toString() } : {},
548+
}
549+
)
514550
}
515551

516-
private buildActiveContractsFilter(options: {
552+
private buildActiveContractFilter(options: {
517553
offset: number
518554
templateIds?: string[]
519-
parties?: string[]
555+
parties?: string[] //TODO: Figure out if this should use this.partyId by default and not allow cross party filtering
520556
filterByParty?: boolean
557+
interfaceIds?: string[]
558+
limit?: number
521559
}) {
522560
const filter: PostRequest<'/v2/state/active-contracts'> = {
523561
filter: {
@@ -544,29 +582,60 @@ export class LedgerClient {
544582
]
545583
}
546584

585+
const buildInterfaceFilter = (interfaceIds?: string[]) => {
586+
if (!interfaceIds) return []
587+
return [
588+
{
589+
identifierFilter: {
590+
InterfaceFilter: {
591+
value: {
592+
interfaceId: interfaceIds[0],
593+
includeCreatedEventBlob: true, //TODO: figure out if this should be configurable
594+
includeInterfaceView: true,
595+
},
596+
},
597+
},
598+
},
599+
]
600+
}
601+
602+
this.logger.info(options, 'active contract query options')
547603
if (
548604
options?.filterByParty &&
549605
options.parties &&
550606
options.parties.length > 0
551607
) {
552608
// Filter by party: set filtersByParty for each party
553-
for (const party of options.parties) {
554-
filter.filter!.filtersByParty[party] = {
555-
cumulative: options.templateIds
556-
? buildTemplateFilter(options.templateIds)
557-
: [],
609+
if (options?.templateIds && !options?.interfaceIds) {
610+
for (const party of options.parties) {
611+
filter.filter!.filtersByParty[party] = {
612+
cumulative: options.templateIds
613+
? buildTemplateFilter(options.templateIds)
614+
: [],
615+
}
616+
}
617+
} else if (options?.interfaceIds && !options?.templateIds) {
618+
for (const party of options.parties) {
619+
filter.filter!.filtersByParty[party] = {
620+
cumulative: options.interfaceIds
621+
? buildInterfaceFilter(options.interfaceIds)
622+
: [],
623+
}
558624
}
559625
}
560626
} else if (options?.templateIds) {
561627
// Only template filter, no party
562628
filter.filter!.filtersForAnyParty = {
563629
cumulative: buildTemplateFilter(options.templateIds),
564630
}
631+
} else if (options?.interfaceIds) {
632+
filter.filter!.filtersForAnyParty = {
633+
cumulative: buildInterfaceFilter(options.templateIds),
634+
}
565635
}
566636

567637
return filter
568638
}
569-
570639
public async postWithRetry<Path extends PostEndpoint>(
571640
path: Path,
572641
body: PostRequest<Path>,

0 commit comments

Comments
 (0)