@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
485 lines (408 loc) • 14.6 kB
text/typescript
/**
* Performance benchmarking script for RTKQ tag invalidation
*
* Run with:
* tsx packages/toolkit/src/query/tests/performance-invalidation.ts
*
* Or with Node.js built-in type stripping (Node 22.6+):
* node --experimental-strip-types packages/toolkit/src/query/tests/performance-invalidation.ts
*
* For CPU profiling with pprof:
* node --prof tsx packages/toolkit/src/query/tests/performance-invalidation.ts
* node --prof-process isolate-*.log > profile.txt
*/
import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query'
import { configureStore } from '@reduxjs/toolkit'
process.env.NODE_ENV = 'production' // Ensure production mode for performance testing
// Test Configuration
const NUM_ENDPOINTS = 10
const NUM_TAGS = 5
const NUM_CACHE_ENTRIES = 300
const TAG_TO_INVALIDATE = 'Tag2' // Will affect ~60 entries (20% of cache)
// Tag types for the test
const tagTypes = Array.from({ length: NUM_TAGS }, (_, i) => `Tag${i}`) as [
string,
...string[],
]
// Helper to create a store with the API
function setupStore(api: any) {
const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
immutableCheck: false,
}).concat(api.middleware),
})
return store
}
// Helper for assertions
function assert(condition: boolean, message: string) {
if (!condition) {
throw new Error(`Assertion failed: ${message}`)
}
}
// Helper to create subscriptions for cache entries
// This ensures entries aren't removed during invalidation
function createSubscriptions(
store: any,
api: any,
entries: Array<{ endpointName: string; arg: any }>,
) {
for (const { endpointName, arg } of entries) {
// Call initiate with forceRefetch: false to just add subscription without refetching
store.dispatch(
(api.endpoints as any)[endpointName].initiate(arg, {
subscribe: true,
forceRefetch: false,
}),
)
}
}
async function test1_fullInvalidationFlow() {
console.log('\n=== Test 1: Full Invalidation Flow ===')
// 1. Create API with multiple endpoints
const api = createApi({
reducerPath: 'api',
baseQuery: fakeBaseQuery(),
tagTypes,
endpoints: (build) => {
const endpoints: any = {}
// Create 10 endpoints, each providing different tag combinations
for (let i = 0; i < NUM_ENDPOINTS; i++) {
endpoints[`endpoint${i}`] = build.query({
queryFn: (id: number) => ({ data: { id, endpoint: i } }),
providesTags: (result: any, error: any, id: number) => {
// Each endpoint provides 2-3 tags based on ID
const tags: any[] = [
`Tag${id % NUM_TAGS}`,
{ type: `Tag${(id + 1) % NUM_TAGS}`, id },
]
if (id % 3 === 0) {
tags.push({ type: `Tag${(id + 2) % NUM_TAGS}`, id: 'LIST' })
}
return tags
},
})
}
// Add mutation that invalidates a specific tag
endpoints.invalidateTag = build.mutation({
queryFn: (tag: string) => ({ data: { tag } }),
invalidatesTags: (result: any, error: any, tag: string) => [tag],
})
return endpoints
},
})
const store = setupStore(api)
// 2. Populate cache with 300 entries using upsertQueryEntries
const startSetup = performance.now()
const entries = []
for (let endpointIdx = 0; endpointIdx < NUM_ENDPOINTS; endpointIdx++) {
for (let id = 0; id < NUM_CACHE_ENTRIES / NUM_ENDPOINTS; id++) {
entries.push({
endpointName: `endpoint${endpointIdx}`,
arg: id + endpointIdx * 100,
value: {
id: id + endpointIdx * 100,
endpoint: endpointIdx,
data: `Sample data ${id}`,
},
})
}
}
// Batch upsert all entries at once
store.dispatch(api.util.upsertQueryEntries(entries as any))
// Create subscriptions for all entries to prevent removal during invalidation
createSubscriptions(store, api, entries)
const setupTime = performance.now() - startSetup
console.log(
`Setup time (${NUM_CACHE_ENTRIES} entries + subscriptions): ${setupTime.toFixed(2)}ms`,
)
// 3. Verify cache is populated
const state = store.getState()
const cacheSize = Object.keys(state.api.queries).length
assert(
cacheSize === NUM_CACHE_ENTRIES,
`Expected ${NUM_CACHE_ENTRIES} cache entries, got ${cacheSize}`,
)
console.log(`✓ Cache populated with ${cacheSize} entries`)
// 4. Count how many entries will be affected by invalidation
const affectedEntries = api.util.selectInvalidatedBy(state, [
TAG_TO_INVALIDATE,
])
console.log(
`Entries affected by invalidating ${TAG_TO_INVALIDATE}: ${affectedEntries.length}`,
)
// 5. Measure invalidation performance
const startInvalidation = performance.now()
await store.dispatch(
(api.endpoints as any).invalidateTag.initiate(TAG_TO_INVALIDATE),
)
const invalidationTime = performance.now() - startInvalidation
console.log(`Invalidation time: ${invalidationTime.toFixed(2)}ms`)
// 6. Performance checks
assert(
setupTime < 200,
`Setup time ${setupTime.toFixed(2)}ms exceeded 200ms threshold`,
)
assert(
invalidationTime < 100,
`Invalidation time ${invalidationTime.toFixed(2)}ms exceeded 100ms threshold`,
)
console.log('✓ Performance within acceptable thresholds')
// 7. Verify invalidation worked
const newState = store.getState()
const invalidatedCount = affectedEntries.filter(({ queryCacheKey }) => {
const entry = newState.api.queries[queryCacheKey]
return entry?.status === 'pending' // Should be refetching
}).length
console.log(`Entries refetching after invalidation: ${invalidatedCount}`)
console.log('✓ Test 1 completed successfully\n')
}
async function test2_selectorPerformance() {
console.log('\n=== Test 2: Selector Performance ===')
// Similar setup but focuses on selector performance
const api = createApi({
reducerPath: 'api',
baseQuery: fakeBaseQuery(),
tagTypes,
endpoints: (build) => ({
// Simplified endpoint for this test
getData: build.query({
queryFn: (id: number) => ({ data: { id } }),
providesTags: (result: any, error: any, id: number) => [
`Tag${id % NUM_TAGS}`,
{ type: `Tag${(id + 1) % NUM_TAGS}` as any, id },
{ type: `Tag${(id + 2) % NUM_TAGS}` as any, id: 'LIST' },
],
}),
}),
})
const store = setupStore(api)
// Populate cache
const entries = Array.from({ length: NUM_CACHE_ENTRIES }, (_, i) => ({
endpointName: 'getData',
arg: i,
value: { id: i, data: `Data ${i}` },
}))
store.dispatch(api.util.upsertQueryEntries(entries as any))
// Create subscriptions to prevent removal during invalidation
createSubscriptions(store, api, entries)
const state = store.getState()
// Test different invalidation patterns
const patterns = [
{ name: 'Single type tag', tags: ['Tag0'] },
{ name: 'Multiple type tags', tags: ['Tag0', 'Tag1', 'Tag2'] },
{ name: 'Specific ID tag', tags: [{ type: 'Tag0', id: 5 }] },
{ name: 'LIST tag', tags: [{ type: 'Tag0', id: 'LIST' }] },
{
name: 'Mixed tags',
tags: ['Tag0', { type: 'Tag1', id: 10 }, { type: 'Tag2', id: 'LIST' }],
},
]
patterns.forEach(({ name, tags }) => {
const start = performance.now()
const affected = api.util.selectInvalidatedBy(state, tags as any)
const duration = performance.now() - start
console.log(
`${name}: ${affected.length} entries in ${duration.toFixed(3)}ms`,
)
assert(
duration < 20,
`${name} took ${duration.toFixed(3)}ms, exceeded 20ms threshold`,
)
})
console.log('✓ All selector patterns within acceptable thresholds')
console.log('✓ Test 2 completed successfully\n')
}
async function test3_hasPendingRequestsPerformance() {
console.log('\n=== Test 3: hasPendingRequests Performance ===')
// This test specifically targets the hasPendingRequests optimization opportunity
const api = createApi({
reducerPath: 'api',
baseQuery: fakeBaseQuery(),
tagTypes,
endpoints: (build) => ({
getData: build.query({
queryFn: (id: number) => ({ data: { id } }),
providesTags: ['Tag0'],
}),
invalidateAll: build.mutation({
queryFn: () => ({ data: {} }),
invalidatesTags: ['Tag0'],
}),
}),
})
const store = setupStore(api)
// Populate with many entries
const entries = Array.from({ length: NUM_CACHE_ENTRIES }, (_, i) => ({
endpointName: 'getData',
arg: i,
value: { id: i },
}))
store.dispatch(api.util.upsertQueryEntries(entries as any))
// Create subscriptions to prevent removal during invalidation
createSubscriptions(store, api, entries)
// Trigger invalidation which will cause hasPendingRequests to be called repeatedly
const start = performance.now()
await store.dispatch(api.endpoints.invalidateAll.initiate(undefined) as any)
const duration = performance.now() - start
console.log(
`Invalidation triggering hasPendingRequests: ${duration.toFixed(2)}ms`,
)
// This should highlight the O(n) iteration issue if it exists
assert(
duration < 200,
`hasPendingRequests test took ${duration.toFixed(2)}ms, exceeded 200ms threshold`,
)
console.log('✓ Test 3 completed successfully\n')
}
async function test4_tagCalculationPerformance() {
console.log('\n=== Test 4: Tag Calculation Performance ===')
// This test measures the performance of calculateProvidedBy
const api = createApi({
reducerPath: 'api',
baseQuery: fakeBaseQuery(),
tagTypes,
endpoints: (build) => ({
getComplexData: build.query({
queryFn: (id: number) => ({ data: { id } }),
providesTags: (result: any, error: any, id: number) => {
// Complex tag calculation that returns many tags
const tags: any[] = []
for (let i = 0; i < NUM_TAGS; i++) {
tags.push(`Tag${i}`)
tags.push({ type: `Tag${i}`, id })
if (id % 2 === 0) {
tags.push({ type: `Tag${i}`, id: 'LIST' })
}
}
return tags
},
}),
}),
})
const store = setupStore(api)
// Measure time to populate cache with complex tag calculations
const entries = Array.from({ length: 100 }, (_, i) => ({
endpointName: 'getComplexData',
arg: i,
value: { id: i, data: `Complex data ${i}` },
}))
const start = performance.now()
store.dispatch(api.util.upsertQueryEntries(entries as any))
// Create subscriptions to prevent removal during invalidation
createSubscriptions(store, api, entries)
const duration = performance.now() - start
console.log(
`Tag calculation for 100 entries with ${NUM_TAGS * 3} tags each: ${duration.toFixed(2)}ms`,
)
// Verify tags were calculated correctly
const state = store.getState()
const providedTags = state.api.provided
// Count total tag subscriptions
let totalSubscriptions = 0
Object.values(providedTags.tags).forEach((tagIds: any) => {
Object.values(tagIds).forEach((cacheKeys: any) => {
totalSubscriptions += cacheKeys.length
})
})
console.log(`Total tag subscriptions created: ${totalSubscriptions}`)
assert(totalSubscriptions > 0, 'Expected tag subscriptions to be created')
assert(
duration < 100,
`Tag calculation took ${duration.toFixed(2)}ms, exceeded 100ms threshold`,
)
console.log('✓ Test 4 completed successfully\n')
}
async function test5_repeatedInvalidations() {
console.log('\n=== Test 5: Repeated Invalidations Performance ===')
// This test simulates a scenario where multiple mutations invalidate tags rapidly
const api = createApi({
reducerPath: 'api',
baseQuery: fakeBaseQuery(),
tagTypes,
endpoints: (build) => ({
getData: build.query({
queryFn: (id: number) => ({ data: { id } }),
providesTags: (result: any, error: any, id: number) => [
`Tag${id % NUM_TAGS}`,
],
}),
updateData: build.mutation({
queryFn: (id: number) => ({ data: { id } }),
invalidatesTags: (result: any, error: any, id: number) => [
`Tag${id % NUM_TAGS}`,
],
}),
}),
})
const store = setupStore(api)
// Populate cache
const entries = Array.from({ length: 200 }, (_, i) => ({
endpointName: 'getData',
arg: i,
value: { id: i },
}))
store.dispatch(api.util.upsertQueryEntries(entries as any))
// Create subscriptions to prevent removal during invalidation
createSubscriptions(store, api, entries)
// Perform multiple rapid invalidations
const numInvalidations = 10
const start = performance.now()
const promises = []
for (let i = 0; i < numInvalidations; i++) {
promises.push(
store.dispatch(api.endpoints.updateData.initiate(i % NUM_TAGS) as any),
)
}
await Promise.all(promises)
const duration = performance.now() - start
const avgPerInvalidation = duration / numInvalidations
console.log(
`${numInvalidations} rapid invalidations: ${duration.toFixed(2)}ms total, ${avgPerInvalidation.toFixed(2)}ms avg`,
)
assert(
avgPerInvalidation < 50,
`Average invalidation time ${avgPerInvalidation.toFixed(2)}ms exceeded 50ms threshold`,
)
console.log('✓ Test 5 completed successfully\n')
}
// Main execution
async function runAllTests() {
console.log('='.repeat(60))
console.log('RTKQ Tag Invalidation Performance Benchmarks')
console.log('='.repeat(60))
for (const test of [
test1_fullInvalidationFlow,
test2_selectorPerformance,
test3_hasPendingRequestsPerformance,
test4_tagCalculationPerformance,
test5_repeatedInvalidations,
]) {
console.log(`\nStarting ${test.name}...`)
try {
await test()
} catch (error) {
console.error(`\n❌ ${test.name} failed:`, error)
}
}
// try {
// await test1_fullInvalidationFlow()
// await test2_selectorPerformance()
// await test3_hasPendingRequestsPerformance()
// await test4_tagCalculationPerformance()
// await test5_repeatedInvalidations()
// console.log('='.repeat(60))
// console.log('✓ All tests completed successfully!')
// console.log('='.repeat(60))
// } catch (error) {
// console.error('\n❌ Test failed:', error)
// process.exit(1)
// }
process.exit(0)
}
// Run the tests
runAllTests()