Skip to content

Commit b855bd9

Browse files
committed
feat: warm-up a cache holding Linked-Wearables on boot time
1 parent 39307a9 commit b855bd9

File tree

4 files changed

+312
-1
lines changed

4 files changed

+312
-1
lines changed
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { IBaseComponent } from '@well-known-components/interfaces'
2+
import { AppComponents, ThirdPartyProvider } from '../types'
3+
4+
export type ThirdPartyCollectionsCacheWarmer = IBaseComponent & {
5+
warmCache(): Promise<void>
6+
getStatus(): CacheWarmerStatus
7+
}
8+
9+
export type CacheWarmerStatus = {
10+
enabled: boolean
11+
lastWarmupTime?: number
12+
lastWarmupDuration?: number
13+
collectionsWarmed: number
14+
totalCollections: number
15+
errors: string[]
16+
isWarming: boolean
17+
}
18+
19+
/**
20+
* Cache warmer component that pre-loads third-party collections into cache
21+
* on service boot and periodically refreshes them.
22+
*
23+
* This eliminates the cold-start penalty where the first user to request
24+
* a collection triggers expensive pagination across thousands of entities.
25+
*/
26+
export async function createThirdPartyCollectionsCacheWarmer(
27+
components: Pick<AppComponents, 'config' | 'logs' | 'thirdPartyProvidersStorage' | 'entitiesFetcher'>
28+
): Promise<ThirdPartyCollectionsCacheWarmer> {
29+
const { config, logs, thirdPartyProvidersStorage, entitiesFetcher } = components
30+
const logger = logs.getLogger('third-party-collections-cache-warmer')
31+
32+
// Configuration
33+
const enabled = (await config.getString('CACHE_WARMER_ENABLED'))?.toLowerCase() === 'true' || false
34+
const warmupIntervalMs = (await config.getNumber('CACHE_WARMER_INTERVAL_MS')) || 1000 * 60 * 60 * 24 // 24 hours default
35+
const warmupDelayMs = (await config.getNumber('CACHE_WARMER_DELAY_MS')) || 5000 // 5 seconds delay after boot
36+
const maxConcurrent = (await config.getNumber('CACHE_WARMER_MAX_CONCURRENT')) || 3 // Warm 3 collections in parallel
37+
38+
// State
39+
let intervalId: ReturnType<typeof setInterval> | undefined
40+
let isWarming = false
41+
const status: CacheWarmerStatus = {
42+
enabled,
43+
collectionsWarmed: 0,
44+
totalCollections: 0,
45+
errors: [],
46+
isWarming: false
47+
}
48+
49+
/**
50+
* Warm a single collection by fetching all its entities
51+
*/
52+
async function warmCollection(provider: ThirdPartyProvider): Promise<void> {
53+
const collectionId = provider.id
54+
const providerName = provider.metadata?.thirdParty?.name || 'unknown'
55+
const startTime = Date.now()
56+
57+
try {
58+
logger.info('[warmCollection] Starting cache warm', {
59+
collectionId,
60+
providerName
61+
})
62+
63+
// Fetch all entities - this will populate the 48h cache
64+
const entities = await entitiesFetcher.fetchCollectionEntities(collectionId)
65+
const duration = Date.now() - startTime
66+
67+
logger.info('[warmCollection] Collection warmed successfully', {
68+
collectionId,
69+
providerName,
70+
entitiesCount: entities.length,
71+
durationMs: duration
72+
})
73+
74+
// Record metric (TODO: Uncomment when metrics are properly configured)
75+
// metrics.increment('cache_warmer_collections_warmed_total', { collection: providerName })
76+
// metrics.observe('cache_warmer_duration_seconds', duration / 1000, { collection: providerName })
77+
78+
status.collectionsWarmed++
79+
} catch (error) {
80+
const duration = Date.now() - startTime
81+
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
82+
83+
logger.error('[warmCollection] Failed to warm collection', {
84+
collectionId,
85+
providerName,
86+
error: errorMsg,
87+
durationMs: duration
88+
})
89+
90+
status.errors.push(`${collectionId}: ${errorMsg}`)
91+
92+
// Record error metric (TODO: Uncomment when metrics are properly configured)
93+
// metrics.increment('cache_warmer_errors_total', { collection: providerName })
94+
}
95+
}
96+
97+
/**
98+
* Warm collections in batches with concurrency control
99+
*/
100+
async function warmCollectionsInBatches(providers: ThirdPartyProvider[]): Promise<void> {
101+
const batches: ThirdPartyProvider[][] = []
102+
103+
// Split providers into batches
104+
for (let i = 0; i < providers.length; i += maxConcurrent) {
105+
batches.push(providers.slice(i, i + maxConcurrent))
106+
}
107+
108+
logger.info('[warmCollectionsInBatches] Processing batches', {
109+
totalProviders: providers.length,
110+
batchCount: batches.length,
111+
maxConcurrent
112+
})
113+
114+
// Process each batch sequentially, but items within batch in parallel
115+
for (let i = 0; i < batches.length; i++) {
116+
const batch = batches[i]
117+
logger.debug('[warmCollectionsInBatches] Processing batch', {
118+
batchNumber: i + 1,
119+
batchSize: batch.length
120+
})
121+
122+
await Promise.all(batch.map((provider) => warmCollection(provider)))
123+
}
124+
}
125+
126+
/**
127+
* Main cache warming function
128+
*/
129+
async function warmCache(): Promise<void> {
130+
if (!enabled) {
131+
logger.info('[warmCache] Cache warmer is disabled')
132+
return
133+
}
134+
135+
if (isWarming) {
136+
logger.warn('[warmCache] Cache warming already in progress, skipping')
137+
return
138+
}
139+
140+
isWarming = true
141+
status.isWarming = true
142+
status.errors = []
143+
status.collectionsWarmed = 0
144+
145+
const overallStart = Date.now()
146+
147+
try {
148+
logger.info('[warmCache] Starting cache warmup')
149+
150+
// Get all third-party providers
151+
const allProviders = await thirdPartyProvidersStorage.getAll()
152+
153+
// Filter providers with contracts (same logic as fetch-third-party-wearables)
154+
const providersWithContracts = allProviders.filter(
155+
(provider) => (provider.metadata.thirdParty.contracts?.length ?? 0) > 0
156+
)
157+
158+
status.totalCollections = providersWithContracts.length
159+
160+
logger.info('[warmCache] Fetched third-party providers', {
161+
totalProviders: allProviders.length,
162+
providersWithContracts: providersWithContracts.length,
163+
providersWithoutContracts: allProviders.length - providersWithContracts.length
164+
})
165+
166+
if (providersWithContracts.length === 0) {
167+
logger.warn('[warmCache] No providers with contracts found')
168+
return
169+
}
170+
171+
// Warm collections in batches
172+
await warmCollectionsInBatches(providersWithContracts)
173+
174+
const overallDuration = Date.now() - overallStart
175+
status.lastWarmupTime = overallStart
176+
status.lastWarmupDuration = overallDuration
177+
178+
logger.info('[warmCache] Cache warmup complete', {
179+
totalProviders: status.totalCollections,
180+
warmedSuccessfully: status.collectionsWarmed,
181+
failed: status.totalCollections - status.collectionsWarmed,
182+
errorsCount: status.errors.length,
183+
durationMs: overallDuration,
184+
avgDurationPerCollection: Math.round(overallDuration / status.totalCollections)
185+
})
186+
187+
// Record overall metrics (TODO: Uncomment when metrics are properly configured)
188+
// metrics.observe('cache_warmer_total_duration_seconds', {}, overallDuration / 1000)
189+
// metrics.increment('cache_warmer_last_warmup_timestamp', {}, overallStart / 1000)
190+
} catch (error) {
191+
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
192+
logger.error('[warmCache] Fatal error during cache warmup', {
193+
error: errorMsg
194+
})
195+
196+
status.errors.push(`Fatal: ${errorMsg}`)
197+
// metrics.increment('cache_warmer_fatal_errors_total') // TODO: Uncomment when metrics are properly configured
198+
} finally {
199+
isWarming = false
200+
status.isWarming = false
201+
}
202+
}
203+
204+
/**
205+
* Get current cache warmer status
206+
*/
207+
function getStatus(): CacheWarmerStatus {
208+
return { ...status }
209+
}
210+
211+
/**
212+
* Start the cache warmer component
213+
*/
214+
async function start() {
215+
if (!enabled) {
216+
logger.info('[start] Cache warmer is disabled via config')
217+
return
218+
}
219+
220+
logger.info('[start] Cache warmer starting', {
221+
warmupIntervalMs,
222+
warmupDelayMs,
223+
maxConcurrent
224+
})
225+
226+
// Initial warmup after delay
227+
setTimeout(() => {
228+
logger.info('[start] Starting initial cache warmup')
229+
warmCache().catch((error) => {
230+
logger.error('[start] Initial warmup failed', {
231+
error: error instanceof Error ? error.message : 'Unknown error'
232+
})
233+
})
234+
}, warmupDelayMs)
235+
236+
// Periodic warmup
237+
intervalId = setInterval(() => {
238+
logger.info('[start] Starting periodic cache warmup')
239+
warmCache().catch((error) => {
240+
logger.error('[start] Periodic warmup failed', {
241+
error: error instanceof Error ? error.message : 'Unknown error'
242+
})
243+
})
244+
}, warmupIntervalMs)
245+
246+
logger.info('[start] Cache warmer started successfully')
247+
}
248+
249+
/**
250+
* Stop the cache warmer component
251+
*/
252+
async function stop() {
253+
logger.info('[stop] Stopping cache warmer')
254+
255+
if (intervalId) {
256+
clearInterval(intervalId)
257+
intervalId = undefined
258+
}
259+
260+
logger.info('[stop] Cache warmer stopped')
261+
}
262+
263+
return {
264+
start,
265+
stop,
266+
warmCache,
267+
getStatus
268+
}
269+
}

src/components.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { createThirdPartyItemChecker } from './ports/ownership-checker/third-par
4242
import { createParcelRightsComponent } from './adapters/parcel-rights-fetcher'
4343
import { fetchNameOwner } from './logic/fetch-elements/fetch-name-owner'
4444
import { fetchAllPermissions } from './logic/fetch-elements/fetch-permissions'
45+
import { createThirdPartyCollectionsCacheWarmer } from './adapters/third-party-collections-cache-warmer'
4546

4647
// Initialize all the components of the app
4748
export async function initComponents(
@@ -194,6 +195,14 @@ export async function initComponents(
194195
l2ThirdPartyItemChecker
195196
})
196197

198+
// Create cache warmer component
199+
const thirdPartyCollectionsCacheWarmer = await createThirdPartyCollectionsCacheWarmer({
200+
config,
201+
logs,
202+
thirdPartyProvidersStorage,
203+
entitiesFetcher
204+
})
205+
197206
return {
198207
config,
199208
logs,
@@ -230,6 +239,7 @@ export async function initComponents(
230239
l1ThirdPartyItemChecker,
231240
l2ThirdPartyItemChecker,
232241
marketplaceApiFetcher,
233-
nameOwnerFetcher
242+
nameOwnerFetcher,
243+
thirdPartyCollectionsCacheWarmer
234244
}
235245
}

src/metrics.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,36 @@ export const metricDeclarations = {
2222
help: 'Third Party Provider fetch assets request duration in seconds.',
2323
type: IMetricsComponent.HistogramType,
2424
labelNames: ['id']
25+
},
26+
cache_warmer_collections_warmed_total: {
27+
help: 'Total number of collections successfully warmed by the cache warmer',
28+
type: IMetricsComponent.CounterType,
29+
labelNames: ['collection']
30+
},
31+
cache_warmer_duration_seconds: {
32+
help: 'Duration of cache warming per collection in seconds',
33+
type: IMetricsComponent.HistogramType,
34+
labelNames: ['collection']
35+
},
36+
cache_warmer_errors_total: {
37+
help: 'Total number of cache warming errors',
38+
type: IMetricsComponent.CounterType,
39+
labelNames: ['collection']
40+
},
41+
cache_warmer_fatal_errors_total: {
42+
help: 'Total number of fatal cache warming errors',
43+
type: IMetricsComponent.CounterType,
44+
labelNames: []
45+
},
46+
cache_warmer_total_duration_seconds: {
47+
help: 'Total duration of full cache warmup cycle in seconds',
48+
type: IMetricsComponent.HistogramType,
49+
labelNames: []
50+
},
51+
cache_warmer_last_warmup_timestamp: {
52+
help: 'Unix timestamp of the last successful cache warmup',
53+
type: IMetricsComponent.GaugeType,
54+
labelNames: []
2555
}
2656
}
2757

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { AlchemyNftFetcher } from './adapters/alchemy-nft-fetcher'
3838
import { ThirdPartyItemChecker } from './ports/ownership-checker/third-party-item-checker'
3939
import { ParcelRightsFetcher } from './adapters/parcel-rights-fetcher'
4040
import { MarketplaceApiFetcher } from './adapters/marketplace-api-fetcher'
41+
import { ThirdPartyCollectionsCacheWarmer } from './adapters/third-party-collections-cache-warmer'
4142

4243
export type GlobalContext = {
4344
components: BaseComponents
@@ -80,6 +81,7 @@ export type BaseComponents = {
8081
l2ThirdPartyItemChecker: ThirdPartyItemChecker
8182
marketplaceApiFetcher?: MarketplaceApiFetcher
8283
nameOwnerFetcher: ElementsFetcher<NameOwner>
84+
thirdPartyCollectionsCacheWarmer: ThirdPartyCollectionsCacheWarmer
8385
}
8486

8587
// components used in runtime

0 commit comments

Comments
 (0)