Skip to content

feat(store): Add tiered cache (disk + mem) - #650

Open
Anton-Kalpakchiev wants to merge 5 commits into
lru-cache-memfrom
lru-cache-tiered
Open

feat(store): Add tiered cache (disk + mem)#650
Anton-Kalpakchiev wants to merge 5 commits into
lru-cache-memfrom
lru-cache-tiered

Conversation

@Anton-Kalpakchiev

Copy link
Copy Markdown
Collaborator

Check #633 for more details.

Implement the handle returned by the tiered store's Create and Open
methods. When the handle is initially opened, it may operate on the
blob's corresponding `memory.File` in the mem cache. However, at some
point after the blob is marked complete, the blob will get evicted to
disk. `tiered.File` seemlessly handles this transition by opening the
blob in the disk store and continuing to operate on it in there,
ensuring store clients don't even notice the transition.

Alternatively, if the memory store had no capacity and the blob was
instead directly initialized on disk, `tiered.File` handles that case
too.
Next commit will implement the store itself. The flusher's job is to
asynchronously flush data from the memory store to disk. I tried
implementing it in a way where the muteces contend as little as possible
and we don't block store APIs on disk IO, but it became a little complex
so I'm open on any feedback on how to simplify the flusher.
Now that the flusher is done, implementing the store APIs was much
easier. I also changed the signature of the disk store's DeleteMetadata
API to match that of memory store's.

Also fixed a bug in the tiered.File where if we fallback to disk when
creating a file (since mem cache is full of inevictable blobs), we'd get
a nil pointer dereference and thus a panic.

TODO - tests
The API interface is the same as disk.Store and memory.Store's scoping
APIs. However, considering that tiered.Store maintains state in 2
different stores that is not fully synced, deciding if a blob is in or
out of scope is more complex, thus a general function like `isOutOfScope`
was not the cleanest way to implement scoping.

 - To make scoping the tiered store easier, I added the
`Scoped(scope store.BlobScope)` API to the disk.Store and memory.Store.
 - Documented tiered.Store
 - Added an error check in tiered store's constructor to protect against
 RebootIncompleteBlobs being turned on for disk store (doing so would
 leak blobs on crash)
 - Added a comment about a theoretically-possible bug that I expect
 won't happen in prod, just in case. We fail-open from that bug if
 it does happen, so it shouldn't be dangeroud to roll out to prod.
 - Will add tests in next commit.
 - Will create a proper tiered.Config in the next commits that allows
 disabling the memory cache
Comment thread lib/store/tiered/file.go

// File represents an open handle to a blob in [Store], similar to how an [os.File] is an open file descriptor to a file on disk.
// File, just like a Linux file with its page cache, continues to work seemlessly if the blob is initially in memory, but later flushed to disk and evicted from memory.
type File 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.

Can we abstract the backing file to an interface, such that this struct holds only a single reference to the backing file? That would also greatly simplify the implementation of its functions, since nil checks between the mem and disk file are no longer needed.

If not, the construction currently assumes a mem xor a disk file is provided, but this is not enforced. Do you think we should do so?

Comment thread lib/store/tiered/file.go
Comment on lines +42 to +44
if diskF != nil {
f.once.Do(func() { f.diskF = diskF })
}

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.

What is the reason for not just passing diskF when initialising the struct? Which concurrency are you accounting for?

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.

Flushing is usually asynchronous. The flow is:

  1. User creates a file in tiered store.
  2. Tiered store thus creates it in memory and returns a tiered.File to the user, where the tiered.File has memF set and diskF as nil.
  3. Once the file moves from mem to disk, tiered.File needs to do diskStore.Open() to get the diskF so the user that holds tiered.File can continue operating on the disk.File

Comment thread lib/store/tiered/file.go Outdated
// A blob might be evicted from the mem store (after being flushed to disk), while a tiered store user is holding a [File].
// To allow the user to continue operating on the [File] seemlessly, we open the blob in the disk store and
// operate on it instead. This is done once, on the first call to [File] after eviction from memory.
func (f *File) openDiskFileIfNeeed() error {

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.

Suggested change
func (f *File) openDiskFileIfNeeed() error {
func (f *File) openDiskFileIfNeeded() error {

Comment thread lib/store/tiered/file.go
func (f *File) openDiskFileIfNeeed() error {
f.once.Do(func() {
diskF, err := f.diskStore.Open(f.key)
if errors.Is(err, os.ErrNotExist) {

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.

You stated that ErrEvicted may also be used for deleted blobs.

However, that means this branch is reachable when trying to operate on a File for a deleted blob, causing an incorrect invariant violation.

Comment thread lib/store/tiered/file.go
}

func errBadSwitch(subErr error) error {
return fmt.Errorf("tiered.File could not switch over from pointing to memory.File to pointing to disk.File after blob was evicted from memory: %w", subErr)

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.

nit: Consider shortening this error message and the three Error(...)s above.

Suggested change
return fmt.Errorf("tiered.File could not switch over from pointing to memory.File to pointing to disk.File after blob was evicted from memory: %w", subErr)
return fmt.Errorf("mem to disk switch failed after eviction: %w", subErr)

// Store is a tiered (disk + memory), thread-safe, LRU cache for blobs and their [metadata.Metadata].
//
// - New blobs are initially created in the memory cache to speed up writes/reads and asynchronously flushed to disk
// once MarkComplete is called on them. If the memory cache is full with inevctable blobs, the new blob is created on disk.

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.

Suggested change
// once MarkComplete is called on them. If the memory cache is full with inevctable blobs, the new blob is created on disk.
// once MarkComplete is called on them. If the memory cache is full with inevictable blobs, the new blob is created on disk.

// - New blobs are initially created in the memory cache to speed up writes/reads and asynchronously flushed to disk
// once MarkComplete is called on them. If the memory cache is full with inevctable blobs, the new blob is created on disk.
//
// - Partially crash-resistant - all complete blobs that were fully flushed to disk are persisted. Use [disk.Store] if you need full crash resistence.

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.

Suggested change
// - Partially crash-resistant - all complete blobs that were fully flushed to disk are persisted. Use [disk.Store] if you need full crash resistence.
// - Partially crash-resistant - all complete blobs that were fully flushed to disk are persisted. Use [disk.Store] if you need full crash resistance.

// Has reports if the blob is 1) in the store and 2) in scope.
// If you don't care about the blob's scope and just want to check membership, do:
//
// _, ok := store.Has(key)

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.

Suggested change
// _, ok := store.Has(key)
// inStore, _ := store.Has(key)

Comment thread lib/store/tiered/store.go
return newFile(key, memF, nil, s.disk, s.log), nil
}

diskF, err := s.disk.Create(key, sizeBytes)

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.

Can we add observability for falling back to disk because of memory.ErrNoSpace?

Comment thread lib/store/tiered/store.go
return false, storelib.ErrOutOfScope
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("mem store get metadarta: %w", err)

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.

Suggested change
return false, fmt.Errorf("mem store get metadarta: %w", err)
return false, fmt.Errorf("mem store get metadata: %w", err)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants