Skip to content

Commit 3a43cb0

Browse files
committed
extended: fail-closed cap enforcement when no evictable atoms remain
- Evictor.SelectForEviction now returns (ids, freed, ok) so callers know whether the requested bytes could be covered by non-pinned atoms. - enforceCap uses per-atom effective size (chunk + atoms.json share + amortized vector cost) instead of raw store+index disk size, avoiding double-counting and making fail-closed checks reliable. - After eviction, if the evictor reports it cannot free enough, AddAtom returns an error and the atom is not written. - Add ExtendedMemory.Close() and atomVectorIndex.Wait() so tests can drain background compaction goroutines before TempDir cleanup.
1 parent 080dbc9 commit 3a43cb0

3 files changed

Lines changed: 50 additions & 21 deletions

File tree

internal/memory/extended/eviction.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ type sizedAtom struct {
1515
// Evictor selects atoms for eviction when the store approaches its size cap.
1616
type Evictor interface {
1717
// SelectForEviction returns atom IDs to remove to free at least needBytes.
18-
// sizedAtoms provides the actual disk size for each atom.
19-
SelectForEviction(sizedAtoms []sizedAtom, needBytes int64) []string
18+
// sizedAtoms provides the actual disk size for each atom. freed reports the
19+
// number of bytes that removing ids would release, and ok is true when the
20+
// requested needBytes can be covered by non-pinned atoms.
21+
SelectForEviction(sizedAtoms []sizedAtom, needBytes int64) (ids []string, freed int64, ok bool)
2022
}
2123

2224
// newEvictor builds the eviction strategy selected by cfg.EvictionPolicy.
@@ -36,9 +38,9 @@ type retentionDecayEvictor struct {
3638
}
3739

3840
// SelectForEviction returns IDs to remove. Pinned atoms are never selected.
39-
func (e *retentionDecayEvictor) SelectForEviction(sizedAtoms []sizedAtom, needBytes int64) []string {
41+
func (e *retentionDecayEvictor) SelectForEviction(sizedAtoms []sizedAtom, needBytes int64) (ids []string, freed int64, ok bool) {
4042
if needBytes <= 0 {
41-
return nil
43+
return nil, 0, true
4244
}
4345
scored := make([]struct {
4446
sized sizedAtom
@@ -58,8 +60,6 @@ func (e *retentionDecayEvictor) SelectForEviction(sizedAtoms []sizedAtom, needBy
5860
return scored[i].score < scored[j].score
5961
})
6062

61-
var freed int64
62-
var ids []string
6363
for _, s := range scored {
6464
if freed >= needBytes {
6565
break
@@ -72,8 +72,9 @@ func (e *retentionDecayEvictor) SelectForEviction(sizedAtoms []sizedAtom, needBy
7272
}
7373
if freed < needBytes {
7474
log.Printf("extended memory: eviction freed only %d of %d requested bytes", freed, needBytes)
75+
return ids, freed, false
7576
}
76-
return ids
77+
return ids, freed, true
7778
}
7879

7980
// buildSizedAtoms resolves the actual on-disk size for each atom. If the store

internal/memory/extended/extended_memory.go

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type ExtendedMemory struct {
3333

3434
// testCapBytes overrides cfg.MaxSizeMB in tests. 0 means use cfg.
3535
testCapBytes int64
36+
37+
closeOnce sync.Once
3638
}
3739

3840
// New creates an ExtendedMemory instance rooted at dir.
@@ -285,37 +287,36 @@ func (em *ExtendedMemory) enforceCap(ctx context.Context, newBytes int64) error
285287
log.Printf("extended memory: evicted %d expired quarantined atom(s)", removed)
286288
}
287289

288-
storeSize, err := em.store.Size()
289-
if err != nil {
290-
log.Printf("extended memory: store size failed: %v", err)
291-
storeSize = 0
292-
}
293290
quarantineSize, err := em.quarantine.Size()
294291
if err != nil {
295292
log.Printf("extended memory: quarantine size failed: %v", err)
296293
quarantineSize = 0
297294
}
298-
indexSize := em.index.Size()
299-
total := storeSize + quarantineSize + indexSize + newBytes
300295

301-
if total <= maxBytes {
302-
return nil
303-
}
304-
305-
need := total - maxBytes + 4096 // headroom
306296
atoms, err := em.store.List()
307297
if err != nil {
308298
log.Printf("extended memory: list atoms for eviction failed: %v", err)
309-
return nil
299+
return fmt.Errorf("extended memory: list atoms: %w", err)
310300
}
311301
sized := buildSizedAtoms(em.store, atoms)
312302
// Include amortized vector cost in each atom's footprint.
313303
for i := range sized {
314304
sized[i].size += vectorCost(len(atoms))
315305
}
316306

307+
var existingEffective int64
308+
for _, s := range sized {
309+
existingEffective += s.size
310+
}
311+
total := existingEffective + quarantineSize + newBytes
312+
313+
if total <= maxBytes {
314+
return nil
315+
}
316+
317+
need := total - maxBytes + 4096 // headroom
317318
before := len(atoms)
318-
ids := em.evictor.SelectForEviction(sized, need)
319+
ids, _, ok := em.evictor.SelectForEviction(sized, need)
319320
for _, id := range ids {
320321
_ = em.store.Remove(id)
321322
em.index.markDirty()
@@ -327,6 +328,10 @@ func (em *ExtendedMemory) enforceCap(ctx context.Context, newBytes int64) error
327328
em.index.Compact()
328329
}
329330
}
331+
332+
if !ok {
333+
return fmt.Errorf("extended memory: cannot free %s; all atoms are pinned or no evictable atoms exist", sizeLabel(need))
334+
}
330335
return nil
331336
}
332337

@@ -362,6 +367,18 @@ func (em *ExtendedMemory) Compact() {
362367
em.index.Compact()
363368
}
364369

370+
// Close waits for background operations to finish. It is safe to call
371+
// multiple times.
372+
func (em *ExtendedMemory) Close() error {
373+
if em == nil {
374+
return nil
375+
}
376+
em.closeOnce.Do(func() {
377+
em.index.Wait()
378+
})
379+
return nil
380+
}
381+
365382
// Size returns the current on-disk size of the Extended Memory store.
366383
func (em *ExtendedMemory) Size() int64 {
367384
if em == nil {

internal/memory/extended/index.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type vectorMeta struct {
3737
// recall. It rebuilds from disk when dirty and caches the result.
3838
type atomVectorIndex struct {
3939
mu sync.RWMutex
40+
wg sync.WaitGroup
4041
dir string
4142
store *vector.Store
4243
emb textEmbedder
@@ -203,12 +204,22 @@ func (vi *atomVectorIndex) Compact() {
203204
vi.dirty = true
204205
vi.dirtySeq++
205206
vi.mu.Unlock()
207+
vi.wg.Add(1)
206208
go func() {
209+
defer vi.wg.Done()
207210
vi.ensureFresh()
208211
log.Printf("extended memory: vector index compacted")
209212
}()
210213
}
211214

215+
// Wait blocks until in-flight background compaction goroutines finish.
216+
func (vi *atomVectorIndex) Wait() {
217+
if vi == nil {
218+
return
219+
}
220+
vi.wg.Wait()
221+
}
222+
212223
// tryLoadLocked attempts to load persisted state. Caller must hold vi.mu.
213224
func (vi *atomVectorIndex) tryLoadLocked() bool {
214225
fp := vi.emb.Fingerprint()

0 commit comments

Comments
 (0)