Skip to content

Commit

Permalink
enhance: Add garbage collection based on expiry time
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Jan 4, 2025
1 parent 060d8e1 commit 67752cc
Show file tree
Hide file tree
Showing 13 changed files with 167 additions and 9 deletions.
1 change: 1 addition & 0 deletions packages/core/src/actionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const INVALIDATE = 'rdc/invalidate' as const;
export const INVALIDATEALL = 'rdc/invalidateall' as const;
export const EXPIREALL = 'rdc/expireall' as const;
export const GC = 'rdc/gc' as const;
export const REF = 'rdc/ref' as const;

export const FETCH_TYPE = FETCH;
export const SET_TYPE = SET;
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
Denormalize,
EndpointInterface,
EntityPath,
Queryable,
ResolveType,
UnknownError,
Expand All @@ -18,6 +19,7 @@ import type {
INVALIDATEALL,
EXPIREALL,
SET_RESPONSE,
REF,
} from './actionTypes.js';
import type { EndpointUpdateFunction } from './controller/types.js';

Expand Down Expand Up @@ -148,9 +150,16 @@ export interface ResetAction {
/* GC */
export interface GCAction {
type: typeof GC;
entities: [string, string][];
entities: EntityPath[];
endpoints: string[];
}
/* ref counting */
export interface RefAction<E extends EndpointAndUpdate<E> = EndpointDefault> {
type: typeof REF;
key: string;
paths: EntityPath[];
incr: boolean;
}

/** @see https://dataclient.io/docs/api/Actions */
export type ActionTypes =
Expand All @@ -164,4 +173,5 @@ export type ActionTypes =
| InvalidateAllAction
| ExpireAllAction
| ResetAction
| GCAction;
| GCAction
| RefAction;
17 changes: 16 additions & 1 deletion packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type { EndpointUpdateFunction } from './types.js';
import { initialState } from '../state/reducer/createReducer.js';
import selectMeta from '../state/selectMeta.js';
import type { ActionTypes, State } from '../types.js';
import { createCountRef } from './actions/createCountRef.js';

export type GenericDispatch = (value: any) => Promise<void>;
export type DataClientDispatch = (value: ActionTypes) => Promise<void>;
Expand Down Expand Up @@ -389,6 +390,7 @@ export default class Controller<
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
countRef: () => () => void;
};

getResponse<
Expand All @@ -403,6 +405,7 @@ export default class Controller<
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
countRef: () => () => void;
};

getResponse(
Expand All @@ -412,6 +415,7 @@ export default class Controller<
data: unknown;
expiryStatus: ExpiryStatus;
expiresAt: number;
countRef: () => () => void;
} {
const state = rest[rest.length - 1] as State<unknown>;
// this is typescript generics breaking
Expand Down Expand Up @@ -446,12 +450,14 @@ export default class Controller<
data: input as any,
expiryStatus: ExpiryStatus.Valid,
expiresAt: Infinity,
countRef: () => () => undefined,
};
}

let isInvalid = false;
if (shouldQuery) {
isInvalid = !validateQueryKey(input);
// endpoint without entities
} else if (!schema || !schemaHasEntity(schema)) {
return {
data: cacheEndpoints,
Expand All @@ -460,6 +466,7 @@ export default class Controller<
: cacheEndpoints && !endpoint.invalidIfStale ? ExpiryStatus.Valid
: ExpiryStatus.InvalidIfStale,
expiresAt: expiresAt || 0,
countRef: createCountRef(this.dispatch, { key }),
};
}

Expand All @@ -477,6 +484,7 @@ export default class Controller<

return this.getSchemaResponse(
data,
key,
paths,
state.entityMeta,
expiresAt,
Expand Down Expand Up @@ -507,6 +515,7 @@ export default class Controller<

private getSchemaResponse<T>(
data: T,
key: string,
paths: EntityPath[],
entityMeta: State<unknown>['entityMeta'],
expiresAt: number,
Expand All @@ -516,6 +525,7 @@ export default class Controller<
data: T;
expiryStatus: ExpiryStatus;
expiresAt: number;
countRef: () => () => void;
} {
const invalidDenormalize = typeof data === 'symbol';

Expand All @@ -533,7 +543,12 @@ export default class Controller<
: invalidDenormalize || invalidIfStale ? ExpiryStatus.InvalidIfStale
: ExpiryStatus.Valid;

return { data, expiryStatus, expiresAt };
return {
data,
expiryStatus,
expiresAt,
countRef: createCountRef(this.dispatch, { key, paths }),
};
}
}

Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/controller/actions/createCountRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { EntityPath } from '@data-client/normalizr';

import { REF } from '../../actionTypes.js';
import type { RefAction } from '../../types.js';

export function createCountRef(
dispatch: (action: RefAction) => void,
{
key,
paths = [],
}: {
key: string;
paths?: EntityPath[];
},
) {
return () => {
dispatch({
type: REF,
key,
paths,
incr: true,
});
return () =>
dispatch({
type: REF,
key,
paths,
incr: false,
});
};
}
88 changes: 88 additions & 0 deletions packages/core/src/manager/GCManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { GCAction } from '../actions.js';
import { REF } from '../actionTypes.js';
import { GC } from '../actionTypes.js';
import Controller from '../controller/Controller.js';
import type { Manager, Middleware } from '../types.js';

export default class GCManager implements Manager {
protected endpointCount: Record<string, number> = {};
protected entityCount: Record<string, Record<string, number>> = {};
protected endpoints = new Set<string>();
protected entities: GCAction['entities'] = [];
declare protected intervalId: ReturnType<typeof setInterval>;
declare protected controller: Controller;

middleware: Middleware = controller => {
this.controller = controller;
return next => async action => {
if (action.type === REF) {
const { key, paths, incr } = action;
if (incr) {
this.endpointCount[key]++;
paths.forEach(path => {
if (!(path.key in this.endpointCount)) {
this.entityCount[path.key] = {};
}
this.entityCount[path.key][path.pk]++;
});
} else {
if (this.endpointCount[key]-- <= 0) {
// queue for cleanup
this.endpoints.add(key);
this.entities.concat(...paths);
}
paths.forEach(path => {
if (!(path.key in this.endpointCount)) {
return;
}
this.entityCount[path.key][path.pk]--;
});
}
return;
}
return next(action);
};
};

protected runSweep() {
const state = this.controller.getState();
const entities: GCAction['entities'] = [];
const endpoints: string[] = [];
const now = Date.now();
for (const key of this.endpoints) {
const expiresAt = state.meta[key]?.expiresAt ?? 0;
if (expiresAt > now) {
endpoints.push(key);
}
}
for (const path of this.entities) {
const expiresAt = state.entityMeta[path.key]?.[path.pk]?.expiresAt ?? 0;
if (expiresAt > now) {
entities.push(path);
}
}
this.controller.dispatch({
type: GC,
entities,
endpoints,
});
}

init() {
this.intervalId = setInterval(
() => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => this.runSweep(), { timeout: 1000 });
} else {
this.runSweep();
}
},
// every 5 min
60 * 1000 * 5,
);
}

cleanup() {
clearInterval(this.intervalId);
}
}
1 change: 0 additions & 1 deletion packages/core/src/manager/SubscriptionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { SUBSCRIBE, UNSUBSCRIBE } from '../actionTypes.js';
import Controller from '../controller/Controller.js';
import type {
Manager,
MiddlewareAPI,
Middleware,
UnsubscribeAction,
SubscribeAction,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/state/reducer/createReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function createReducer(controller: Controller): ReducerType {
switch (action.type) {
case GC:
// inline deletes are fine as these should have 0 refcounts
action.entities.forEach(([key, pk]) => {
action.entities.forEach(({ key, pk }) => {
delete (state as any).entities[key]?.[pk];
delete (state as any).entityMeta[key]?.[pk];
});
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/hooks/useDLE.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { InteractionManager } from 'react-native';
import useCacheState from './useCacheState.js';
import useController from './useController.js';
import useFocusEffect from './useFocusEffect.native.js';
import { useUniveralEffect } from './useUniversalEffect.js';

type SchemaReturn<S extends Schema | undefined> =
| {
Expand Down Expand Up @@ -81,7 +82,7 @@ export default function useDLE<

// Compute denormalized value
// eslint-disable-next-line prefer-const
let { data, expiryStatus, expiresAt } = useMemo(() => {
let { data, expiryStatus, expiresAt, countRef } = useMemo(() => {
return controller.getResponse(endpoint, ...args, state);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
Expand Down Expand Up @@ -136,6 +137,8 @@ export default function useDLE<

const error = controller.getError(endpoint, ...args, state);

useUniveralEffect(countRef, [data]);

return {
data,
loading,
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/hooks/useDLE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useMemo } from 'react';

import useCacheState from './useCacheState.js';
import useController from './useController.js';
import { useUniveralEffect } from './useUniversalEffect.js';

type SchemaReturn<S extends Schema | undefined> =
| {
Expand Down Expand Up @@ -79,7 +80,7 @@ export default function useDLE<

// Compute denormalized value
// eslint-disable-next-line prefer-const
let { data, expiryStatus, expiresAt } = useMemo(() => {
let { data, expiryStatus, expiresAt, countRef } = useMemo(() => {
return controller.getResponse(endpoint, ...args, state);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
Expand Down Expand Up @@ -123,6 +124,8 @@ export default function useDLE<

const error = controller.getError(endpoint, ...args, state);

useUniveralEffect(countRef, [data]);

return {
data,
loading,
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/hooks/useSuspense.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { InteractionManager } from 'react-native';
import useCacheState from './useCacheState.js';
import useController from './useController.js';
import useFocusEffect from './useFocusEffect.native.js';
import { useUniveralEffect } from './useUniversalEffect.js';

/**
* Ensure an endpoint is available.
Expand Down Expand Up @@ -63,7 +64,7 @@ export default function useSuspense<
const meta = state.meta[key];

// Compute denormalized value
const { data, expiryStatus, expiresAt } = useMemo(() => {
const { data, expiryStatus, expiresAt, countRef } = useMemo(() => {
return controller.getResponse(endpoint, ...args, state);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
Expand Down Expand Up @@ -112,5 +113,7 @@ export default function useSuspense<
return () => task.cancel();
}, []);

useUniveralEffect(countRef, [data]);

return data;
}
5 changes: 4 additions & 1 deletion packages/react/src/hooks/useSuspense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useMemo } from 'react';

import useCacheState from './useCacheState.js';
import useController from './useController.js';
import { useUniveralEffect } from './useUniversalEffect.js';

/**
* Ensure an endpoint is available.
Expand Down Expand Up @@ -60,7 +61,7 @@ export default function useSuspense<
const meta = state.meta[key];

// Compute denormalized value
const { data, expiryStatus, expiresAt } = useMemo(() => {
const { data, expiryStatus, expiresAt, countRef } = useMemo(() => {
return controller.getResponse(endpoint, ...args, state);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
Expand Down Expand Up @@ -98,5 +99,7 @@ export default function useSuspense<

if (error) throw error;

useUniveralEffect(countRef, [data]);

return data;
}
1 change: 1 addition & 0 deletions packages/react/src/hooks/useUniversalEffect.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useUniveralEffect } from './useFocusEffect';
1 change: 1 addition & 0 deletions packages/react/src/hooks/useUniversalEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useEffect as useUniveralEffect } from 'react';

0 comments on commit 67752cc

Please sign in to comment.