11import type { CachedFlagDefinition , Logger } from "./types" ;
22
3+ const DEFAULT_MIN_REFRESH_INTERVAL_MS = 1000 ;
4+
35type FlagsCacheRefreshResult = {
46 definitions : CachedFlagDefinition [ ] ;
57 flagStateVersion ?: number ;
68} ;
79
10+ type FlagsCacheScheduledRefresh = {
11+ cancel : ( ) => void ;
12+ } ;
13+
14+ type FlagsCacheOptions = {
15+ logger ?: Logger ;
16+ minRefreshIntervalMs ?: number ;
17+ scheduleTrailingRefresh ?: (
18+ delayMs : number ,
19+ callback : ( ) => void ,
20+ ) => FlagsCacheScheduledRefresh ;
21+ } ;
22+
823/**
924 * Stores the latest compiled flag definitions and coordinates refresh work.
1025 *
11- * A single instance is shared across all sync modes. We allow at most one
12- * in-flight fetch plus one pending follow-up refresh. Response
26+ * A single instance is shared across all sync modes. Response
1327 * `flagStateVersion`s decide whether fetched definitions replace the current
1428 * cache, so correctness does not depend on request ordering.
29+ *
30+ * Refreshes are throttled to at most one fetch start per interval. When the
31+ * runtime supports delayed work we schedule one trailing refresh; otherwise we
32+ * keep the pending refresh queued until the next caller touches the cache.
1533 */
1634export class FlagsCache {
1735 private value : CachedFlagDefinition [ ] | undefined ;
1836 private flagStateVersion : number | undefined ;
1937 private refreshPromise : Promise < void > | undefined ;
38+ private scheduledRefresh : FlagsCacheScheduledRefresh | undefined ;
39+ private scheduledRefreshPromise : Promise < void > | undefined ;
40+ private resolveScheduledRefreshPromise : ( ( ) => void ) | undefined ;
2041 private lastRefreshAt : number | undefined ;
42+ private lastRefreshStartedAt : number | undefined ;
2143 private destroyed = false ;
2244
2345 private pendingFullRefresh = false ;
2446 private pendingWaitForVersion : number | undefined ;
2547
48+ private readonly logger ?: Logger ;
49+ private readonly minRefreshIntervalMs : number ;
50+ private readonly scheduleTrailingRefresh ?: FlagsCacheOptions [ "scheduleTrailingRefresh" ] ;
51+
2652 constructor (
2753 private readonly fetchFlags : (
2854 waitForVersion ?: number ,
2955 ) => Promise < FlagsCacheRefreshResult | undefined > ,
30- private readonly logger ?: Logger ,
31- ) { }
56+ options : FlagsCacheOptions = { } ,
57+ ) {
58+ this . logger = options . logger ;
59+ this . minRefreshIntervalMs =
60+ options . minRefreshIntervalMs ?? DEFAULT_MIN_REFRESH_INTERVAL_MS ;
61+ this . scheduleTrailingRefresh = options . scheduleTrailingRefresh ;
62+ }
3263
3364 public get ( ) {
3465 return this . value ;
@@ -40,30 +71,34 @@ export class FlagsCache {
4071 }
4172
4273 this . queueRefresh ( waitForVersion ) ;
43- return await this . ensureRefreshRunning ( ) ;
74+ this . ensureRefreshStartedOrScheduled ( ) ;
75+ await this . waitForQueuedWork ( ) ;
76+ return this . value ;
4477 }
4578
4679 public async waitRefresh ( ) {
47- await this . refreshPromise ;
80+ await this . waitForQueuedWork ( ) ;
4881 }
4982
5083 public destroy ( ) {
5184 this . destroyed = true ;
5285 this . value = undefined ;
5386 this . flagStateVersion = undefined ;
5487 this . refreshPromise = undefined ;
88+ this . cancelScheduledRefresh ( ) ;
5589 this . pendingFullRefresh = false ;
5690 this . pendingWaitForVersion = undefined ;
5791 this . lastRefreshAt = undefined ;
92+ this . lastRefreshStartedAt = undefined ;
5893 }
5994
6095 public getLastRefreshAt ( ) {
6196 return this . lastRefreshAt ;
6297 }
6398
64- // Remember the newest refresh request we still need to run after the current
65- // fetch finishes. Versioned refreshes win over plain refreshes because they
66- // carry a concrete target we can wait for.
99+ // Remember the newest refresh request we still need to run. Versioned
100+ // refreshes win over plain refreshes because they carry a concrete target we
101+ // can wait for.
67102 private queueRefresh ( waitForVersion ?: number ) {
68103 if ( waitForVersion !== undefined ) {
69104 if (
@@ -88,6 +123,10 @@ export class FlagsCache {
88123 this . pendingFullRefresh = true ;
89124 }
90125
126+ private hasPendingRefresh ( ) {
127+ return this . pendingWaitForVersion !== undefined || this . pendingFullRefresh ;
128+ }
129+
91130 private takeNextRefreshRequest ( ) {
92131 if ( this . pendingWaitForVersion !== undefined ) {
93132 const waitForVersion = this . pendingWaitForVersion ;
@@ -129,39 +168,103 @@ export class FlagsCache {
129168 }
130169 }
131170
132- private async runQueuedRefreshes ( ) {
133- while ( ! this . destroyed ) {
134- const request = this . takeNextRefreshRequest ( ) ;
135- if ( ! request ) {
136- return ;
137- }
171+ private getNextRefreshDelayMs ( ) {
172+ if (
173+ this . lastRefreshStartedAt === undefined ||
174+ this . minRefreshIntervalMs <= 0
175+ ) {
176+ return 0 ;
177+ }
138178
139- const result = await this . fetchFlags ( request . waitForVersion ) ;
140- if ( this . destroyed || ! result ) {
141- continue ;
142- }
179+ return Math . max (
180+ 0 ,
181+ this . lastRefreshStartedAt + this . minRefreshIntervalMs - Date . now ( ) ,
182+ ) ;
183+ }
184+
185+ private settleScheduledRefresh ( ) {
186+ this . scheduledRefresh = undefined ;
187+ this . scheduledRefreshPromise = undefined ;
188+ this . resolveScheduledRefreshPromise ?.( ) ;
189+ this . resolveScheduledRefreshPromise = undefined ;
190+ }
143191
144- this . clearSatisfiedPendingVersion ( result . flagStateVersion ) ;
192+ private cancelScheduledRefresh ( ) {
193+ this . scheduledRefresh ?. cancel ( ) ;
194+ this . settleScheduledRefresh ( ) ;
195+ }
145196
146- if ( ! this . shouldApplyRefreshResult ( result . flagStateVersion ) ) {
147- continue ;
148- }
197+ private ensureScheduledRefresh ( delayMs : number ) {
198+ if ( ! this . scheduleTrailingRefresh || this . scheduledRefresh ) {
199+ return ;
200+ }
201+
202+ this . scheduledRefreshPromise = new Promise < void > ( ( resolve ) => {
203+ this . resolveScheduledRefreshPromise = resolve ;
204+ } ) ;
149205
150- this . value = result . definitions ;
151- this . flagStateVersion = result . flagStateVersion ;
152- this . lastRefreshAt = Date . now ( ) ;
153- this . logger ?. info ( "refreshed flag definitions" ) ;
206+ this . scheduledRefresh = this . scheduleTrailingRefresh ( delayMs , ( ) => {
207+ this . settleScheduledRefresh ( ) ;
208+ this . ensureRefreshStartedOrScheduled ( ) ;
209+ } ) ;
210+ }
211+
212+ private ensureRefreshStartedOrScheduled ( ) {
213+ if ( this . destroyed || this . refreshPromise || ! this . hasPendingRefresh ( ) ) {
214+ return ;
154215 }
216+
217+ const delayMs = this . getNextRefreshDelayMs ( ) ;
218+ if ( delayMs > 0 ) {
219+ this . ensureScheduledRefresh ( delayMs ) ;
220+ return ;
221+ }
222+
223+ this . cancelScheduledRefresh ( ) ;
224+ this . startNextRefresh ( ) ;
155225 }
156226
157- private async ensureRefreshRunning ( ) {
158- if ( ! this . refreshPromise ) {
159- this . refreshPromise = this . runQueuedRefreshes ( ) . finally ( ( ) => {
160- this . refreshPromise = undefined ;
161- } ) ;
227+ private startNextRefresh ( ) {
228+ const request = this . takeNextRefreshRequest ( ) ;
229+ if ( ! request ) {
230+ return ;
162231 }
163232
164- await this . refreshPromise ;
165- return this . value ;
233+ this . lastRefreshStartedAt = Date . now ( ) ;
234+ this . refreshPromise = this . fetchAndApplyRefresh (
235+ request . waitForVersion ,
236+ ) . finally ( ( ) => {
237+ this . refreshPromise = undefined ;
238+ this . ensureRefreshStartedOrScheduled ( ) ;
239+ } ) ;
240+ }
241+
242+ private async fetchAndApplyRefresh ( waitForVersion ?: number ) {
243+ const result = await this . fetchFlags ( waitForVersion ) ;
244+ if ( this . destroyed || ! result ) {
245+ return ;
246+ }
247+
248+ this . clearSatisfiedPendingVersion ( result . flagStateVersion ) ;
249+
250+ if ( ! this . shouldApplyRefreshResult ( result . flagStateVersion ) ) {
251+ return ;
252+ }
253+
254+ this . value = result . definitions ;
255+ this . flagStateVersion = result . flagStateVersion ;
256+ this . lastRefreshAt = Date . now ( ) ;
257+ this . logger ?. info ( "refreshed flag definitions" ) ;
258+ }
259+
260+ private async waitForQueuedWork ( ) {
261+ while ( ! this . destroyed ) {
262+ const workPromise = this . refreshPromise ?? this . scheduledRefreshPromise ;
263+ if ( ! workPromise ) {
264+ return ;
265+ }
266+
267+ await workPromise ;
268+ }
166269 }
167270}
0 commit comments