Skip to content

feat(store): Add memory LRU cache - #647

Open
Anton-Kalpakchiev wants to merge 9 commits into
lru-cachefrom
lru-cache-mem
Open

feat(store): Add memory LRU cache#647
Anton-Kalpakchiev wants to merge 9 commits into
lru-cachefrom
lru-cache-mem

Conversation

@Anton-Kalpakchiev

Copy link
Copy Markdown
Collaborator

Read #633 first for more context.

@Anton-Kalpakchiev
Anton-Kalpakchiev force-pushed the lru-cache-mem branch 2 times, most recently from e247a4a to 1186526 Compare July 27, 2026 13:15
@Anton-Kalpakchiev
Anton-Kalpakchiev force-pushed the lru-cache-mem branch 2 times, most recently from c5fe6f7 to aa1df50 Compare July 28, 2026 14:55
Comment thread lib/store/memory/scoped_store.go
Comment thread lib/store/memory/store.go
// while [Store]'s APIs omit that arg (cleaner interface) and instead expose other APIs to scope the whole store.
//
// Check [Store]'s comments for details on functionality.
type store struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also briefly discussed this with @iawakimjan offline, but the implementations for the disk and memory stores are quite similar. I am wondering, did you consider abstracting the storage layer and using a single cache policy orchestrator?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's worth it as IMO there are subtle differences in the 2 storages that might not be easily abstractable. I am waiting for @iawakimjan 's review to see if he sees a way to abstract these 2 stores.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya I also feel that if there's a possibility of abstraction, we should implement it.
Atleast, I feel the methods are same so they should be an implementation of an interface.

Comment thread lib/store/memory/handle.go Outdated
Comment thread lib/store/memory/handle.go Outdated
Comment thread lib/store/memory/store_test.go Outdated
Comment thread lib/store/memory/store_test.go Outdated
Comment thread lib/store/memory/store_test.go Outdated
Comment thread lib/store/memory/store_test.go Outdated
Comment thread lib/store/memory/store.go
Comment thread lib/store/memory/file.go
The interface is mostly the same as the disk.Store with 1 big difference:
entries that get evicted in the disk store but are still opened by users
do not actually get deleted from disk, getting removed only once all
clients close their handles. This is intentional and is handled by
Linux. It leads to a bit of overreservation disk-wise, which is why
we will leave a buffer when picking the store's capacity.

However, the same behavior is much more dangerous when used in a memory
store due to the danger of OOMs. Therefore, we have 2 options:
1. don't evict entries that are evictable but held by clients
2. evict the entries AND make sure the memory is actually freed.

We pick option 2, as a crucial part of the mem store's design is to
prioritize writes to the store over reads from it, as writes are origin's
bottleneck.
 - The APIs are very similar to the disk.Store's so I decided to
 implement them in a single commit.
 - Decided to change Stat's API to just return the size of a blob and
 not [os.FileInfo], as returning ps.FileInfo requires more bookkeeping
 (e.g. tracking the last access time of a blob) which would add
 redundant complexity, as clients never ever actually use Stat for
 anything else than 1) getting the blob's size and 2) getting the LAT
 of the blob, HOWEVER, they only need it for eviction, which after this
 refactor won't be necessary outside the store.
 - tests will be added in the next commit
 - Follows the same pattern as disk.Store()
 - I extracted the common logic into lib/store/scope.go
I copypasted the tests for the disk store and adapted them for the
memory store. I also added 2 extra tests that are important for the mem
store. The first one is that after eviction/deletion of a blob, the
handle returned to the client returns ErrEvicted.

The second test was about a bug, where i was doing
`data := make([]byte, size)` instead of
`data := make([]byte, 0, size)` in Create. This meant that
when clients overreport a blob's size (this happens) and they later
read the blob, they would read trailing empty space at the end. Fixed
the bug and added a test for under- and overreporting blob size. Added
the test to the disk store as well.

Finally, I added a log and a fail-open `if` case when releasing space
(when bookkeeping the store's size) to ensure that we catch any bugs
where adding and subsequently removing a blob changes store's size (used
to be a bug in the past).
Comment thread lib/store/memory/handle.go Outdated
Comment on lines +12 to +176
var _ storelib.FileReadWriter = &handle{}

// handle is the struct returned to clients when they create/open a blob.
// As soon as the blob's data is evicted, the handle no longer has a reference to it,
// ensuring GC can clean it.
type handle struct {
data *atomic.Pointer[[]byte]
sliceMu *sync.Mutex // A potential optimization if contention is too high: currently all writes are not parallelized. However, writes that only mutate the array but not the slice are parallelizable with each other. Thus, we could transition to a RWMutex to enable that.
off int64
}

func newHandle(data *atomic.Pointer[[]byte], sliceMu *sync.Mutex) *handle {
return &handle{
data: data,
sliceMu: sliceMu,
off: 0,
}
}

func (h *handle) getData() (data []byte, evicted bool) {
buf := h.data.Load()
if buf == nil {
return nil, true
}
return *buf, false
}

func (h *handle) Read(p []byte) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
buf, evicted := h.getData()
if evicted {
return 0, ErrEvicted
}

if h.off >= int64(len(buf)) {
return 0, io.EOF
}
n = copy(p, buf[h.off:])
h.off += int64(n)
return n, nil
}

// ReadAt implements [io.ReaderAt]. Thread-safe.
func (h *handle) ReadAt(p []byte, off int64) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
if off < 0 {
return 0, errors.New("negative offset")
}

buf, evicted := h.getData()
if evicted {
return 0, ErrEvicted
}
if off >= int64(len(buf)) {
return 0, io.EOF
}

n = copy(p, buf[off:])
if n < len(p) {
return n, io.EOF
}
return n, nil
}

// Seek implements [io.Seeker]. Not thread-safe.
func (h *handle) Seek(off int64, whence int) (int64, error) {
buf, evicted := h.getData()
if evicted {
return 0, ErrEvicted
}

var newOff int64
switch whence {
case io.SeekStart:
newOff = off
case io.SeekCurrent:
newOff = h.off + off
case io.SeekEnd:
newOff = int64(len(buf)) + off
default:
return 0, errors.New("invalid whence")
}

if newOff < 0 || newOff > int64(len(buf)) {
return 0, errors.New("invalid seek location")
}
h.off = newOff
return newOff, nil
}

// Stat returns the blob's actual size, even if it differs from the size reported during Create.
func (h *handle) Size() int64 {
buf, evicted := h.getData()
if evicted {
// TODO - consider whether this is ok or whether we need to store the user-provided blob size in [*handle].
return 0
}
return int64(len(buf))
}

// WriteAt implements [io.WriterAt]. It is fully thread-safe.
func (h *handle) WriteAt(p []byte, off int64) (n int, err error) {
if off < 0 {
return 0, errors.New("negative offset")
}

h.sliceMu.Lock()
defer h.sliceMu.Unlock()

buf, evicted := h.getData()
if evicted {
return 0, ErrEvicted
}

end := int(off) + len(p)
buf, resized := resizeSliceIfNecessary(buf, end)
n = copy(buf[off:], p)
if resized {
h.data.Store(&buf)
}
return n, nil
}

func resizeSliceIfNecessary(buf []byte, end int) ([]byte, bool) {
resized := false
if len(buf) < end {
if cap(buf) < end {
newBuf := make([]byte, end)
copy(newBuf, buf)
buf = newBuf
}
buf = buf[:end]
resized = true
}
return buf, resized
}

// Write implements io.Writer.
func (h *handle) Write(p []byte) (n int, err error) {
h.sliceMu.Lock() // We need to lock in case we update the pointer to `data`.
defer h.sliceMu.Unlock()

buf, evicted := h.getData()
if evicted {
return 0, ErrEvicted
}

end := int(h.off) + len(p)
buf, resized := resizeSliceIfNecessary(buf, end)

n = copy(buf[h.off:], p)
if resized {
h.data.Store(&buf)
}
h.off += int64(n)
return n, nil
}

func (h *handle) Cancel() error { return nil } // no-op
func (h *handle) Close() error { return nil } // no-op
func (h *handle) Commit() error { return nil } // no-op

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious: shouldn't we just reuse lib/store/base/BufferReadWriter since the implementation is almost similar?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used BufferReadWriter as inspiration, but it doesn't support the ErrEvicted use case, so I decided to write a replacement, which I don't think is avoidable during the migration period, where we have both CAStore and disk.Store + mem.Store in the codebase. WDYT?

Comment thread lib/store/memory/store.go
// while [Store]'s APIs omit that arg (cleaner interface) and instead expose other APIs to scope the whole store.
//
// Check [Store]'s comments for details on functionality.
type store struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya I also feel that if there's a possibility of abstraction, we should implement it.
Atleast, I feel the methods are same so they should be an implementation of an interface.

Comment thread lib/store/memory/handle.go Outdated
off int64
}

func newHandle(data *atomic.Pointer[[]byte], sliceMu *sync.Mutex) *handle {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to pass a mutex from the caller? This can cause unnecessary locking in case the caller accidentally uses the same locks for the handle and other work

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only caller is the memory.Store which needs to synchronize with the handle as to prevent a race on the blob's data. If the store misuses the mutex, that's just a bug in the store. The mutex is encapsulated within the memory package and therefore unreachable from clients of the package.

Check the comment on sliceMu in the blob struct for more info.

Comment thread lib/store/memory/store.go
}

type blob struct {
data atomic.Pointer[[]byte] // set to nil upon eviction/deletion.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to use atomic?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered doing data *[]byte instead of data *atomic.Pointer[[]byte] and having eviction be done with *data = nil instead of data.Store(nil), however, this means that now reads must call sliceMu.RLock, which protects again a race between buf := *h.data in read APIs and *data = nil when evicting the data and *data = newBuf (not an atomic operation, this actually mutates len, cap, and the slice's underlying array non-atomically).

Having said that, perhaps it would be simpler to just use a RWMutex, hmm... The original reason I went for an atomic is because I wanted writes to be parallelizable, but we pivoted away from that idea.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. let's try to be consistent. Either we move fully to optimistic locking or fully to mutexes.
In the current implementation, it is better to use RLock rather than using atomic

Comment thread lib/store/memory/store.go
Comment on lines +151 to +153
b.sliceMu.Lock()
b.data.Store(nil) // Ensure the byte slice is not referenced by clients outside the store holding [*handle], so GC can evict the memory.
b.sliceMu.Unlock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Locking inside a lock is a potential deadlock scenario, IMO. Is there a way we can avoid this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, at first I also considered doing CAS, but I decided it would be simpler to have sliceMu be responsible for synchronizing mutations to the data slice and not share ownership of that between sync primitives.

Also, right now there can't be a deadlock, as no operation acquires sliceMu first and then store.mu.

WDYT?

@sambhav-jain-16 sambhav-jain-16 Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, let's not consider CAS for this. I also don't see any cases, but I wonder if it might happen in the future if code is added to the store.

 - add Has method for disk.Store and mem.Store (needed by tiered.Store)
 - rename each store's `handle`` to `File` and return the File struct
 instead of the store.FileReadWriter interface in the Create and Open
 APIs of the stores (in accordance with Golang's principle of returning)
 a struct and accepting an interface.
 - Add method `Off` to memory.File that returns the current offset. (
  needed in tiered.Store)
 - move the disk handle implementation from lib/store/file.go to
 lib/store/disk/file.go
 - add tests for the new behavior
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants