Skip to content

Commit 770da74

Browse files
authored
feat: deduplication on get many (#366)
* Adding unique utility * Get many keys deduplication * Adding some tests * Fixing minor lint issue * Lint fixes * Release prepare * Doc update
1 parent 66002d5 commit 770da74

9 files changed

Lines changed: 84 additions & 8 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Since there are a few cache solutions, here is a table comparing them:
4040
| Single Entity Fetch |||||
4141
| Bulk Entity Fetch || |||
4242
| Single Entity Fetch Deduplication (Read-Through) |||| |
43-
| Bulk Entity Fetch Deduplication | | || |
43+
| Bulk Entity Fetch Deduplication | | || |
4444
| Preemptive Cache Refresh (Refresh-Ahead) || | | |
4545
| Tiered Caches || | ||
4646
| Group Support || partially, references for invalidation | | |
@@ -216,7 +216,7 @@ Loader provides following methods:
216216
- `invalidateCacheForMany(keys: string[]): Promise<void>` - expunge all entries for given keys from all caches of this Loader;
217217
- `invalidateCache(): Promise<void>` - expunge all entries from all caches of this Loader;
218218
- `get(loadParams: LoadParams = string): Promise<T>` - sequentially attempt to retrieve data for specified key from all caches and loaders, in an order in which those data sources passed to the Loader constructor.
219-
- `getMany(keys: string[], loadParams?: P): Promise<T>` - sequentially attempt to retrieve data for specified keys from all caches and data sources, in an order in which those data sources were passed to the Loader constructor. Note that this retrieval mode doesn't support neither fetch deduplication nor the preemptive background refresh. Note that you need to manually resolve all keys upfront for this retrieval method (e. g. by using cacheKeyFromLoadParamsResolver from the Loader).
219+
- `getMany(keys: string[], loadParams?: P): Promise<T>` - sequentially attempt to retrieve data for specified keys from all caches and data sources, in an order in which those data sources were passed to the Loader constructor. Duplicate keys in the input array are automatically deduplicated to optimize performance and prevent redundant data source calls. Note that this retrieval mode doesn't support preemptive background refresh. Note that you need to manually resolve all keys upfront for this retrieval method (e. g. by using cacheKeyFromLoadParamsResolver from the Loader).
220220

221221
## Parametrized loading
222222

lib/AbstractFlatCache.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import {AbstractCache, CommonCacheConfig} from './AbstractCache'
1+
import {AbstractCache} from './AbstractCache'
22
import type { Cache } from './types/DataSources'
33
import type { GetManyResult, SynchronousCache } from './types/SyncDataSources'
44
import {InMemoryCacheConfiguration} from "./memory/InMemoryCache";
55
import {NotificationPublisher} from "./notifications/NotificationPublisher";
6+
import {unique} from "./util/unique";
67

78
export abstract class AbstractFlatCache<LoadedValue, LoadParams = string> extends AbstractCache<
89
LoadedValue,
@@ -86,7 +87,8 @@ export abstract class AbstractFlatCache<LoadedValue, LoadParams = string> extend
8687
}
8788

8889
public getMany(keys: string[], loadParams?: LoadParams): Promise<LoadedValue[]> {
89-
const inMemoryValues = this.getManyInMemoryOnly(keys)
90+
const uniqueKeys = unique(keys)
91+
const inMemoryValues = this.getManyInMemoryOnly(uniqueKeys)
9092
// everything is in memory, hurray
9193
if (inMemoryValues.unresolvedKeys.length === 0) {
9294
return Promise.resolve(inMemoryValues.resolvedValues)

lib/AbstractGroupCache.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { InMemoryGroupCacheConfiguration } from './memory/InMemoryGroupCach
33
import type { GroupNotificationPublisher } from './notifications/GroupNotificationPublisher'
44
import type { GroupCache } from './types/DataSources'
55
import type { GetManyResult, SynchronousGroupCache } from './types/SyncDataSources'
6+
import {unique} from "./util/unique";
67

78
export abstract class AbstractGroupCache<LoadedValue, LoadParams = string> extends AbstractCache<
89
LoadedValue,
@@ -84,7 +85,7 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = string> exten
8485
group: string,
8586
loadParams?: LoadParams,
8687
): Promise<GetManyResult<LoadedValue>> {
87-
// This doesn't support deduplication, and never might, as that would affect perf strongly. Maybe as an opt-in option in the future?
88+
// Deduplication is handled at the getMany level for optimal performance
8889
return this.resolveManyGroupValues(keys, group, loadParams).then((result) => {
8990
for (let i = 0; i < result.resolvedValues.length; i++) {
9091
const resolvedValue = result.resolvedValues[i]
@@ -110,7 +111,8 @@ export abstract class AbstractGroupCache<LoadedValue, LoadParams = string> exten
110111
group: string,
111112
loadParams?: LoadParams,
112113
): Promise<LoadedValue[]> {
113-
const inMemoryValues = this.getManyInMemoryOnly(keys, group)
114+
const uniqueKeys = unique(keys)
115+
const inMemoryValues = this.getManyInMemoryOnly(uniqueKeys, group)
114116
// everything is in memory, hurray
115117
if (inMemoryValues.unresolvedKeys.length === 0) {
116118
return Promise.resolve(inMemoryValues.resolvedValues)

lib/util/unique.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { unique } from './unique'
3+
4+
describe('unique', () => {
5+
it('returns a new array of mixed primitive value without duplicates', () => {
6+
const objectA = {}
7+
const objectB = {}
8+
const duplicateValues = [
9+
1,
10+
1,
11+
'a',
12+
'a',
13+
Number.NaN,
14+
Number.NaN,
15+
true,
16+
true,
17+
false,
18+
false,
19+
null,
20+
null,
21+
undefined,
22+
undefined,
23+
objectA,
24+
objectA,
25+
objectB,
26+
objectB,
27+
]
28+
29+
expect(unique(duplicateValues)).toEqual([
30+
1,
31+
'a',
32+
Number.NaN,
33+
true,
34+
false,
35+
null,
36+
undefined,
37+
objectA,
38+
objectB,
39+
])
40+
})
41+
})

lib/util/unique.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const unique = <T>(arr: T[]): T[] => Array.from(new Set(arr))

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "layered-loader",
3-
"version": "14.0.1",
3+
"version": "14.1.0",
44
"description": "Data loader with support for caching and fallback data sources ",
55
"license": "MIT",
66
"maintainers": [

test/GroupLoader-main.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,20 @@ describe('GroupLoader Main', () => {
776776
expect(value2).toEqual([user1])
777777
expect(loader.counter).toBe(2)
778778
})
779+
780+
it('deduplicates keys in getMany with mixed cache layers', async () => {
781+
const loader = new GroupLoader({
782+
inMemoryCache: IN_MEMORY_CACHE_CONFIG,
783+
asyncCache: new DummyGroupedCache(userValues),
784+
dataSources: [new CountingGroupedLoader(userValues)],
785+
cacheKeyFromValueResolver: (user: User) => user.userId,
786+
})
787+
788+
const duplicatedKeys = ['1', '1', '2', '2', '1']
789+
790+
const result = await loader.getMany(duplicatedKeys, '1')
791+
expect(result).toEqual([user1, user2])
792+
})
779793
})
780794

781795
describe('invalidateCacheFor', () => {

test/Loader-main.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,18 @@ describe('Loader Main', () => {
957957
expect(value3).toBe('value')
958958
expect(loader.counter).toBe(3)
959959
})
960+
961+
it('deduplicates keys in getMany with mixed cache layers', async () => {
962+
const loader = new Loader({
963+
inMemoryCache: IN_MEMORY_CACHE_CONFIG,
964+
asyncCache: new DummyCache('value'),
965+
dataSources: [new CountingDataSource('value')],
966+
cacheKeyFromValueResolver: idResolver,
967+
})
968+
969+
const result = await loader.getMany(['key1', 'key1', 'key2', 'key2', 'key1'])
970+
expect(result).toEqual(['value', 'value'])
971+
})
960972
})
961973

962974
describe('forceRefresh', () => {

test/fakes/CountingGroupedLoader.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ export class CountingGroupedLoader implements GroupDataSource<User> {
1717
return Promise.resolve(this.groupValues?.[group]?.[key])
1818
}
1919

20-
getManyFromGroup(keys: string[], group: string, _loadParams: undefined): Promise<User[]> {
20+
getManyFromGroup(
21+
keys: string[],
22+
group: string,
23+
_loadParams: string | undefined,
24+
): Promise<User[]> {
2125
this.counter++
2226

2327
const groupValues = this.groupValues?.[group] ?? {}

0 commit comments

Comments
 (0)