66} from '@aws-sdk/client-appconfigdata'
77import { createLogger } from '@sim/logger'
88import { getErrorMessage } from '@sim/utils/errors'
9+ import { LRUCache } from 'lru-cache'
910import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
1011import { env } from '@/lib/core/config/env'
1112
@@ -22,13 +23,8 @@ export interface AppConfigProfileIdentifiers {
2223interface CacheEntry < T > {
2324 /** Last successfully parsed value, or `null` if the config is empty/unseeded. */
2425 value : T | null
25- /** True once any poll has completed (success, empty payload, or error). */
26- loaded : boolean
2726 /** Token for the next `GetLatestConfiguration` poll, rotated on each call. */
2827 nextToken : string | undefined
29- expiresAt : number
30- /** In-flight poll, shared so concurrent callers don't each hit AppConfig. */
31- inflight : Promise < T | null > | null
3228 validatedAt : number | null
3329 remoteMatchesValue : boolean
3430 strict : boolean
@@ -39,7 +35,31 @@ export interface AppConfigSnapshot<T> {
3935 readonly validatedAt : number | null
4036}
4137
42- const cache = new Map < string , CacheEntry < unknown > > ( )
38+ interface PollContext {
39+ ids : AppConfigProfileIdentifiers
40+ parse : ( json : unknown ) => unknown
41+ strict : boolean
42+ }
43+
44+ const cache = new LRUCache < string , CacheEntry < unknown > , PollContext > ( {
45+ max : 64 ,
46+ ttl : DEFAULT_TTL_MS ,
47+ ttlResolution : 0 ,
48+ ignoreFetchAbort : true ,
49+ /** Poll intervals and snapshot freshness share the same clock. */
50+ perf : { now : ( ) => Date . now ( ) } ,
51+ fetchMethod : async ( _key , stale , { context, options } ) => {
52+ const entry = stale ?? {
53+ value : null ,
54+ nextToken : undefined ,
55+ validatedAt : null ,
56+ remoteMatchesValue : false ,
57+ strict : context . strict ,
58+ }
59+ options . ttl = await poll ( context . ids , context . parse , entry )
60+ return entry
61+ } ,
62+ } )
4363
4464let client : AppConfigDataClient | null = null
4565
@@ -66,15 +86,14 @@ function cacheKey(ids: AppConfigProfileIdentifiers): string {
6686 * Run one AppConfig poll for `entry`: starts a session if no token is held, then
6787 * calls `GetLatestConfiguration`. An empty payload means "unchanged" (or an
6888 * unseeded profile) and the previous value is kept. Any error is logged and the
69- * last good value is retained. Marks the entry `loaded` on any outcome so callers
70- * never re-block on the cold path, and honors AppConfig's `NextPollInterval` so we
89+ * last good value is retained. Returns AppConfig's `NextPollInterval` so we
7190 * don't poll faster than the server allows (which would throttle).
7291 */
7392async function poll < T > (
7493 ids : AppConfigProfileIdentifiers ,
7594 parse : ( json : unknown ) => T ,
7695 entry : CacheEntry < T >
77- ) : Promise < T | null > {
96+ ) : Promise < number > {
7897 let response : GetLatestConfigurationCommandOutput
7998 try {
8099 const dataClient = getClient ( )
@@ -98,23 +117,16 @@ async function poll<T>(
98117 )
99118 entry . nextToken = response . NextPollConfigurationToken ?? entry . nextToken
100119 } catch ( error ) {
101- // Network/session failure: drop the token so the next attempt starts a fresh
102- // session (handles expired or invalid tokens). Mark loaded + back off so we
103- // serve the fallback and retry in the background rather than blocking every
104- // request during an outage.
120+ /** A failed or expired session retries after backoff without renewing snapshot freshness. */
105121 entry . nextToken = undefined
106- entry . expiresAt = Date . now ( ) + DEFAULT_TTL_MS
107- entry . loaded = true
108122 logger . error ( 'AppConfig fetch failed; serving last known value' , {
109123 profile : cacheKey ( ids ) ,
110124 error : getErrorMessage ( error ) ,
111125 } )
112- return entry . value
126+ return DEFAULT_TTL_MS
113127 }
114128
115- // Parse outside the network try: a decode/parse error must NOT discard the
116- // already-rotated session token — the round trip succeeded, so the next poll
117- // can reuse it instead of opening a new session. Keep the last good value.
129+ /** Decode failures retain the rotated session token and last validated value. */
118130 try {
119131 if ( response . Configuration && response . Configuration . length > 0 ) {
120132 entry . remoteMatchesValue = false
@@ -136,9 +148,7 @@ async function poll<T>(
136148 }
137149
138150 const intervalMs = ( response . NextPollIntervalInSeconds ?? 60 ) * 1000
139- entry . expiresAt = Date . now ( ) + Math . max ( DEFAULT_TTL_MS , intervalMs )
140- entry . loaded = true
141- return entry . value
151+ return Math . max ( DEFAULT_TTL_MS , intervalMs )
142152}
143153
144154/**
@@ -156,36 +166,11 @@ export async function fetchAppConfigProfile<T>(
156166 ids : AppConfigProfileIdentifiers ,
157167 parse : ( json : unknown ) => T
158168) : Promise < T | null > {
159- const key = cacheKey ( ids )
160- const entry = ( cache . get ( key ) as CacheEntry < T > | undefined ) ?? {
161- value : null ,
162- loaded : false ,
163- nextToken : undefined ,
164- expiresAt : 0 ,
165- inflight : null ,
166- validatedAt : null ,
167- remoteMatchesValue : false ,
168- strict : false ,
169- }
170- cache . set ( key , entry )
171-
172- // Cold: never polled — await a single shared poll so concurrent callers don't
173- // each hit AppConfig (and don't race the rotating session token).
174- if ( ! entry . loaded ) {
175- entry . inflight ??= poll ( ids , parse , entry ) . finally ( ( ) => {
176- entry . inflight = null
177- } )
178- return entry . inflight
179- }
180-
181- // Warm but stale: serve cached value, refresh once in the background.
182- if ( Date . now ( ) >= entry . expiresAt && ! entry . inflight ) {
183- entry . inflight = poll ( ids , parse , entry ) . finally ( ( ) => {
184- entry . inflight = null
185- } )
186- }
187-
188- return entry . value
169+ const entry = await cache . fetch ( cacheKey ( ids ) , {
170+ context : { ids, parse, strict : false } ,
171+ allowStale : true ,
172+ } )
173+ return ( entry ?. value ?? null ) as T | null
189174}
190175
191176/**
@@ -197,23 +182,12 @@ export async function fetchAppConfigSnapshot<T>(
197182 ids : AppConfigProfileIdentifiers ,
198183 parse : ( json : unknown ) => T
199184) : Promise < AppConfigSnapshot < T > > {
200- const key = `strict:${ cacheKey ( ids ) } `
201- const entry = ( cache . get ( key ) as CacheEntry < T > | undefined ) ?? {
202- value : null ,
203- loaded : false ,
204- nextToken : undefined ,
205- expiresAt : 0 ,
206- inflight : null ,
207- validatedAt : null ,
208- remoteMatchesValue : false ,
209- strict : true ,
210- }
211- cache . set ( key , entry )
212- if ( ! entry . loaded || Date . now ( ) >= entry . expiresAt ) {
213- entry . inflight ??= poll ( ids , parse , entry ) . finally ( ( ) => {
214- entry . inflight = null
215- } )
216- await entry . inflight
217- }
218- return Object . freeze ( { value : entry . value , validatedAt : entry . validatedAt } )
185+ const entry = await cache . fetch ( `strict:${ cacheKey ( ids ) } ` , {
186+ context : { ids, parse, strict : true } ,
187+ allowStale : false ,
188+ } )
189+ return Object . freeze ( {
190+ value : ( entry ?. value ?? null ) as T | null ,
191+ validatedAt : entry ?. validatedAt ?? null ,
192+ } )
219193}
0 commit comments