Skip to content

Commit 13e6db8

Browse files
committed
fix(observability): backfill the shared Redis state in place, preserving identity
The previous commit normalized the global by building a replacement object and assigning it. That fixed the undefined-field problem and introduced a worse one: sharing a single object across module evaluations is the entire reason this state lives on globalThis, and replacing it leaves an earlier module instance holding the previous object. Two instances that disagree about `client` each open their own connection and start their own health check — a real change to connection lifecycle in a change that is supposed to be pure instrumentation. Backfills the existing object in place instead, so every holder keeps pointing at the same state while an older shape still gets its missing fields. The test now asserts object identity as well as the filled values, and fails if the function returns a copy.
1 parent ed6ea45 commit 13e6db8

2 files changed

Lines changed: 38 additions & 31 deletions

File tree

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ vi.mock('ioredis', () => ({
2727
import {
2828
acquireLock,
2929
closeRedisConnection,
30-
createRedisState,
3130
describeRedisConnection,
3231
extendLock,
3332
getRedisClient,
33+
normalizeRedisState,
3434
onRedisReconnect,
3535
resetForTesting,
3636
} from '@/lib/core/config/redis'
@@ -252,7 +252,11 @@ describe('redis config', () => {
252252
reconnectListeners: [],
253253
}
254254

255-
const normalized = createRedisState(stale)
255+
const normalized = normalizeRedisState(stale)
256+
257+
// Identity must survive: every module instance has to keep pointing at the
258+
// same object, or two of them each open their own client.
259+
expect(normalized).toBe(stale)
256260

257261
// Undefined slips past the `=== null` guards and yields NaN downstream.
258262
expect(normalized.clientCreatedAt).toBeNull()
@@ -270,14 +274,14 @@ describe('redis config', () => {
270274
it('carries a running health check across normalization so it is not started twice', () => {
271275
const interval = setInterval(() => {}, 1_000)
272276
try {
273-
expect(createRedisState({ pingInterval: interval }).pingInterval).toBe(interval)
277+
expect(normalizeRedisState({ pingInterval: interval }).pingInterval).toBe(interval)
274278
} finally {
275279
clearInterval(interval)
276280
}
277281
})
278282

279283
it('preserves accumulated counters when the shape is already current', () => {
280-
const normalized = createRedisState({ connects: 4, reconnects: 2, errors: 7 })
284+
const normalized = normalizeRedisState({ connects: 4, reconnects: 2, errors: 7 })
281285

282286
expect(normalized).toMatchObject({ connects: 4, reconnects: 2, errors: 7 })
283287
})

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

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,37 +72,40 @@ interface RedisState {
7272
const g = globalThis as typeof globalThis & { _redisState?: Partial<RedisState> }
7373

7474
/**
75-
* Normalized rather than initialized-once, because the global outlives a module
76-
* evaluation. A state object created before any of these fields existed keeps
77-
* them `undefined`, which slips past the `=== null` guards below and turns every
78-
* age into `now - undefined` and every counter into `undefined++` — NaN in both
79-
* cases, serialized as `null`, in the one payload whose entire purpose is being
80-
* trustworthy. Rebuilding from whatever is there upgrades an existing global
81-
* instead of trusting its shape, and carries `pingInterval` across so the health
82-
* check is never started twice.
75+
Backfilled in place, because the global outlives a module evaluation in two
76+
* directions at once.
8377
*
84-
* Exported so the normalization itself is testable: a module-evaluation-time
85-
* side effect is not reachable from a test that has already imported it.
78+
* A state object created before any of these fields existed keeps them
79+
* `undefined`, which slips past the `=== null` guards below and turns every age
80+
* into `now - undefined` and every counter into `undefined++` — NaN in both
81+
* cases, serialized as `null`, in the one payload whose purpose is being
82+
* trustworthy. So the shape has to be upgraded rather than trusted.
83+
*
84+
* It must be upgraded *without replacing the object*. Sharing one object across
85+
* evaluations is the entire reason this lives on `globalThis`: assigning a fresh
86+
* one leaves an earlier module instance holding the previous object, and two
87+
* instances that disagree about `client` each open their own connection and
88+
* start their own health check. Mutating in place keeps every holder pointing at
89+
* the same state while still filling in what an older shape lacks.
8690
*/
87-
export function createRedisState(existing: Partial<RedisState> = {}): RedisState {
88-
return {
89-
client: existing.client ?? null,
90-
pingFailures: existing.pingFailures ?? 0,
91-
pingInterval: existing.pingInterval ?? null,
92-
pingInFlight: existing.pingInFlight ?? false,
93-
reconnectListeners: existing.reconnectListeners ?? [],
94-
clientCreatedAt: existing.clientCreatedAt ?? null,
95-
lastReadyAt: existing.lastReadyAt ?? null,
96-
lastPingOkAt: existing.lastPingOkAt ?? null,
97-
connects: existing.connects ?? 0,
98-
reconnects: existing.reconnects ?? 0,
99-
errors: existing.errors ?? 0,
100-
lastErrorMessage: existing.lastErrorMessage ?? null,
101-
}
91+
export function normalizeRedisState(existing: Partial<RedisState>): RedisState {
92+
existing.client ??= null
93+
existing.pingFailures ??= 0
94+
existing.pingInterval ??= null
95+
existing.pingInFlight ??= false
96+
existing.reconnectListeners ??= []
97+
existing.clientCreatedAt ??= null
98+
existing.lastReadyAt ??= null
99+
existing.lastPingOkAt ??= null
100+
existing.connects ??= 0
101+
existing.reconnects ??= 0
102+
existing.errors ??= 0
103+
existing.lastErrorMessage ??= null
104+
return existing as RedisState
102105
}
103106

104-
const state = createRedisState(g._redisState)
105-
g._redisState = state
107+
g._redisState ??= {}
108+
const state = normalizeRedisState(g._redisState)
106109

107110
/**
108111
* A command that never gets a reply fails identically whichever of three states

0 commit comments

Comments
 (0)