Skip to content

Commit f203584

Browse files
icecrasher321claude
andcommitted
improvement(execution): idle-TTL touch-on-read + LRU eviction for the large-value cache
Entry lifetimes were absolute-from-insert and eviction order was insertion order, so a value a live run kept referencing could expire or be pressure-evicted mid-use — while a genuinely idle entry survived the full window. Every authorized read now refreshes the expiry and moves the entry to the back of the eviction order: expiry and eviction only ever take entries nothing has read for a full TTL, and pressure eviction takes the least-recently-used recoverable entry. Strictly fewer mid-execution misses; TTL values, the admission budget, and the sole-copy (non-recoverable) eviction protection are unchanged. Touching stays behind the scope check so an unauthorized probe cannot extend a lifetime. Module TSDoc now records the standing constraint: the warm pass runs once per execution start, so the TTL must outlive the warm-to-first-reference gap — do not shorten it until warming is per-block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c6a7acf commit f203584

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

apps/sim/lib/execution/payloads/cache.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,26 @@ import {
66
cacheLargeValue,
77
clearLargeValueCacheForTests,
88
getLargeValueCacheStats,
9+
materializeLargeValueRefSync,
910
} from '@/lib/execution/payloads/cache'
11+
import {
12+
LARGE_VALUE_REF_VERSION,
13+
type LargeValueRef,
14+
} from '@/lib/execution/payloads/large-value-ref'
15+
16+
const MB = 1024 * 1024
17+
const SCOPE = { executionId: 'exec-1' }
18+
19+
function makeRef(id: string, size: number): LargeValueRef {
20+
return {
21+
__simLargeValueRef: true,
22+
version: LARGE_VALUE_REF_VERSION,
23+
id,
24+
kind: 'object',
25+
size,
26+
executionId: 'exec-1',
27+
}
28+
}
1029

1130
describe('large value cache sweep', () => {
1231
beforeEach(() => {
@@ -49,3 +68,66 @@ describe('large value cache sweep', () => {
4968
expect(getLargeValueCacheStats()).toEqual({ entries: 1, trackedBytes: 16 })
5069
})
5170
})
71+
72+
describe('large value cache retention policy', () => {
73+
beforeEach(() => {
74+
vi.useFakeTimers()
75+
clearLargeValueCacheForTests()
76+
})
77+
78+
afterEach(() => {
79+
clearLargeValueCacheForTests()
80+
vi.useRealTimers()
81+
})
82+
83+
it('refreshes the idle TTL on every read so in-use values outlive the absolute window', () => {
84+
cacheLargeValue('lv_touchedvalue', { data: 'v' }, 32, SCOPE)
85+
86+
vi.advanceTimersByTime(10 * 60 * 1000)
87+
expect(materializeLargeValueRefSync(makeRef('lv_touchedvalue', 32), SCOPE)).toEqual({
88+
data: 'v',
89+
})
90+
91+
vi.advanceTimersByTime(10 * 60 * 1000)
92+
expect(materializeLargeValueRefSync(makeRef('lv_touchedvalue', 32), SCOPE)).toEqual({
93+
data: 'v',
94+
})
95+
96+
vi.advanceTimersByTime(16 * 60 * 1000)
97+
expect(materializeLargeValueRefSync(makeRef('lv_touchedvalue', 32), SCOPE)).toBeUndefined()
98+
})
99+
100+
it('pressure-evicts the least-recently-read recoverable entry, not the oldest-inserted', () => {
101+
cacheLargeValue('lv_aaaaaaaaaaaa', { name: 'a' }, 120 * MB, SCOPE, { recoverable: true })
102+
cacheLargeValue('lv_bbbbbbbbbbbb', { name: 'b' }, 120 * MB, SCOPE, { recoverable: true })
103+
104+
expect(materializeLargeValueRefSync(makeRef('lv_aaaaaaaaaaaa', 120 * MB), SCOPE)).toEqual({
105+
name: 'a',
106+
})
107+
108+
expect(
109+
cacheLargeValue('lv_cccccccccccc', { name: 'c' }, 60 * MB, SCOPE, { recoverable: true })
110+
).toBe(true)
111+
112+
expect(materializeLargeValueRefSync(makeRef('lv_aaaaaaaaaaaa', 120 * MB), SCOPE)).toEqual({
113+
name: 'a',
114+
})
115+
expect(
116+
materializeLargeValueRefSync(makeRef('lv_bbbbbbbbbbbb', 120 * MB), SCOPE)
117+
).toBeUndefined()
118+
expect(getLargeValueCacheStats()).toEqual({ entries: 2, trackedBytes: 180 * MB })
119+
})
120+
121+
it('never pressure-evicts a sole-copy entry; admission fails instead', () => {
122+
cacheLargeValue('lv_nnnnnnnnnnnn', { name: 'sole-copy' }, 200 * MB, SCOPE)
123+
124+
expect(
125+
cacheLargeValue('lv_rrrrrrrrrrrr', { name: 'r' }, 100 * MB, SCOPE, { recoverable: true })
126+
).toBe(false)
127+
128+
expect(materializeLargeValueRefSync(makeRef('lv_nnnnnnnnnnnn', 200 * MB), SCOPE)).toEqual({
129+
name: 'sole-copy',
130+
})
131+
expect(getLargeValueCacheStats()).toEqual({ entries: 1, trackedBytes: 200 * MB })
132+
})
133+
})

apps/sim/lib/execution/payloads/cache.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ import {
44
type LargeValueRef,
55
} from '@/lib/execution/payloads/large-value-ref'
66

7+
/**
8+
* In-memory retention for large execution values. Durable storage is the
9+
* source of truth — every recoverable entry also exists in object storage and
10+
* transparently re-fetches through the async materialize path on a miss — so
11+
* this layer is an accelerator plus one deliberate exception: an entry whose
12+
* durable persist failed (`recoverable: false`) is the value's ONLY copy, and
13+
* pressure eviction must never remove it (losing it fails the execution that
14+
* stored it; expiry is its only exit).
15+
*
16+
* Lifetimes are IDLE TTLs, not absolute: every successful read refreshes the
17+
* entry and moves it to the back of the eviction order, so a value a live run
18+
* keeps referencing cannot expire mid-use, and pressure eviction always takes
19+
* the least-recently-used recoverable entry. The TTL must comfortably outlive
20+
* the gap between an execution's warm pass (`warmLargeValueRefs`, which runs
21+
* ONCE at execution start over the resumed snapshot) and that value's first
22+
* sync reference during the run — shorten it only if the warm becomes
23+
* per-block.
24+
*/
725
const FALLBACK_TTL_MS = 15 * 60 * 1000
826
const MAX_IN_MEMORY_BYTES = 256 * 1024 * 1024
927
const SWEEP_INTERVAL_MS = 60 * 1000
@@ -169,6 +187,14 @@ export function materializeLargeValueRefSync(
169187
if (!cached || !scopeMatchesRef(ref, cached.scope, callerScope)) {
170188
return undefined
171189
}
190+
// Idle-TTL touch on every authorized read: refresh expiry and move the entry
191+
// to the back of the eviction order. A value a live run keeps referencing can
192+
// therefore never expire or be pressure-evicted mid-use — expiry and eviction
193+
// only ever take entries nothing has read for a full TTL. Touching must stay
194+
// behind the scope check so an unauthorized probe cannot extend a lifetime.
195+
cached.expiresAt = Date.now() + FALLBACK_TTL_MS
196+
inMemoryValues.delete(ref.id)
197+
inMemoryValues.set(ref.id, cached)
172198
return cached.value
173199
}
174200

0 commit comments

Comments
 (0)