Skip to content

Commit ff45ee8

Browse files
committed
use single supplier so hook is debounced in entirety
Signed-off-by: Todd Baert <[email protected]>
1 parent d56725e commit ff45ee8

File tree

3 files changed

+99
-100
lines changed

3 files changed

+99
-100
lines changed

libs/hooks/debounce/src/lib/debounce-hook.spec.ts

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,7 @@ describe('DebounceHook', () => {
1414
finally: jest.fn(),
1515
};
1616

17-
const supplier = (flagKey: string) => flagKey;
18-
1917
const hook = new DebounceHook<string>(innerHook, {
20-
beforeCacheKeySupplier: supplier,
21-
afterCacheKeySupplier: supplier,
22-
errorCacheKeySupplier: supplier,
23-
finallyCacheKeySupplier: supplier,
2418
debounceTime: 60_000,
2519
maxCacheItems: 100,
2620
});
@@ -71,6 +65,31 @@ describe('DebounceHook', () => {
7165
hints,
7266
);
7367
});
68+
69+
it('stages should be cached independently', () => {
70+
const innerHook: Hook = {
71+
before: jest.fn(),
72+
after: jest.fn(),
73+
};
74+
75+
const hook = new DebounceHook<boolean>(innerHook, {
76+
debounceTime: 60_000,
77+
maxCacheItems: 100,
78+
});
79+
80+
const flagKey = 'my-flag';
81+
82+
hook.before({ flagKey } as HookContext, {});
83+
hook.after({ flagKey } as HookContext, {
84+
flagKey,
85+
flagMetadata: {},
86+
value: true,
87+
});
88+
89+
// both should run
90+
expect(innerHook.before).toHaveBeenCalledTimes(1);
91+
expect(innerHook.after).toHaveBeenCalledTimes(1);
92+
});
7493
});
7594

7695
describe('options', () => {
@@ -84,7 +103,6 @@ describe('DebounceHook', () => {
84103
};
85104

86105
const hook = new DebounceHook<string>(innerHook, {
87-
beforeCacheKeySupplier: (flagKey: string) => flagKey,
88106
debounceTime: 60_000,
89107
maxCacheItems: 1,
90108
});
@@ -105,7 +123,6 @@ describe('DebounceHook', () => {
105123
const flagKey = 'some-flag';
106124

107125
const hook = new DebounceHook<string>(innerHook, {
108-
beforeCacheKeySupplier: (flagKey: string) => flagKey,
109126
debounceTime: 500,
110127
maxCacheItems: 1,
111128
});
@@ -122,40 +139,28 @@ describe('DebounceHook', () => {
122139
expect(innerHook.before).toHaveBeenCalledTimes(2);
123140
});
124141

125-
it('noop if supplier not defined', () => {
142+
it('use custom supplier', () => {
126143
const innerHook: Hook = {
127144
before: jest.fn(),
128145
after: jest.fn(),
129146
error: jest.fn(),
130147
finally: jest.fn(),
131148
};
132149

133-
const flagKey = 'some-flag';
134150
const context = {};
135151
const hints = {};
136152

137-
// no suppliers defined, so we no-op (do no caching)
138153
const hook = new DebounceHook<string>(innerHook, {
154+
cacheKeySupplier: () => 'a-silly-const-key', // a constant key means all invocations are cached; just to test that the custom supplier is used
139155
debounceTime: 60_000,
140156
maxCacheItems: 100,
141157
});
142158

143-
const evaluationDetails: EvaluationDetails<string> = {
144-
value: 'testValue',
145-
} as EvaluationDetails<string>;
146-
147-
for (let i = 0; i < 3; i++) {
148-
hook.before({ flagKey, context } as HookContext, hints);
149-
hook.after({ flagKey, context } as HookContext, evaluationDetails, hints);
150-
hook.error({ flagKey, context } as HookContext, hints);
151-
hook.finally({ flagKey, context } as HookContext, evaluationDetails, hints);
152-
}
159+
hook.before({ flagKey: 'flag1', context } as HookContext, hints);
160+
hook.before({ flagKey: 'flag2', context } as HookContext, hints);
153161

154-
// every invocation should have run since we have only maxCacheItems: 1
155-
expect(innerHook.before).toHaveBeenCalledTimes(3);
156-
expect(innerHook.after).toHaveBeenCalledTimes(3);
157-
expect(innerHook.error).toHaveBeenCalledTimes(3);
158-
expect(innerHook.finally).toHaveBeenCalledTimes(3);
162+
// since we used a constant key, the second invocation should have been cached even though the flagKey was different
163+
expect(innerHook.before).toHaveBeenCalledTimes(1);
159164
});
160165

161166
it.each([
@@ -180,7 +185,6 @@ describe('DebounceHook', () => {
180185

181186
// this hook caches error invocations
182187
const hook = new DebounceHook<string>(innerErrorHook, {
183-
beforeCacheKeySupplier: (flagKey: string) => flagKey,
184188
maxCacheItems: 100,
185189
debounceTime: 60_000,
186190
cacheErrors,
Lines changed: 66 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1-
import type {
2-
EvaluationContext,
3-
EvaluationDetails,
4-
FlagValue,
5-
Hook,
6-
HookContext,
7-
HookHints,
1+
import type { Logger } from '@openfeature/web-sdk';
2+
import {
3+
ErrorCode,
4+
OpenFeatureError,
5+
type EvaluationContext,
6+
type EvaluationDetails,
7+
type FlagValue,
8+
type Hook,
9+
type HookContext,
10+
type HookHints,
811
} from '@openfeature/web-sdk';
912
import { FixedSizeExpiringCache } from './utils/fixed-size-expiring-cache';
1013

14+
const DEFAULT_CACHE_KEY_SUPPLIER = (flagKey: string) => flagKey;
15+
type StageResult = true | CachedError;
16+
type HookStagesEntry = { before?: StageResult; after?: StageResult; error?: StageResult; finally?: StageResult };
17+
1118
/**
1219
* An error cached from a previous hook invocation.
1320
*/
14-
export class CachedError extends Error {
21+
export class CachedError extends OpenFeatureError {
1522
private _innerError: unknown;
1623

1724
constructor(innerError: unknown) {
@@ -27,60 +34,27 @@ export class CachedError extends Error {
2734
get innerError() {
2835
return this._innerError;
2936
}
37+
38+
get code() {
39+
if (this._innerError instanceof OpenFeatureError) {
40+
return this._innerError.code;
41+
}
42+
return ErrorCode.GENERAL;
43+
}
3044
}
3145

32-
export type Options<T extends FlagValue = FlagValue> = {
46+
export type Options = {
3347
/**
34-
* Function to generate the cache key for the before stage of the wrapped hook.
48+
* Function to generate the cache key for the wrapped hook.
3549
* If the cache key is found in the cache, the hook stage will not run.
36-
* If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
50+
* By default, the flag key is used as the cache key.
3751
*
3852
* @param flagKey the flag key
3953
* @param context the evaluation context
4054
* @returns cache key for this stage
55+
* @default (flagKey) => flagKey
4156
*/
42-
beforeCacheKeySupplier?: (flagKey: string, context: EvaluationContext) => string | null | undefined;
43-
/**
44-
* Function to generate the cache key for the after stage of the wrapped hook.
45-
* If the cache key is found in the cache, the hook stage will not run.
46-
* If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
47-
*
48-
* @param flagKey the flag key
49-
* @param context the evaluation context
50-
* @param details the evaluation details
51-
* @returns cache key for this stage
52-
*/
53-
afterCacheKeySupplier?: (
54-
flagKey: string,
55-
context: EvaluationContext,
56-
details: EvaluationDetails<T>,
57-
) => string | null | undefined;
58-
/**
59-
* Function to generate the cache key for the error stage of the wrapped hook.
60-
* If the cache key is found in the cache, the hook stage will not run.
61-
* If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
62-
*
63-
* @param flagKey the flag key
64-
* @param context the evaluation context
65-
* @param err the Error
66-
* @returns cache key for this stage
67-
*/
68-
errorCacheKeySupplier?: (flagKey: string, context: EvaluationContext, err: unknown) => string | null | undefined;
69-
/**
70-
* Function to generate the cache key for the error stage of the wrapped hook.
71-
* If the cache key is found in the cache, the hook stage will not run.
72-
* If not defined, the DebounceHook will no-op for this stage (inner hook will always run for this stage).
73-
*
74-
* @param flagKey the flag key
75-
* @param context the evaluation context
76-
* @param details the evaluation details
77-
* @returns cache key for this stage
78-
*/
79-
finallyCacheKeySupplier?: (
80-
flagKey: string,
81-
context: EvaluationContext,
82-
details: EvaluationDetails<T>,
83-
) => string | null | undefined;
57+
cacheKeySupplier?: (flagKey: string, context: EvaluationContext) => string | null | undefined;
8458
/**
8559
* Whether or not to debounce and cache the errors thrown by hook stages.
8660
* If false (default) stages that throw will not be debounced and their errors not cached.
@@ -95,24 +69,29 @@ export type Options<T extends FlagValue = FlagValue> = {
9569
* Max number of items to be kept in cache before the oldest entry falls out.
9670
*/
9771
maxCacheItems: number;
72+
/**
73+
* Optional logger.
74+
*/
75+
logger?: Logger;
9876
};
9977

10078
/**
10179
* A hook that wraps another hook and debounces its execution based on the provided options.
102-
* Each stage of the hook (before, after, error, finally) is debounced independently.
103-
* If a stage is called with a cache key that has been seen within the debounce time, the inner hook's stage will not run.
80+
* The cacheKeySupplier is used to generate a cache key for the hook, which is used to determine if the hook should be executed or skipped.
10481
* If no cache key supplier is provided for a stage, that stage will always run.
10582
*/
10683
export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
107-
private readonly cache: FixedSizeExpiringCache<true | CachedError>;
84+
private readonly cache: FixedSizeExpiringCache<HookStagesEntry>;
10885
private readonly cacheErrors: boolean;
86+
private readonly cacheKeySupplier: Options['cacheKeySupplier'];
10987

11088
public constructor(
11189
private readonly innerHook: Hook,
112-
private readonly options: Options<T>,
90+
private readonly options: Options,
11391
) {
11492
this.cacheErrors = options.cacheErrors ?? false;
115-
this.cache = new FixedSizeExpiringCache<true | CachedError>({
93+
this.cacheKeySupplier = options.cacheKeySupplier ?? DEFAULT_CACHE_KEY_SUPPLIER;
94+
this.cache = new FixedSizeExpiringCache<HookStagesEntry>({
11695
maxItems: options.maxCacheItems,
11796
ttlMs: options.debounceTime,
11897
});
@@ -121,31 +100,31 @@ export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
121100
before(hookContext: HookContext, hookHints?: HookHints) {
122101
this.maybeSkipAndCache(
123102
'before',
124-
() => this.options?.beforeCacheKeySupplier?.(hookContext.flagKey, hookContext.context),
103+
() => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context),
125104
() => this.innerHook?.before?.(hookContext, hookHints),
126105
);
127106
}
128107

129108
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<T>, hookHints?: HookHints) {
130109
this.maybeSkipAndCache(
131110
'after',
132-
() => this.options?.afterCacheKeySupplier?.(hookContext.flagKey, hookContext.context, evaluationDetails),
111+
() => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context),
133112
() => this.innerHook?.after?.(hookContext, evaluationDetails, hookHints),
134113
);
135114
}
136115

137116
error(hookContext: HookContext, err: unknown, hookHints?: HookHints) {
138117
this.maybeSkipAndCache(
139118
'error',
140-
() => this.options?.errorCacheKeySupplier?.(hookContext.flagKey, hookContext.context, err),
119+
() => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context),
141120
() => this.innerHook?.error?.(hookContext, err, hookHints),
142121
);
143122
}
144123

145124
finally(hookContext: HookContext, evaluationDetails: EvaluationDetails<T>, hookHints?: HookHints) {
146125
this.maybeSkipAndCache(
147126
'finally',
148-
() => this.options?.finallyCacheKeySupplier?.(hookContext.flagKey, hookContext.context, evaluationDetails),
127+
() => this.cacheKeySupplier?.(hookContext.flagKey, hookContext.context),
149128
() => this.innerHook?.finally?.(hookContext, evaluationDetails, hookHints),
150129
);
151130
}
@@ -156,34 +135,49 @@ export class DebounceHook<T extends FlagValue = FlagValue> implements Hook {
156135
hookCallback: () => void,
157136
) {
158137
// the cache key is a concatenation of the result of calling keyGenCallback and the stage
159-
const dynamicKey = keyGenCallback();
138+
let dynamicKey: string | null | undefined;
139+
140+
try {
141+
dynamicKey = keyGenCallback();
142+
} catch (e) {
143+
// if the keyGenCallback throws, we log and run the hook stage
144+
this.options.logger?.error(
145+
`DebounceHook: cacheKeySupplier threw an error, running inner hook stage "${stage}" without debouncing.`,
146+
e,
147+
);
148+
}
160149

161-
// if the keyGenCallback returns nothing, we don't do any caching
150+
// if the keyGenCallback returns nothing, we don't do any caching
162151
if (!dynamicKey) {
163152
hookCallback.call(this.innerHook);
153+
return;
164154
}
165-
155+
166156
const cacheKeySuffix = stage;
167157
const cacheKey = `${dynamicKey}::${cacheKeySuffix}`;
168158
const got = this.cache.get(cacheKey);
169159

170160
if (got) {
161+
const cachedStageResult = got[stage];
171162
// throw cached errors
172-
if (got instanceof CachedError) {
163+
if (cachedStageResult instanceof CachedError) {
173164
throw got;
174165
}
175-
return;
176-
}
177-
166+
if (cachedStageResult === true) {
167+
// already ran this stage for this key and is still in the debounce period
168+
return;
169+
}
170+
}
171+
178172
try {
179173
hookCallback.call(this.innerHook);
180-
this.cache.set(cacheKey, true);
174+
this.cache.set(cacheKey, { ...got, [stage]: true });
181175
} catch (error: unknown) {
182176
if (this.cacheErrors) {
183177
// cache error
184-
this.cache.set(cacheKey, new CachedError(error));
178+
this.cache.set(cacheKey, { ...got, [stage]: new CachedError(error) });
185179
}
186180
throw error;
187-
}
181+
}
188182
}
189183
}

libs/hooks/debounce/src/lib/utils/fixed-size-expiring-cache.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class FixedSizeExpiringCache<T> {
4545
* @param key key for the entry
4646
* @returns value or key or undefined
4747
*/
48-
get(key: string): T | void {
48+
get(key: string): T | undefined {
4949
if (key) {
5050
const entry = this.cacheMap.get(key);
5151
if (entry) {
@@ -56,6 +56,7 @@ export class FixedSizeExpiringCache<T> {
5656
}
5757
}
5858
}
59+
return undefined;
5960
}
6061

6162
/**

0 commit comments

Comments
 (0)