Skip to content

Commit 57df2c2

Browse files
authored
feat: Add hasExpired and expiryMultiplier options to GCPolicy (#3374)
* feat: Add hasExpired and expiryMultiplier options to GCPolicy * enhance: Change to expiresAt for expiry policy
1 parent f796b6c commit 57df2c2

File tree

16 files changed

+357
-96
lines changed

16 files changed

+357
-96
lines changed

.changeset/silly-hotels-shout.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,28 @@
22
'@data-client/react': patch
33
---
44

5-
Add gcPolicy option to DataProvider and prepareStore
5+
Add gcPolicy option to [DataProvider](https://dataclient.io/docs/api/DataProvider) and [prepareStore](https://dataclient.io/docs/guides/redux)
6+
7+
```tsx
8+
// run GC sweep every 10min
9+
<DataProvider gcPolicy={new GCPolicy({ intervalMS: 60 * 1000 * 10 })}>{children}</DataProvider>
10+
```
11+
12+
```ts
13+
const { store, selector, controller } = prepareStore(
14+
initialState,
15+
managers,
16+
Controller,
17+
otherReducers,
18+
extraMiddlewares,
19+
gcPolicy: new GCPolicy({ intervalMS: 60 * 1000 * 10 }),
20+
);
21+
```
22+
23+
To maintain existing behavior, use `ImmortalGCPolicy`:
24+
25+
```tsx
26+
import { ImmortalGCPolicy, DataProvider } from '@data-client/react';
27+
28+
<DataProvider gcPolicy={new ImmortalGCPolicy()}>{children}</DataProvider>
29+
```

.changeset/strange-buckets-clean.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,49 @@
33
'@data-client/core': patch
44
---
55

6-
Add GCPolicy
6+
Add GCPolicy to control Garbage Collection of data in the store.
7+
8+
This can be configured with constructor options, or custom GCPolicies implemented by extending
9+
or simply building your own. Use `ImmortalGCPolicy` to never GC (to maintain existing behavior).
10+
11+
### constructor
12+
13+
#### intervalMS = 60 * 1000 * 5
14+
15+
How long between low priority GC sweeps.
16+
17+
Longer values may result in more memory usage, but less performance impact.
18+
19+
#### expiryMultiplier = 2
20+
21+
Used in the default `hasExpired` policy.
22+
23+
Represents how many 'stale' lifetimes data should persist before being
24+
garbage collected.
25+
26+
#### expiresAt
27+
28+
```typescript
29+
expiresAt({
30+
fetchedAt,
31+
expiresAt,
32+
}: {
33+
expiresAt: number;
34+
date: number;
35+
fetchedAt: number;
36+
}): number {
37+
return (
38+
Math.max(
39+
(expiresAt - fetchedAt) * this.options.expiryMultiplier,
40+
120000,
41+
) + fetchedAt
42+
);
43+
}
44+
```
45+
46+
Indicates at what timestamp it is acceptable to remove unused data from the store.
47+
48+
Data not currently rendered in any components is considered unused. However, unused
49+
data may be used again in the future (as a cache).
50+
51+
This results in a tradeoff between memory usage and cache hit rate (and thus performance).

packages/core/src/controller/__tests__/Controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ describe('Controller', () => {
5050
meta: {
5151
[fetchKey]: {
5252
date: Date.now(),
53+
fetchedAt: Date.now(),
5354
expiresAt: Date.now() + 10000,
5455
},
5556
},
@@ -88,6 +89,7 @@ describe('Controller', () => {
8889
meta: {
8990
[fetchKey]: {
9091
date: 0,
92+
fetchedAt: 0,
9193
expiresAt: 0,
9294
},
9395
},

packages/core/src/state/GCPolicy.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@ export class GCPolicy implements GCInterface {
1111

1212
declare protected intervalId: ReturnType<typeof setInterval>;
1313
declare protected controller: Controller;
14-
declare protected options: GCOptions;
14+
declare protected options: Required<Omit<GCOptions, 'expiresAt'>>;
1515

16-
constructor(
16+
constructor({
1717
// every 5 min
18-
{ intervalMS = 60 * 1000 * 5 }: GCOptions = {},
19-
) {
20-
this.options = { intervalMS };
18+
intervalMS = 60 * 1000 * 5,
19+
expiryMultiplier = 2,
20+
expiresAt,
21+
}: GCOptions = {}) {
22+
if (expiresAt) {
23+
this.expiresAt = expiresAt.bind(this);
24+
}
25+
this.options = {
26+
intervalMS,
27+
expiryMultiplier,
28+
};
2129
}
2230

2331
init(controller: Controller) {
@@ -79,6 +87,22 @@ export class GCPolicy implements GCInterface {
7987
};
8088
}
8189

90+
protected expiresAt({
91+
fetchedAt,
92+
expiresAt,
93+
}: {
94+
expiresAt: number;
95+
date: number;
96+
fetchedAt: number;
97+
}): number {
98+
return (
99+
Math.max(
100+
(expiresAt - fetchedAt) * this.options.expiryMultiplier,
101+
120000,
102+
) + fetchedAt
103+
);
104+
}
105+
82106
protected runSweep() {
83107
const state = this.controller.getState();
84108
const entities: EntityPath[] = [];
@@ -87,8 +111,16 @@ export class GCPolicy implements GCInterface {
87111

88112
const nextEndpointsQ = new Set<string>();
89113
for (const key of this.endpointsQ) {
90-
const expiresAt = state.meta[key]?.expiresAt ?? 0;
91-
if (expiresAt < now && !this.endpointCount.has(key)) {
114+
if (
115+
!this.endpointCount.has(key) &&
116+
this.expiresAt(
117+
state.meta[key] ?? {
118+
fetchedAt: 0,
119+
date: 0,
120+
expiresAt: 0,
121+
},
122+
) < now
123+
) {
92124
endpoints.push(key);
93125
} else {
94126
nextEndpointsQ.add(key);
@@ -98,8 +130,16 @@ export class GCPolicy implements GCInterface {
98130

99131
const nextEntitiesQ: EntityPath[] = [];
100132
for (const path of this.entitiesQ) {
101-
const expiresAt = state.entityMeta[path.key]?.[path.pk]?.expiresAt ?? 0;
102-
if (expiresAt < now && !this.entityCount.get(path.key)?.has(path.pk)) {
133+
if (
134+
!this.entityCount.get(path.key)?.has(path.pk) &&
135+
this.expiresAt(
136+
state.entityMeta[path.key]?.[path.pk] ?? {
137+
fetchedAt: 0,
138+
date: 0,
139+
expiresAt: 0,
140+
},
141+
) < now
142+
) {
103143
entities.push(path);
104144
} else {
105145
nextEntitiesQ.push(path);
@@ -140,6 +180,12 @@ export class ImmortalGCPolicy implements GCInterface {
140180

141181
export interface GCOptions {
142182
intervalMS?: number;
183+
expiryMultiplier?: number;
184+
expiresAt?: (meta: {
185+
expiresAt: number;
186+
date: number;
187+
fetchedAt: number;
188+
}) => number;
143189
}
144190
export interface CreateCountRef {
145191
({ key, paths }: { key?: string; paths?: EntityPath[] }): () => () => void;

packages/core/src/state/__tests__/GCPolicy.test.ts

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EntityPath } from '@data-client/normalizr';
22
import { jest } from '@jest/globals';
3+
import { has } from 'benchmark';
34

45
import { GC } from '../../actionTypes';
56
import Controller from '../../controller/Controller';
@@ -40,8 +41,22 @@ describe('GCPolicy', () => {
4041
const key = 'testEndpoint';
4142
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
4243
const state = {
43-
meta: { testEndpoint: { expiresAt: Date.now() - 1000 } },
44-
entityMeta: { testEntity: { '1': { expiresAt: Date.now() - 1000 } } },
44+
meta: {
45+
testEndpoint: {
46+
date: 0,
47+
fetchedAt: 0,
48+
expiresAt: 0,
49+
},
50+
},
51+
entityMeta: {
52+
testEntity: {
53+
'1': {
54+
date: 0,
55+
fetchedAt: 0,
56+
expiresAt: 0,
57+
},
58+
},
59+
},
4560
};
4661
(controller.getState as jest.Mock).mockReturnValue(state);
4762

@@ -68,8 +83,22 @@ describe('GCPolicy', () => {
6883
const key = 'testEndpoint';
6984
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
7085
const state = {
71-
meta: { testEndpoint: { expiresAt: Date.now() - 1000 } },
72-
entityMeta: { testEntity: { '1': { expiresAt: Date.now() - 1000 } } },
86+
meta: {
87+
testEndpoint: {
88+
date: 0,
89+
fetchedAt: 0,
90+
expiresAt: 0,
91+
},
92+
},
93+
entityMeta: {
94+
testEntity: {
95+
'1': {
96+
date: 0,
97+
fetchedAt: 0,
98+
expiresAt: 0,
99+
},
100+
},
101+
},
73102
};
74103
(controller.getState as jest.Mock).mockReturnValue(state);
75104

@@ -127,8 +156,22 @@ describe('GCPolicy', () => {
127156
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
128157
const futureTime = Date.now() + 1000;
129158
const state = {
130-
meta: { testEndpoint: { expiresAt: futureTime } },
131-
entityMeta: { testEntity: { '1': { expiresAt: futureTime } } },
159+
meta: {
160+
testEndpoint: {
161+
date: futureTime - 100,
162+
fetchAt: futureTime - 100,
163+
expiresAt: futureTime,
164+
},
165+
},
166+
entityMeta: {
167+
testEntity: {
168+
'1': {
169+
date: futureTime - 100,
170+
fetchAt: futureTime - 100,
171+
expiresAt: futureTime,
172+
},
173+
},
174+
},
132175
};
133176
(controller.getState as jest.Mock).mockReturnValue(state);
134177

@@ -146,8 +189,22 @@ describe('GCPolicy', () => {
146189
// Fast forward time to past the futureTime
147190
jest.advanceTimersByTime(2000);
148191
(controller.getState as jest.Mock).mockReturnValue({
149-
meta: { testEndpoint: { expiresAt: Date.now() - 1000 } },
150-
entityMeta: { testEntity: { '1': { expiresAt: Date.now() - 1000 } } },
192+
meta: {
193+
testEndpoint: {
194+
date: 0,
195+
fetchedAt: 0,
196+
expiresAt: 0,
197+
},
198+
},
199+
entityMeta: {
200+
testEntity: {
201+
'1': {
202+
date: 0,
203+
fetchedAt: 0,
204+
expiresAt: 0,
205+
},
206+
},
207+
},
151208
});
152209

153210
gcPolicy['runSweep']();
@@ -160,4 +217,42 @@ describe('GCPolicy', () => {
160217

161218
jest.useRealTimers();
162219
});
220+
221+
it('should support custom hasExpired', () => {
222+
jest.useFakeTimers();
223+
gcPolicy = new GCPolicy({ expiresAt: () => 0 });
224+
gcPolicy.init(controller);
225+
const key = 'testEndpoint';
226+
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
227+
const futureTime = Date.now() + 1000;
228+
const state = {
229+
meta: {
230+
testEndpoint: {
231+
date: futureTime - 100,
232+
fetchAt: futureTime - 100,
233+
expiresAt: futureTime,
234+
},
235+
},
236+
entityMeta: {
237+
testEntity: {
238+
'1': {
239+
date: futureTime - 100,
240+
fetchAt: futureTime - 100,
241+
expiresAt: futureTime,
242+
},
243+
},
244+
},
245+
};
246+
(controller.getState as jest.Mock).mockReturnValue(state);
247+
248+
const countRef = gcPolicy.createCountRef({ key, paths });
249+
250+
const decrement = countRef();
251+
countRef(); // Increment again
252+
decrement();
253+
decrement(); // Decrement twice
254+
255+
gcPolicy['runSweep']();
256+
expect(controller.dispatch).toHaveBeenCalled();
257+
});
163258
});

packages/core/src/state/__tests__/__snapshots__/reducer.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ exports[`reducer should set error in meta for "set" 1`] = `
1313
"error": [Error: hi],
1414
"errorPolicy": undefined,
1515
"expiresAt": 5000500000,
16+
"fetchedAt": 5000000000,
1617
},
1718
},
1819
"optimistic": [],
@@ -48,6 +49,7 @@ exports[`reducer singles should update state correctly 1`] = `
4849
"http://test.com/article/20": {
4950
"date": 5000000000,
5051
"expiresAt": 5000500000,
52+
"fetchedAt": 5000000000,
5153
"prevExpiresAt": undefined,
5254
},
5355
},

packages/core/src/state/reducer/setResponseReducer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function setResponseReducer(
7575
...state.meta,
7676
[action.key]: {
7777
date: action.meta.date,
78+
fetchedAt: action.meta.fetchedAt,
7879
expiresAt: action.meta.expiresAt,
7980
prevExpiresAt: state.meta[action.key]?.expiresAt,
8081
},
@@ -126,8 +127,9 @@ function reduceError(
126127
...state.meta,
127128
[action.key]: {
128129
date: action.meta.date,
129-
error,
130+
fetchedAt: action.meta.fetchedAt,
130131
expiresAt: action.meta.expiresAt,
132+
error,
131133
errorPolicy: action.endpoint.errorPolicy?.(error),
132134
},
133135
},

packages/core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface State<T> {
3333
readonly meta: {
3434
readonly [key: string]: {
3535
readonly date: number;
36+
readonly fetchedAt: number;
3637
readonly expiresAt: number;
3738
readonly prevExpiresAt?: number;
3839
readonly error?: ErrorTypes;

0 commit comments

Comments
 (0)