Skip to content

Commit 2565109

Browse files
committed
fix(network): bound configuration cache and handle nested TLS sockets
1 parent 94c5382 commit 2565109

3 files changed

Lines changed: 132 additions & 79 deletions

File tree

apps/sim/lib/core/config/appconfig.test.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,48 @@ describe('fetchAppConfigProfile', () => {
133133
expect(b).toEqual({ x: 1 })
134134
expect(mockSend.mock.calls.map(([c]) => c.__type)).toEqual(['start', 'get'])
135135
})
136+
137+
it('honors the server poll interval and serves warm values during one shared refresh', async () => {
138+
vi.useFakeTimers()
139+
vi.setSystemTime(100_000)
140+
try {
141+
mockSend.mockImplementation((command: { __type: string }) =>
142+
Promise.resolve(
143+
command.__type === 'start'
144+
? { InitialConfigurationToken: 'token' }
145+
: {
146+
Configuration: encode({ revision: 'first' }),
147+
NextPollConfigurationToken: 'next',
148+
NextPollIntervalInSeconds: 60,
149+
}
150+
)
151+
)
152+
const ids = uniqueIds()
153+
const parse = (value: unknown) => value
154+
expect(await fetchAppConfigProfile(ids, parse)).toEqual({ revision: 'first' })
155+
vi.setSystemTime(130_001)
156+
await fetchAppConfigProfile(ids, parse)
157+
expect(mockSend).toHaveBeenCalledTimes(2)
158+
159+
let finish: (value: unknown) => void = () => {}
160+
mockSend.mockReturnValueOnce(
161+
new Promise((resolve) => {
162+
finish = resolve
163+
})
164+
)
165+
vi.setSystemTime(161_000)
166+
expect(
167+
await Promise.all([fetchAppConfigProfile(ids, parse), fetchAppConfigProfile(ids, parse)])
168+
).toEqual([{ revision: 'first' }, { revision: 'first' }])
169+
expect(mockSend).toHaveBeenCalledTimes(3)
170+
finish({ Configuration: encode({ revision: 'second' }), NextPollConfigurationToken: 'next' })
171+
await vi.waitFor(async () => {
172+
expect(await fetchAppConfigProfile(ids, parse)).toEqual({ revision: 'second' })
173+
})
174+
} finally {
175+
vi.useRealTimers()
176+
}
177+
})
136178
})
137179

138180
describe('fetchAppConfigSnapshot freshness', () => {
@@ -143,6 +185,28 @@ describe('fetchAppConfigSnapshot freshness', () => {
143185
})
144186
afterEach(() => vi.useRealTimers())
145187

188+
it('evicts old profiles and requires fresh evidence when they are requested again', async () => {
189+
mockSend.mockImplementation((command: { __type: string }) =>
190+
Promise.resolve(
191+
command.__type === 'start'
192+
? { InitialConfigurationToken: 'token' }
193+
: { Configuration: encode({ revision: 'valid' }), NextPollConfigurationToken: 'next' }
194+
)
195+
)
196+
const oldest = uniqueIds()
197+
await fetchAppConfigSnapshot(oldest, (value) => value)
198+
const results = await Promise.all(
199+
Array.from({ length: 65 }, () => fetchAppConfigSnapshot(uniqueIds(), (value) => value))
200+
)
201+
expect(results.every((result) => result.value !== null)).toBe(true)
202+
mockSend.mockRejectedValueOnce(new Error('unavailable'))
203+
expect(await fetchAppConfigSnapshot(oldest, (value) => value)).toEqual({
204+
value: null,
205+
validatedAt: null,
206+
})
207+
expect(mockSend.mock.calls.at(-1)?.[0].__type).toBe('start')
208+
})
209+
146210
it('does not turn an empty first response or a cold failure into a valid snapshot', async () => {
147211
mockSend.mockRejectedValueOnce(new Error('unavailable'))
148212
expect(await fetchAppConfigSnapshot(uniqueIds(), (value) => value)).toEqual({
@@ -180,16 +244,16 @@ describe('fetchAppConfigSnapshot freshness', () => {
180244
const first = await fetchAppConfigSnapshot(ids, parse)
181245
expect(first.validatedAt).toBe(100_000)
182246
payload = new TextEncoder().encode('invalid json')
183-
vi.setSystemTime(130_000)
247+
vi.setSystemTime(130_001)
184248
expect(await fetchAppConfigSnapshot(ids, parse)).toEqual(first)
185249
payload = new Uint8Array()
186-
vi.setSystemTime(160_000)
250+
vi.setSystemTime(160_002)
187251
expect(await fetchAppConfigSnapshot(ids, parse)).toEqual(first)
188252
payload = encode({ revision: 'replacement' })
189-
vi.setSystemTime(190_000)
253+
vi.setSystemTime(190_003)
190254
expect(await fetchAppConfigSnapshot(ids, parse)).toEqual({
191255
value: { revision: 'replacement' },
192-
validatedAt: 190_000,
256+
validatedAt: 190_003,
193257
})
194258
})
195259

@@ -210,12 +274,12 @@ describe('fetchAppConfigSnapshot freshness', () => {
210274
const parse = (value: unknown) => value
211275
await fetchAppConfigSnapshot(ids, parse)
212276
payload = new Uint8Array()
213-
vi.setSystemTime(130_000)
277+
vi.setSystemTime(130_001)
214278
const results = await Promise.all([
215279
fetchAppConfigSnapshot(ids, parse),
216280
fetchAppConfigSnapshot(ids, parse),
217281
])
218-
expect(results[0].validatedAt).toBe(130_000)
282+
expect(results[0].validatedAt).toBe(130_001)
219283
expect(results[1]).toEqual(results[0])
220284
expect(mockSend.mock.calls).toHaveLength(3)
221285
})

apps/sim/lib/core/config/appconfig.ts

Lines changed: 45 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@aws-sdk/client-appconfigdata'
77
import { createLogger } from '@sim/logger'
88
import { getErrorMessage } from '@sim/utils/errors'
9+
import { LRUCache } from 'lru-cache'
910
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
1011
import { env } from '@/lib/core/config/env'
1112

@@ -22,13 +23,8 @@ export interface AppConfigProfileIdentifiers {
2223
interface 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

4464
let 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
*/
7392
async 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
}

apps/sim/lib/core/network/gateway.server.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,31 @@ const CONNECT_TIMEOUT_MS = 10_000
1010
const MAX_TUNNEL_AGE_MS = 300_000
1111

1212
/** The proxy CA never changes trust for the upstream service. */
13-
async function secureGatewayTunnel(socket: Socket, hostname: string): Promise<Socket> {
13+
async function secureGatewayTunnel(
14+
socket: Socket,
15+
hostname: string,
16+
port: number
17+
): Promise<Socket> {
1418
return new Promise((resolve, reject) => {
1519
const tls = connectTls({
1620
socket,
1721
host: hostname,
22+
port,
1823
servername: isIP(hostname) ? undefined : hostname,
1924
checkServerIdentity: (_name, certificate) => checkServerIdentity(hostname, certificate),
2025
rejectUnauthorized: true,
2126
ALPNProtocols: ['http/1.1'],
2227
})
28+
/** The inner TLS stream has no TCP descriptor; QoS must be applied to the outer socket. */
29+
if ('setTypeOfService' in socket && typeof socket.setTypeOfService === 'function') {
30+
const setTypeOfService = socket.setTypeOfService.bind(socket)
31+
Object.defineProperty(tls, 'setTypeOfService', {
32+
value(tos: number) {
33+
setTypeOfService(tos)
34+
return tls
35+
},
36+
})
37+
}
2338
const timer = setTimeout(
2439
() => tls.destroy(new OutboundRoutingError('GATEWAY_UNAVAILABLE')),
2540
CONNECT_TIMEOUT_MS
@@ -102,7 +117,7 @@ export function createGatewayDispatcher(
102117
}
103118
const socket = await openGatewayTunnel(gateway, address, port)
104119
if (connection.protocol !== 'https:') return socket
105-
return secureGatewayTunnel(socket, hostname)
120+
return secureGatewayTunnel(socket, hostname, port)
106121
})().then(
107122
(socket) => callback(null, socket),
108123
() => callback(new OutboundRoutingError('GATEWAY_UNAVAILABLE'), null)

0 commit comments

Comments
 (0)