mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
This code represents an attempt at providing reliable tracking of whether any bitmaps still in use have access to a given block of mmapped data, allowing us to unmap the data when nothing is using it anymore. The basic approach is as follows: Each mmap is associated with a new object, called a "generation". A generation reflects a particular instance of a given file being mapped. When a bitmap is built from an mmapped data source, the bitmap is given a pointer to the generation as its Source. When bitmap operations combine containers from other bitmaps, they produce new bitmaps that are tagged with the combined set of sources. When we snapshot a file, or for some other reason wish to remap it, the corresponding bitmap has all its containers updated to use the new storage, and the bitmap's source is changed. However, previously-handed-out containers might still have references to the old storage. Those containers would be in bitmaps with the old source. After a bunch of study of trying to reference-count and track this, I realized: We don't actually need to do that, because we already have something suitable for determining whether anything can reach a given object. It's the garbage collector. So we set a finalizer on the generation object, which handles unmapping. There's additional sanity-checks here to confirm things like "we thought this generation should be expiring", and we track timestamps. We could also have things check whether a given bitmap's source was marked as obsolete "a while ago", but that isn't implemented yet. There's a debug version of this which tracks finalization, creation, and ending timestamps, and has a call to provide diagnostics for this. Identical generation IDs get separated out with random suffixes in this case -- there's sometimes a second or third instance of the same name due to a holder closing and reopening, but this basically only happens in testing. Note that generations are still used even when there's no mmapping, but unless debugging is turned on, they shouldn't propagate much -- we don't consider a generation to be the source of a bitmap unless the bitmap actually mapped things from that generation's mmapped storage, or debugging is on. There's a couple of other, possibly more subtle, changes and bug fixes that got caught by the testing on this: * If a fragment is partially opened and then opening some later part fails, we close the earlier parts before returning the error so we aren't leaving it partially open. * Several operations on segments which were requesting that a frozen copy of a bitmap be created are now actually *replacing* their bitmap with the frozen bitmap, rather than discarding it. * intersectRunRun, if it decides to create an array or bitmap, will yield that container instead of discarding it. And why all of this? Why, so we can actually implement the thing where when a fragment has a valid roaring bitmap, but the ops log is corrupt, we can truncate the corrupt part of the ops log and reopen it. Which I did. When the generationdebug build tag is in use, every generation has a finalizer all the time. When it's not, they only get finalizers when we expect them to be done -- say, when closing a fragment. This is because finalizers appear to be possibly-expensive. There's some logical cleanup to openStorage here, dividing part of its work into applyStorage and importStorage, which have a common case for handling "there's no data in this file".
407 lines
14 KiB
Go
407 lines
14 KiB
Go
// Copyright 2019 Pilosa Corp.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package pilosa
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"runtime"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/pilosa/pilosa/v2/logger"
|
|
"github.com/pilosa/pilosa/v2/roaring"
|
|
"github.com/pilosa/pilosa/v2/syswrap"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
// generation represents one "generation" of opening a data file.
|
|
// This is what determines when it's safe to unmap a data file, if it
|
|
// got mapped, and handles closing/reopening files if we need to
|
|
// manage file handle availability. It's an interface because this
|
|
// lets us write simpler code for specific cases, rather than handling
|
|
// the whole matrix of mapped/unmapped, staying open/being reopened,
|
|
// etcetera.
|
|
//
|
|
// You create a generation by calling newGeneration with a file
|
|
// path. If it succeeds in opening that path, it calls a provided
|
|
// setup function with the data from the generation, and a flag
|
|
// indicating whether the data is mmapped. If the setup function
|
|
// fails, newGeneration cleans things up and closes. Otherwise,
|
|
// it returns a generation.
|
|
//
|
|
// The generation itself uses runtime.SetFinalizer to clean up when
|
|
// the last reference to it goes away. You should store a pointer
|
|
// to the generation in any object which is reliant on the generation.
|
|
//
|
|
// When you anticipate a generation should be done (for instance,
|
|
// opening a new generation), the old one gets marked done, which
|
|
// stashes a timestamp in it. Later operations can check whether
|
|
// the timestamp is a while back, and if so, complain that something
|
|
// might be wrong.
|
|
//
|
|
// In some cases, we don't have enough open file limit to keep every
|
|
// file actually open. To address this, use the `Transaction` function,
|
|
// which ensures that the file is open, stores a reference to it in
|
|
// a provided `*io.Writer`, and then restores the previous value of
|
|
// the io.Writer when it's done. For instance, for a bitmap, this might
|
|
// be used with `&b.OpWriter`.
|
|
//
|
|
// newGeneration takes an optional previous generation; it calls
|
|
// that generation's Done function after running the provided setup,
|
|
// and bumps the generation count.
|
|
type generation interface {
|
|
// Transaction runs the given transaction with the generation's
|
|
// file open. If the **os.File parameter is
|
|
// non-nil, the generation's file will be open, and stored
|
|
// into that pointer, during the execution of func, after
|
|
// which the previous contents are restored. Otherwise
|
|
// the file may or may not be open during the operation.
|
|
Transaction(*io.Writer, func() error) error
|
|
// Done() should be called exactly once, to indicate that a
|
|
// generation is expected not to be in use for long -- for instance,
|
|
// when a new generation replaces it.
|
|
Done()
|
|
// Generation count.
|
|
Generation() int64
|
|
// ID indicates the source -- path and generation number -- that
|
|
// this generation represents.
|
|
ID() string
|
|
Dead() bool
|
|
}
|
|
|
|
type mmapGeneration struct {
|
|
mu sync.Mutex // mutex guards modifiers of generation, not of data
|
|
transMu sync.Mutex // guards transactions, specifically
|
|
path string
|
|
id string
|
|
file *os.File
|
|
data []byte
|
|
generation int64 // generation counter
|
|
dead bool // we think this generation is dead
|
|
deadSince time.Time // when this generation was marked dead
|
|
retries int // for cases where we're retrying
|
|
logger logger.Logger
|
|
}
|
|
|
|
func (m *mmapGeneration) Dead() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.dead
|
|
}
|
|
|
|
func (m *mmapGeneration) ID() string {
|
|
return m.id
|
|
}
|
|
|
|
func (m *mmapGeneration) Generation() int64 {
|
|
return m.generation
|
|
}
|
|
|
|
// Transaction runs an exclusive call, ensuring that the file is open if
|
|
// the *io.Writer parameter is present.
|
|
func (m *mmapGeneration) Transaction(fileP *io.Writer, fn func() error) (transactionErr error) {
|
|
m.transMu.Lock()
|
|
defer m.transMu.Unlock()
|
|
// HEY LOOK CAREFULLY AT THIS BIT:
|
|
// We can't just defer this unlock. We specifically want to be
|
|
// sure to unlock the regular mutex *before* this function is over,
|
|
// and if we error out trying to open the file, we want to do it
|
|
// even sooner. If we deferred this, the transaction would block
|
|
// *everything*, including things like sanity checks against the
|
|
// generation being Dead(), but also including the deferred
|
|
// re-close-the-file.
|
|
m.mu.Lock()
|
|
// if we've been asked for a file pointer, we need to ensure that
|
|
// our file is open, and that the file pointer to it is stored in
|
|
// the requested location, then revert that when we're done.
|
|
// if we aren't asked for a file pointer, nothing needs the file
|
|
// open.
|
|
if m.dead {
|
|
elapsed := time.Since(m.deadSince)
|
|
m.logger.Printf("WARNING: transaction against %s, which has been dead for %v\n", m.id, elapsed)
|
|
}
|
|
if fileP != nil {
|
|
if m.file == nil {
|
|
// we ignore the shouldClose response here; if this
|
|
// fragment was previously not being kept open, we're
|
|
// going to stick with that.
|
|
_, err := m.openFile()
|
|
if err != nil {
|
|
m.mu.Unlock()
|
|
return err
|
|
}
|
|
defer func() {
|
|
// report a close error if we have no other error to report
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
err := m.closeFile()
|
|
if transactionErr == nil {
|
|
transactionErr = err
|
|
}
|
|
}()
|
|
}
|
|
var fileStash io.Writer
|
|
fileStash, *fileP = *fileP, m.file
|
|
defer func() {
|
|
*fileP = fileStash
|
|
}()
|
|
}
|
|
// We are done locking the generation itself for now.
|
|
m.mu.Unlock()
|
|
return fn()
|
|
}
|
|
|
|
// Done marks the generation done, and closes its file, but may not unmap it.
|
|
// It's still conceptually possible to end up doing a Transaction against a
|
|
// done generation, but it's a red flag.
|
|
func (m *mmapGeneration) Done() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.dead {
|
|
oops := fmt.Sprintf("generation %s, marked done again at %v, previously marked dead at %v",
|
|
m.id, time.Now(), m.deadSince)
|
|
panic(oops)
|
|
}
|
|
m.dead = true
|
|
m.deadSince = time.Now()
|
|
err := m.closeFile()
|
|
if err != nil {
|
|
m.logger.Printf("error closing generation %s: %v", m.id, err)
|
|
}
|
|
// If we're not debugging, the finalizer won't have been enabled
|
|
// previously. Finalizers have non-zero cost, so having them not be
|
|
// created until they're needed seems rewarding?
|
|
if !generationDebug {
|
|
runtime.SetFinalizer(m, generationFinalizer)
|
|
}
|
|
endGeneration(m.id)
|
|
// note, Done() doesn't close the file; only the finalizer actually
|
|
// does the shutdown.
|
|
}
|
|
|
|
// Try to close the file if it's currently open.
|
|
func (m *mmapGeneration) closeFile() error {
|
|
var lastErr error
|
|
// report the most serious error encountered, but still close
|
|
// file even if something else failed.
|
|
if m.file != nil {
|
|
if err := m.file.Sync(); err != nil {
|
|
lastErr = fmt.Errorf("sync: %s", err)
|
|
}
|
|
if err := syscall.Flock(int(m.file.Fd()), syscall.LOCK_UN); err != nil {
|
|
lastErr = fmt.Errorf("unlock: %s", err)
|
|
}
|
|
if err := syswrap.CloseFile(m.file); err != nil {
|
|
lastErr = fmt.Errorf("close file: %s", err)
|
|
}
|
|
m.file = nil
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
// openFile ensures the file is open and locked, or fails. If it does
|
|
// open the file, it will also report the "you need to close this file
|
|
// when you're done" flag from syswrap.
|
|
func (m *mmapGeneration) openFile() (shouldClose bool, err error) {
|
|
if m.file != nil {
|
|
return false, nil
|
|
}
|
|
m.file, shouldClose, err = syswrap.OpenFile(m.path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// do we actually want this in every openFile? I don't know.
|
|
if err := syscall.Flock(int(m.file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
m.file.Close()
|
|
m.file = nil
|
|
return false, fmt.Errorf("flock: %s", err)
|
|
}
|
|
return shouldClose, nil
|
|
}
|
|
|
|
func generationFinalizer(m *mmapGeneration) {
|
|
m.mu.Lock()
|
|
if !m.dead {
|
|
m.logger.Printf("finalizing generation %s which isn't dead yet\n",
|
|
m.id)
|
|
}
|
|
m.mu.Unlock()
|
|
err := m.closeFile()
|
|
if err != nil {
|
|
m.logger.Printf("finalizing generation, closing file: %v\n", err)
|
|
}
|
|
if m.data != nil {
|
|
err := syswrap.Munmap(m.data)
|
|
if err != nil {
|
|
m.logger.Printf("finalizing generation, munmap: %v\n", err)
|
|
}
|
|
m.data = nil
|
|
}
|
|
finalizeGeneration(m.id)
|
|
}
|
|
|
|
// Cancel closes a generation out entirely. It cancels any finalizer,
|
|
// unmaps any data, ends generation tracking, and closes any files.
|
|
// It does each of these separately whether or not the others need to be done,
|
|
// or succeed. It's used to handle failures from newGeneration; it makes sure
|
|
// the generation isn't holding any resources and doesn't need to be cleaned
|
|
// up otherwise.
|
|
//
|
|
// Mostly a helper function because there's several cases where newGeneration
|
|
// might fail.
|
|
func (m *mmapGeneration) Cancel() {
|
|
if m.data != nil {
|
|
_ = syswrap.Munmap(m.data)
|
|
m.data = nil
|
|
}
|
|
err := m.closeFile()
|
|
if err != nil {
|
|
m.logger.Printf("error cancelling generation %s: %v", m.id, err)
|
|
}
|
|
runtime.SetFinalizer(m, nil)
|
|
m.dead = true
|
|
m.deadSince = time.Now()
|
|
cancelGeneration(m.id)
|
|
}
|
|
|
|
// newGeneration creates a new generation using the given file path. It
|
|
// then calls the provided setup function with the allocated storage, a
|
|
// file handle, the new generation, and a flag indicatting whether the storage
|
|
// is memory-mapped. If the setup function returns a non-nil error, the
|
|
// generation is cleaned up, and newGeneration fails. The setup function
|
|
// also returns a boolean indicating whether it used the mapping; if it
|
|
// didn't, newGeneration discards the mapping and returns a nil generation.
|
|
//
|
|
// If generationDebug is enabled, we track the generation even if no mapping
|
|
// is actually in use, so we can verify that the tracking is working.
|
|
//
|
|
// On failure, newGeneration returns nil values for generation and func,
|
|
// and an error. On success, the func returned is the close func to use
|
|
// when the generation is no longer needed by the caller.
|
|
func newGeneration(existing generation, path string, readData bool, setup func([]byte, *os.File, generation, bool) (bool, error), logger logger.Logger) (generation, error) {
|
|
m := mmapGeneration{path: path, logger: logger}
|
|
if existing != nil {
|
|
m.generation = existing.Generation() + 1
|
|
// we might keep a previous generation around just for its generation count.
|
|
if !existing.Dead() {
|
|
defer existing.Done()
|
|
}
|
|
}
|
|
shouldClose, err := m.openFile()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.id = fmt.Sprintf("%s:%d", m.path, m.generation)
|
|
// possibly assign new generation ID if this one's been used, which can
|
|
// happen with reopens, especially during testing.
|
|
m.id = registerGeneration(m.id)
|
|
// if debugging, we always want the finalizer on so we notice if a
|
|
// generation is finalized without being closed. for non-debugging
|
|
// use, we only need it when the generation is closed.
|
|
if generationDebug {
|
|
runtime.SetFinalizer(&m, generationFinalizer)
|
|
}
|
|
// Mmap the underlying file so it can be zero copied.
|
|
var mapped bool
|
|
var data []byte
|
|
fi, err := m.file.Stat()
|
|
if err == nil && fi.Size() > 0 {
|
|
data, err = syswrap.Mmap(int(m.file.Fd()), 0, int(fi.Size()), syscall.PROT_READ, syscall.MAP_SHARED)
|
|
if err == syswrap.ErrMaxMapCountReached {
|
|
// I have no idea where/how to display this message.
|
|
m.logger.Printf("maximum number of maps reached, reading file '%s' instead", m.path)
|
|
} else if err != nil {
|
|
m.Cancel()
|
|
return nil, errors.Wrap(err, "mmap failed")
|
|
} else {
|
|
mapped = true
|
|
}
|
|
}
|
|
if data == nil && readData {
|
|
data, err = ioutil.ReadAll(m.file)
|
|
if err != nil {
|
|
m.Cancel()
|
|
return nil, errors.Wrap(err, "failure file readall")
|
|
}
|
|
}
|
|
// if we got here, data's the expected data, so let's try to use it
|
|
mappedAny, err := setup(data, m.file, &m, mapped)
|
|
|
|
// if the setup failed, we unmap data if we previously mapped it,
|
|
// and exit. Note that having no data, or having only trivial
|
|
// data (like a zero-container Roaring file) isn't "failed".
|
|
if err != nil {
|
|
m.Cancel()
|
|
// Unless, that is, we think the file probably ought to
|
|
// be truncated: For instance, if a bitmap has a corrupted
|
|
// ops log, we could truncate that part of it and retry.
|
|
if err, ok := err.(roaring.FileShouldBeTruncatedError); ok && m.retries < 1 {
|
|
m.logger.Printf("file %s read partially, but should-be-truncated at %d bytes\n", m.path, err.SuggestedLength())
|
|
// close this generation, then try again. once.
|
|
m.retries++
|
|
err := os.Truncate(m.path, err.SuggestedLength())
|
|
if err != nil {
|
|
m.logger.Printf("truncating file failed [but retrying anyway]: %v\n", err)
|
|
}
|
|
return newGeneration(&m, path, readData, setup, logger)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if mapped {
|
|
// when generationDebug is on, we want to track this even
|
|
// if it's not being used.
|
|
if generationDebug || mappedAny {
|
|
// Advise the kernel that the mmap is accessed randomly.
|
|
// We don't care much about errors with this.
|
|
_ = madvise(data, syscall.MADV_RANDOM)
|
|
// store the data, so we can unmap it when this generation
|
|
// gets finalized.
|
|
m.data = data
|
|
} else {
|
|
// unmap the data and don't stash the pointer in this
|
|
// generation. It's not being used. This generation
|
|
// doesn't need to exist, yay.
|
|
unmapErr := syswrap.Munmap(data)
|
|
if unmapErr != nil {
|
|
m.logger.Printf("error unmapping (probably harmless): %v", unmapErr)
|
|
}
|
|
}
|
|
}
|
|
// shouldClose comes from underlying syswrap.OpenFile, which checks
|
|
// a count of open files to hint at us when we need to start closing
|
|
// files to preserve open file descriptor limit.
|
|
if shouldClose {
|
|
err := m.closeFile()
|
|
if err != nil {
|
|
m.logger.Printf("closing file to preserve open files failed: %v\n", err)
|
|
}
|
|
}
|
|
// It's possible that the generation has no actual data to track,
|
|
// because nothing's mapped, in which case there won't be any bitmap
|
|
// sources following this, just the fragment source. (Bitmaps won't
|
|
// be attached to the source unless they're actually mapped to it,
|
|
// or generationDebug is true). That's okay. We pay a tiny cost
|
|
// for the finalizer, but we also get higher confidence that it really
|
|
// does get cleaned up.
|
|
return &m, nil
|
|
}
|