mirror of
https://source.quilibrium.com/quilibrium/ceremonyclient.git
synced 2024-12-24 23:55:18 +00:00
2069 lines
76 KiB
Go
2069 lines
76 KiB
Go
// Copyright 2018 The LevelDB-Go and Pebble Authors. All rights reserved. Use
|
|
// of this source code is governed by a BSD-style license that can be found in
|
|
// the LICENSE file.
|
|
|
|
package pebble
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/cockroachdb/pebble/internal/base"
|
|
"github.com/cockroachdb/pebble/internal/humanize"
|
|
"github.com/cockroachdb/pebble/internal/manifest"
|
|
)
|
|
|
|
// The minimum count for an intra-L0 compaction. This matches the RocksDB
|
|
// heuristic.
|
|
const minIntraL0Count = 4
|
|
|
|
type compactionEnv struct {
|
|
// diskAvailBytes holds a statistic on the number of bytes available on
|
|
// disk, as reported by the filesystem. It's used to be more restrictive in
|
|
// expanding compactions if available disk space is limited.
|
|
//
|
|
// The cached value (d.diskAvailBytes) is updated whenever a file is deleted
|
|
// and whenever a compaction or flush completes. Since file removal is the
|
|
// primary means of reclaiming space, there is a rough bound on the
|
|
// statistic's staleness when available bytes is growing. Compactions and
|
|
// flushes are longer, slower operations and provide a much looser bound
|
|
// when available bytes is decreasing.
|
|
diskAvailBytes uint64
|
|
earliestUnflushedSeqNum uint64
|
|
earliestSnapshotSeqNum uint64
|
|
inProgressCompactions []compactionInfo
|
|
readCompactionEnv readCompactionEnv
|
|
}
|
|
|
|
type compactionPicker interface {
|
|
getScores([]compactionInfo) [numLevels]float64
|
|
getBaseLevel() int
|
|
estimatedCompactionDebt(l0ExtraSize uint64) uint64
|
|
pickAuto(env compactionEnv) (pc *pickedCompaction)
|
|
pickElisionOnlyCompaction(env compactionEnv) (pc *pickedCompaction)
|
|
pickRewriteCompaction(env compactionEnv) (pc *pickedCompaction)
|
|
pickReadTriggeredCompaction(env compactionEnv) (pc *pickedCompaction)
|
|
forceBaseLevel1()
|
|
}
|
|
|
|
// readCompactionEnv is used to hold data required to perform read compactions
|
|
type readCompactionEnv struct {
|
|
rescheduleReadCompaction *bool
|
|
readCompactions *readCompactionQueue
|
|
flushing bool
|
|
}
|
|
|
|
// Information about in-progress compactions provided to the compaction picker.
|
|
// These are used to constrain the new compactions that will be picked.
|
|
type compactionInfo struct {
|
|
// versionEditApplied is true if this compaction's version edit has already
|
|
// been committed. The compaction may still be in-progress deleting newly
|
|
// obsolete files.
|
|
versionEditApplied bool
|
|
inputs []compactionLevel
|
|
outputLevel int
|
|
smallest InternalKey
|
|
largest InternalKey
|
|
}
|
|
|
|
func (info compactionInfo) String() string {
|
|
var buf bytes.Buffer
|
|
var largest int
|
|
for i, in := range info.inputs {
|
|
if i > 0 {
|
|
fmt.Fprintf(&buf, " -> ")
|
|
}
|
|
fmt.Fprintf(&buf, "L%d", in.level)
|
|
in.files.Each(func(m *fileMetadata) {
|
|
fmt.Fprintf(&buf, " %s", m.FileNum)
|
|
})
|
|
if largest < in.level {
|
|
largest = in.level
|
|
}
|
|
}
|
|
if largest != info.outputLevel || len(info.inputs) == 1 {
|
|
fmt.Fprintf(&buf, " -> L%d", info.outputLevel)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
type sortCompactionLevelsByPriority []candidateLevelInfo
|
|
|
|
func (s sortCompactionLevelsByPriority) Len() int {
|
|
return len(s)
|
|
}
|
|
|
|
// A level should be picked for compaction if the compensatedScoreRatio is >= the
|
|
// compactionScoreThreshold.
|
|
const compactionScoreThreshold = 1
|
|
|
|
// Less should return true if s[i] must be placed earlier than s[j] in the final
|
|
// sorted list. The candidateLevelInfo for the level placed earlier is more likely
|
|
// to be picked for a compaction.
|
|
func (s sortCompactionLevelsByPriority) Less(i, j int) bool {
|
|
iShouldCompact := s[i].compensatedScoreRatio >= compactionScoreThreshold
|
|
jShouldCompact := s[j].compensatedScoreRatio >= compactionScoreThreshold
|
|
// Ordering is defined as decreasing on (shouldCompact, uncompensatedScoreRatio)
|
|
// where shouldCompact is 1 for true and 0 for false.
|
|
if iShouldCompact && !jShouldCompact {
|
|
return true
|
|
}
|
|
if !iShouldCompact && jShouldCompact {
|
|
return false
|
|
}
|
|
|
|
if s[i].uncompensatedScoreRatio != s[j].uncompensatedScoreRatio {
|
|
return s[i].uncompensatedScoreRatio > s[j].uncompensatedScoreRatio
|
|
}
|
|
return s[i].level < s[j].level
|
|
}
|
|
|
|
func (s sortCompactionLevelsByPriority) Swap(i, j int) {
|
|
s[i], s[j] = s[j], s[i]
|
|
}
|
|
|
|
// sublevelInfo is used to tag a LevelSlice for an L0 sublevel with the
|
|
// sublevel.
|
|
type sublevelInfo struct {
|
|
manifest.LevelSlice
|
|
sublevel manifest.Level
|
|
}
|
|
|
|
func (cl sublevelInfo) Clone() sublevelInfo {
|
|
return sublevelInfo{
|
|
sublevel: cl.sublevel,
|
|
LevelSlice: cl.LevelSlice.Reslice(func(start, end *manifest.LevelIterator) {}),
|
|
}
|
|
}
|
|
func (cl sublevelInfo) String() string {
|
|
return fmt.Sprintf(`Sublevel %s; Levels %s`, cl.sublevel, cl.LevelSlice)
|
|
}
|
|
|
|
// generateSublevelInfo will generate the level slices for each of the sublevels
|
|
// from the level slice for all of L0.
|
|
func generateSublevelInfo(cmp base.Compare, levelFiles manifest.LevelSlice) []sublevelInfo {
|
|
sublevelMap := make(map[uint64][]*fileMetadata)
|
|
it := levelFiles.Iter()
|
|
for f := it.First(); f != nil; f = it.Next() {
|
|
sublevelMap[uint64(f.SubLevel)] = append(sublevelMap[uint64(f.SubLevel)], f)
|
|
}
|
|
|
|
var sublevels []int
|
|
for level := range sublevelMap {
|
|
sublevels = append(sublevels, int(level))
|
|
}
|
|
sort.Ints(sublevels)
|
|
|
|
var levelSlices []sublevelInfo
|
|
for _, sublevel := range sublevels {
|
|
metas := sublevelMap[uint64(sublevel)]
|
|
levelSlices = append(
|
|
levelSlices,
|
|
sublevelInfo{
|
|
manifest.NewLevelSliceKeySorted(cmp, metas),
|
|
manifest.L0Sublevel(sublevel),
|
|
},
|
|
)
|
|
}
|
|
return levelSlices
|
|
}
|
|
|
|
// compactionPickerMetrics holds metrics related to the compaction picking process
|
|
type compactionPickerMetrics struct {
|
|
// scores contains the compensatedScoreRatio from the candidateLevelInfo.
|
|
scores []float64
|
|
singleLevelOverlappingRatio float64
|
|
multiLevelOverlappingRatio float64
|
|
}
|
|
|
|
// pickedCompaction contains information about a compaction that has already
|
|
// been chosen, and is being constructed. Compaction construction info lives in
|
|
// this struct, and is copied over into the compaction struct when that's
|
|
// created.
|
|
type pickedCompaction struct {
|
|
cmp Compare
|
|
// score of the chosen compaction. This is the same as the
|
|
// compensatedScoreRatio in the candidateLevelInfo.
|
|
score float64
|
|
// kind indicates the kind of compaction.
|
|
kind compactionKind
|
|
// startLevel is the level that is being compacted. Inputs from startLevel
|
|
// and outputLevel will be merged to produce a set of outputLevel files.
|
|
startLevel *compactionLevel
|
|
// outputLevel is the level that files are being produced in. outputLevel is
|
|
// equal to startLevel+1 except when:
|
|
// - if startLevel is 0, the output level equals compactionPicker.baseLevel().
|
|
// - in multilevel compaction, the output level is the lowest level involved in
|
|
// the compaction
|
|
outputLevel *compactionLevel
|
|
// extraLevels contain additional levels in between the input and output
|
|
// levels that get compacted in multi level compactions
|
|
extraLevels []*compactionLevel
|
|
inputs []compactionLevel
|
|
// LBase at the time of compaction picking.
|
|
baseLevel int
|
|
// L0-specific compaction info. Set to a non-nil value for all compactions
|
|
// where startLevel == 0 that were generated by L0Sublevels.
|
|
lcf *manifest.L0CompactionFiles
|
|
// maxOutputFileSize is the maximum size of an individual table created
|
|
// during compaction.
|
|
maxOutputFileSize uint64
|
|
// maxOverlapBytes is the maximum number of bytes of overlap allowed for a
|
|
// single output table with the tables in the grandparent level.
|
|
maxOverlapBytes uint64
|
|
// maxReadCompactionBytes is the maximum bytes a read compaction is allowed to
|
|
// overlap in its output level with. If the overlap is greater than
|
|
// maxReadCompaction bytes, then we don't proceed with the compaction.
|
|
maxReadCompactionBytes uint64
|
|
// The boundaries of the input data.
|
|
smallest InternalKey
|
|
largest InternalKey
|
|
version *version
|
|
pickerMetrics compactionPickerMetrics
|
|
}
|
|
|
|
func defaultOutputLevel(startLevel, baseLevel int) int {
|
|
outputLevel := startLevel + 1
|
|
if startLevel == 0 {
|
|
outputLevel = baseLevel
|
|
}
|
|
if outputLevel >= numLevels-1 {
|
|
outputLevel = numLevels - 1
|
|
}
|
|
return outputLevel
|
|
}
|
|
|
|
func newPickedCompaction(
|
|
opts *Options, cur *version, startLevel, outputLevel, baseLevel int,
|
|
) *pickedCompaction {
|
|
if startLevel > 0 && startLevel < baseLevel {
|
|
panic(fmt.Sprintf("invalid compaction: start level %d should not be empty (base level %d)",
|
|
startLevel, baseLevel))
|
|
}
|
|
|
|
adjustedLevel := adjustedOutputLevel(outputLevel, baseLevel)
|
|
pc := &pickedCompaction{
|
|
cmp: opts.Comparer.Compare,
|
|
version: cur,
|
|
baseLevel: baseLevel,
|
|
inputs: []compactionLevel{{level: startLevel}, {level: outputLevel}},
|
|
maxOutputFileSize: uint64(opts.Level(adjustedLevel).TargetFileSize),
|
|
maxOverlapBytes: maxGrandparentOverlapBytes(opts, adjustedLevel),
|
|
maxReadCompactionBytes: maxReadCompactionBytes(opts, adjustedLevel),
|
|
}
|
|
pc.startLevel = &pc.inputs[0]
|
|
pc.outputLevel = &pc.inputs[1]
|
|
return pc
|
|
}
|
|
|
|
// adjustedOutputLevel is the output level used for the purpose of
|
|
// determining the target output file size, overlap bytes, and expanded
|
|
// bytes, taking into account the base level.
|
|
func adjustedOutputLevel(outputLevel int, baseLevel int) int {
|
|
adjustedOutputLevel := outputLevel
|
|
if adjustedOutputLevel > 0 {
|
|
// Output level is in the range [baseLevel, numLevels]. For the purpose of
|
|
// determining the target output file size, overlap bytes, and expanded
|
|
// bytes, we want to adjust the range to [1,numLevels].
|
|
adjustedOutputLevel = 1 + outputLevel - baseLevel
|
|
}
|
|
return adjustedOutputLevel
|
|
}
|
|
|
|
func newPickedCompactionFromL0(
|
|
lcf *manifest.L0CompactionFiles, opts *Options, vers *version, baseLevel int, isBase bool,
|
|
) *pickedCompaction {
|
|
outputLevel := baseLevel
|
|
if !isBase {
|
|
outputLevel = 0 // Intra L0
|
|
}
|
|
|
|
pc := newPickedCompaction(opts, vers, 0, outputLevel, baseLevel)
|
|
pc.lcf = lcf
|
|
pc.outputLevel.level = outputLevel
|
|
|
|
// Manually build the compaction as opposed to calling
|
|
// pickAutoHelper. This is because L0Sublevels has already added
|
|
// any overlapping L0 SSTables that need to be added, and
|
|
// because compactions built by L0SSTables do not necessarily
|
|
// pick contiguous sequences of files in pc.version.Levels[0].
|
|
files := make([]*manifest.FileMetadata, 0, len(lcf.Files))
|
|
iter := vers.Levels[0].Iter()
|
|
for f := iter.First(); f != nil; f = iter.Next() {
|
|
if lcf.FilesIncluded[f.L0Index] {
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
pc.startLevel.files = manifest.NewLevelSliceSeqSorted(files)
|
|
return pc
|
|
}
|
|
|
|
func (pc *pickedCompaction) String() string {
|
|
var builder strings.Builder
|
|
builder.WriteString(fmt.Sprintf(`Score=%f, `, pc.score))
|
|
builder.WriteString(fmt.Sprintf(`Kind=%s, `, pc.kind))
|
|
builder.WriteString(fmt.Sprintf(`AdjustedOutputLevel=%d, `, adjustedOutputLevel(pc.outputLevel.level, pc.baseLevel)))
|
|
builder.WriteString(fmt.Sprintf(`maxOutputFileSize=%d, `, pc.maxOutputFileSize))
|
|
builder.WriteString(fmt.Sprintf(`maxReadCompactionBytes=%d, `, pc.maxReadCompactionBytes))
|
|
builder.WriteString(fmt.Sprintf(`smallest=%s, `, pc.smallest))
|
|
builder.WriteString(fmt.Sprintf(`largest=%s, `, pc.largest))
|
|
builder.WriteString(fmt.Sprintf(`version=%s, `, pc.version))
|
|
builder.WriteString(fmt.Sprintf(`inputs=%s, `, pc.inputs))
|
|
builder.WriteString(fmt.Sprintf(`startlevel=%s, `, pc.startLevel))
|
|
builder.WriteString(fmt.Sprintf(`outputLevel=%s, `, pc.outputLevel))
|
|
builder.WriteString(fmt.Sprintf(`extraLevels=%s, `, pc.extraLevels))
|
|
builder.WriteString(fmt.Sprintf(`l0SublevelInfo=%s, `, pc.startLevel.l0SublevelInfo))
|
|
builder.WriteString(fmt.Sprintf(`lcf=%s`, pc.lcf))
|
|
return builder.String()
|
|
}
|
|
|
|
// Clone creates a deep copy of the pickedCompaction
|
|
func (pc *pickedCompaction) clone() *pickedCompaction {
|
|
|
|
// Quickly copy over fields that do not require special deep copy care, and
|
|
// set all fields that will require a deep copy to nil.
|
|
newPC := &pickedCompaction{
|
|
cmp: pc.cmp,
|
|
score: pc.score,
|
|
kind: pc.kind,
|
|
baseLevel: pc.baseLevel,
|
|
maxOutputFileSize: pc.maxOutputFileSize,
|
|
maxOverlapBytes: pc.maxOverlapBytes,
|
|
maxReadCompactionBytes: pc.maxReadCompactionBytes,
|
|
smallest: pc.smallest.Clone(),
|
|
largest: pc.largest.Clone(),
|
|
|
|
// TODO(msbutler): properly clone picker metrics
|
|
pickerMetrics: pc.pickerMetrics,
|
|
|
|
// Both copies see the same manifest, therefore, it's ok for them to se
|
|
// share the same pc. version.
|
|
version: pc.version,
|
|
}
|
|
|
|
newPC.inputs = make([]compactionLevel, len(pc.inputs))
|
|
newPC.extraLevels = make([]*compactionLevel, 0, len(pc.extraLevels))
|
|
for i := range pc.inputs {
|
|
newPC.inputs[i] = pc.inputs[i].Clone()
|
|
if i == 0 {
|
|
newPC.startLevel = &newPC.inputs[i]
|
|
} else if i == len(pc.inputs)-1 {
|
|
newPC.outputLevel = &newPC.inputs[i]
|
|
} else {
|
|
newPC.extraLevels = append(newPC.extraLevels, &newPC.inputs[i])
|
|
}
|
|
}
|
|
|
|
if len(pc.startLevel.l0SublevelInfo) > 0 {
|
|
newPC.startLevel.l0SublevelInfo = make([]sublevelInfo, len(pc.startLevel.l0SublevelInfo))
|
|
for i := range pc.startLevel.l0SublevelInfo {
|
|
newPC.startLevel.l0SublevelInfo[i] = pc.startLevel.l0SublevelInfo[i].Clone()
|
|
}
|
|
}
|
|
if pc.lcf != nil {
|
|
newPC.lcf = pc.lcf.Clone()
|
|
}
|
|
return newPC
|
|
}
|
|
|
|
// maybeExpandedBounds is a helper function for setupInputs which ensures the
|
|
// pickedCompaction's smallest and largest internal keys are updated iff
|
|
// the candidate keys expand the key span. This avoids a bug for multi-level
|
|
// compactions: during the second call to setupInputs, the picked compaction's
|
|
// smallest and largest keys should not decrease the key span.
|
|
func (pc *pickedCompaction) maybeExpandBounds(smallest InternalKey, largest InternalKey) {
|
|
emptyKey := InternalKey{}
|
|
if base.InternalCompare(pc.cmp, smallest, emptyKey) == 0 {
|
|
if base.InternalCompare(pc.cmp, largest, emptyKey) != 0 {
|
|
panic("either both candidate keys are empty or neither are empty")
|
|
}
|
|
return
|
|
}
|
|
if base.InternalCompare(pc.cmp, pc.smallest, emptyKey) == 0 {
|
|
if base.InternalCompare(pc.cmp, pc.largest, emptyKey) != 0 {
|
|
panic("either both pc keys are empty or neither are empty")
|
|
}
|
|
pc.smallest = smallest
|
|
pc.largest = largest
|
|
return
|
|
}
|
|
if base.InternalCompare(pc.cmp, pc.smallest, smallest) >= 0 {
|
|
pc.smallest = smallest
|
|
}
|
|
if base.InternalCompare(pc.cmp, pc.largest, largest) <= 0 {
|
|
pc.largest = largest
|
|
}
|
|
}
|
|
|
|
// setupInputs returns true if a compaction has been set up. It returns false if
|
|
// a concurrent compaction is occurring on the start or output level files.
|
|
func (pc *pickedCompaction) setupInputs(
|
|
opts *Options, diskAvailBytes uint64, startLevel *compactionLevel,
|
|
) bool {
|
|
// maxExpandedBytes is the maximum size of an expanded compaction. If
|
|
// growing a compaction results in a larger size, the original compaction
|
|
// is used instead.
|
|
maxExpandedBytes := expandedCompactionByteSizeLimit(
|
|
opts, adjustedOutputLevel(pc.outputLevel.level, pc.baseLevel), diskAvailBytes,
|
|
)
|
|
|
|
// Expand the initial inputs to a clean cut.
|
|
var isCompacting bool
|
|
startLevel.files, isCompacting = expandToAtomicUnit(pc.cmp, startLevel.files, false /* disableIsCompacting */)
|
|
if isCompacting {
|
|
return false
|
|
}
|
|
pc.maybeExpandBounds(manifest.KeyRange(pc.cmp, startLevel.files.Iter()))
|
|
|
|
// Determine the sstables in the output level which overlap with the input
|
|
// sstables, and then expand those tables to a clean cut. No need to do
|
|
// this for intra-L0 compactions; outputLevel.files is left empty for those.
|
|
if startLevel.level != pc.outputLevel.level {
|
|
pc.outputLevel.files = pc.version.Overlaps(pc.outputLevel.level, pc.cmp, pc.smallest.UserKey,
|
|
pc.largest.UserKey, pc.largest.IsExclusiveSentinel())
|
|
pc.outputLevel.files, isCompacting = expandToAtomicUnit(pc.cmp, pc.outputLevel.files,
|
|
false /* disableIsCompacting */)
|
|
if isCompacting {
|
|
return false
|
|
}
|
|
pc.maybeExpandBounds(manifest.KeyRange(pc.cmp,
|
|
startLevel.files.Iter(), pc.outputLevel.files.Iter()))
|
|
}
|
|
|
|
// Grow the sstables in startLevel.level as long as it doesn't affect the number
|
|
// of sstables included from pc.outputLevel.level.
|
|
if pc.lcf != nil && startLevel.level == 0 && pc.outputLevel.level != 0 {
|
|
// Call the L0-specific compaction extension method. Similar logic as
|
|
// pc.grow. Additional L0 files are optionally added to the compaction at
|
|
// this step. Note that the bounds passed in are not the bounds of the
|
|
// compaction, but rather the smallest and largest internal keys that
|
|
// the compaction cannot include from L0 without pulling in more Lbase
|
|
// files. Consider this example:
|
|
//
|
|
// L0: c-d e+f g-h
|
|
// Lbase: a-b e+f i-j
|
|
// a b c d e f g h i j
|
|
//
|
|
// The e-f files have already been chosen in the compaction. As pulling
|
|
// in more LBase files is undesirable, the logic below will pass in
|
|
// smallest = b and largest = i to ExtendL0ForBaseCompactionTo, which
|
|
// will expand the compaction to include c-d and g-h from L0. The
|
|
// bounds passed in are exclusive; the compaction cannot be expanded
|
|
// to include files that "touch" it.
|
|
smallestBaseKey := base.InvalidInternalKey
|
|
largestBaseKey := base.InvalidInternalKey
|
|
if pc.outputLevel.files.Empty() {
|
|
baseIter := pc.version.Levels[pc.outputLevel.level].Iter()
|
|
if sm := baseIter.SeekLT(pc.cmp, pc.smallest.UserKey); sm != nil {
|
|
smallestBaseKey = sm.Largest
|
|
}
|
|
if la := baseIter.SeekGE(pc.cmp, pc.largest.UserKey); la != nil {
|
|
largestBaseKey = la.Smallest
|
|
}
|
|
} else {
|
|
// NB: We use Reslice to access the underlying level's files, but
|
|
// we discard the returned slice. The pc.outputLevel.files slice
|
|
// is not modified.
|
|
_ = pc.outputLevel.files.Reslice(func(start, end *manifest.LevelIterator) {
|
|
if sm := start.Prev(); sm != nil {
|
|
smallestBaseKey = sm.Largest
|
|
}
|
|
if la := end.Next(); la != nil {
|
|
largestBaseKey = la.Smallest
|
|
}
|
|
})
|
|
}
|
|
oldLcf := pc.lcf.Clone()
|
|
if pc.version.L0Sublevels.ExtendL0ForBaseCompactionTo(smallestBaseKey, largestBaseKey, pc.lcf) {
|
|
var newStartLevelFiles []*fileMetadata
|
|
iter := pc.version.Levels[0].Iter()
|
|
var sizeSum uint64
|
|
for j, f := 0, iter.First(); f != nil; j, f = j+1, iter.Next() {
|
|
if pc.lcf.FilesIncluded[f.L0Index] {
|
|
newStartLevelFiles = append(newStartLevelFiles, f)
|
|
sizeSum += f.Size
|
|
}
|
|
}
|
|
if sizeSum+pc.outputLevel.files.SizeSum() < maxExpandedBytes {
|
|
startLevel.files = manifest.NewLevelSliceSeqSorted(newStartLevelFiles)
|
|
pc.smallest, pc.largest = manifest.KeyRange(pc.cmp,
|
|
startLevel.files.Iter(), pc.outputLevel.files.Iter())
|
|
} else {
|
|
*pc.lcf = *oldLcf
|
|
}
|
|
}
|
|
} else if pc.grow(pc.smallest, pc.largest, maxExpandedBytes, startLevel) {
|
|
pc.maybeExpandBounds(manifest.KeyRange(pc.cmp,
|
|
startLevel.files.Iter(), pc.outputLevel.files.Iter()))
|
|
}
|
|
|
|
if pc.startLevel.level == 0 {
|
|
// We don't change the input files for the compaction beyond this point.
|
|
pc.startLevel.l0SublevelInfo = generateSublevelInfo(pc.cmp, pc.startLevel.files)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// grow grows the number of inputs at c.level without changing the number of
|
|
// c.level+1 files in the compaction, and returns whether the inputs grew. sm
|
|
// and la are the smallest and largest InternalKeys in all of the inputs.
|
|
func (pc *pickedCompaction) grow(
|
|
sm, la InternalKey, maxExpandedBytes uint64, startLevel *compactionLevel,
|
|
) bool {
|
|
if pc.outputLevel.files.Empty() {
|
|
return false
|
|
}
|
|
grow0 := pc.version.Overlaps(startLevel.level, pc.cmp, sm.UserKey,
|
|
la.UserKey, la.IsExclusiveSentinel())
|
|
grow0, isCompacting := expandToAtomicUnit(pc.cmp, grow0, false /* disableIsCompacting */)
|
|
if isCompacting {
|
|
return false
|
|
}
|
|
if grow0.Len() <= startLevel.files.Len() {
|
|
return false
|
|
}
|
|
if grow0.SizeSum()+pc.outputLevel.files.SizeSum() >= maxExpandedBytes {
|
|
return false
|
|
}
|
|
// We need to include the outputLevel iter because without it, in a multiLevel scenario,
|
|
// sm1 and la1 could shift the output level keyspace when pc.outputLevel.files is set to grow1.
|
|
sm1, la1 := manifest.KeyRange(pc.cmp, grow0.Iter(), pc.outputLevel.files.Iter())
|
|
grow1 := pc.version.Overlaps(pc.outputLevel.level, pc.cmp, sm1.UserKey,
|
|
la1.UserKey, la1.IsExclusiveSentinel())
|
|
grow1, isCompacting = expandToAtomicUnit(pc.cmp, grow1, false /* disableIsCompacting */)
|
|
if isCompacting {
|
|
return false
|
|
}
|
|
if grow1.Len() != pc.outputLevel.files.Len() {
|
|
return false
|
|
}
|
|
startLevel.files = grow0
|
|
pc.outputLevel.files = grow1
|
|
return true
|
|
}
|
|
|
|
func (pc *pickedCompaction) compactionSize() uint64 {
|
|
var bytesToCompact uint64
|
|
for i := range pc.inputs {
|
|
bytesToCompact += pc.inputs[i].files.SizeSum()
|
|
}
|
|
return bytesToCompact
|
|
}
|
|
|
|
// setupMultiLevelCandidated returns true if it successfully added another level
|
|
// to the compaction.
|
|
func (pc *pickedCompaction) setupMultiLevelCandidate(opts *Options, diskAvailBytes uint64) bool {
|
|
pc.inputs = append(pc.inputs, compactionLevel{level: pc.outputLevel.level + 1})
|
|
|
|
// Recalibrate startLevel and outputLevel:
|
|
// - startLevel and outputLevel pointers may be obsolete after appending to pc.inputs.
|
|
// - push outputLevel to extraLevels and move the new level to outputLevel
|
|
pc.startLevel = &pc.inputs[0]
|
|
pc.extraLevels = []*compactionLevel{&pc.inputs[1]}
|
|
pc.outputLevel = &pc.inputs[2]
|
|
return pc.setupInputs(opts, diskAvailBytes, pc.extraLevels[len(pc.extraLevels)-1])
|
|
}
|
|
|
|
// expandToAtomicUnit expands the provided level slice within its level both
|
|
// forwards and backwards to its "atomic compaction unit" boundaries, if
|
|
// necessary.
|
|
//
|
|
// While picking compaction inputs, this is required to maintain the invariant
|
|
// that the versions of keys at level+1 are older than the versions of keys at
|
|
// level. Tables are added to the right of the current slice tables such that
|
|
// the rightmost table has a "clean cut". A clean cut is either a change in
|
|
// user keys, or when the largest key in the left sstable is a range tombstone
|
|
// sentinel key (InternalKeyRangeDeleteSentinel).
|
|
//
|
|
// In addition to maintaining the seqnum invariant, expandToAtomicUnit is used
|
|
// to provide clean boundaries for range tombstone truncation during
|
|
// compaction. In order to achieve these clean boundaries, expandToAtomicUnit
|
|
// needs to find a "clean cut" on the left edge of the compaction as well.
|
|
// This is necessary in order for "atomic compaction units" to always be
|
|
// compacted as a unit. Failure to do this leads to a subtle bug with
|
|
// truncation of range tombstones to atomic compaction unit boundaries.
|
|
// Consider the scenario:
|
|
//
|
|
// L3:
|
|
// 12:[a#2,15-b#1,1]
|
|
// 13:[b#0,15-d#72057594037927935,15]
|
|
//
|
|
// These sstables contain a range tombstone [a-d)#2 which spans the two
|
|
// sstables. The two sstables need to always be kept together. Compacting
|
|
// sstable 13 independently of sstable 12 would result in:
|
|
//
|
|
// L3:
|
|
// 12:[a#2,15-b#1,1]
|
|
// L4:
|
|
// 14:[b#0,15-d#72057594037927935,15]
|
|
//
|
|
// This state is still ok, but when sstable 12 is next compacted, its range
|
|
// tombstones will be truncated at "b" (the largest key in its atomic
|
|
// compaction unit). In the scenario here, that could result in b#1 becoming
|
|
// visible when it should be deleted.
|
|
//
|
|
// isCompacting is returned true for any atomic units that contain files that
|
|
// have in-progress compactions, i.e. FileMetadata.Compacting == true. If
|
|
// disableIsCompacting is true, isCompacting always returns false. This helps
|
|
// avoid spurious races from being detected when this method is used outside
|
|
// of compaction picking code.
|
|
//
|
|
// TODO(jackson): Compactions and flushes no longer split a user key between two
|
|
// sstables. We could perform a migration, re-compacting any sstables with split
|
|
// user keys, which would allow us to remove atomic compaction unit expansion
|
|
// code.
|
|
func expandToAtomicUnit(
|
|
cmp Compare, inputs manifest.LevelSlice, disableIsCompacting bool,
|
|
) (slice manifest.LevelSlice, isCompacting bool) {
|
|
// NB: Inputs for L0 can't be expanded and *version.Overlaps guarantees
|
|
// that we get a 'clean cut.' For L0, Overlaps will return a slice without
|
|
// access to the rest of the L0 files, so it's OK to try to reslice.
|
|
if inputs.Empty() {
|
|
// Nothing to expand.
|
|
return inputs, false
|
|
}
|
|
|
|
// TODO(jackson): Update to avoid use of LevelIterator.Current(). The
|
|
// Reslice interface will require some tweaking, because we currently rely
|
|
// on Reslice having already positioned the LevelIterator appropriately.
|
|
|
|
inputs = inputs.Reslice(func(start, end *manifest.LevelIterator) {
|
|
iter := start.Clone()
|
|
iter.Prev()
|
|
for cur, prev := start.Current(), iter.Current(); prev != nil; cur, prev = start.Prev(), iter.Prev() {
|
|
if cur.IsCompacting() {
|
|
isCompacting = true
|
|
}
|
|
if cmp(prev.Largest.UserKey, cur.Smallest.UserKey) < 0 {
|
|
break
|
|
}
|
|
if prev.Largest.IsExclusiveSentinel() {
|
|
// The table prev has a largest key indicating that the user key
|
|
// prev.largest.UserKey doesn't actually exist in the table.
|
|
break
|
|
}
|
|
// prev.Largest.UserKey == cur.Smallest.UserKey, so we need to
|
|
// include prev in the compaction.
|
|
}
|
|
|
|
iter = end.Clone()
|
|
iter.Next()
|
|
for cur, next := end.Current(), iter.Current(); next != nil; cur, next = end.Next(), iter.Next() {
|
|
if cur.IsCompacting() {
|
|
isCompacting = true
|
|
}
|
|
if cmp(cur.Largest.UserKey, next.Smallest.UserKey) < 0 {
|
|
break
|
|
}
|
|
if cur.Largest.IsExclusiveSentinel() {
|
|
// The table cur has a largest key indicating that the user key
|
|
// cur.largest.UserKey doesn't actually exist in the table.
|
|
break
|
|
}
|
|
// cur.Largest.UserKey == next.Smallest.UserKey, so we need to
|
|
// include next in the compaction.
|
|
}
|
|
})
|
|
inputIter := inputs.Iter()
|
|
isCompacting = !disableIsCompacting &&
|
|
(isCompacting || inputIter.First().IsCompacting() || inputIter.Last().IsCompacting())
|
|
return inputs, isCompacting
|
|
}
|
|
|
|
func newCompactionPicker(
|
|
v *version, opts *Options, inProgressCompactions []compactionInfo,
|
|
) compactionPicker {
|
|
p := &compactionPickerByScore{
|
|
opts: opts,
|
|
vers: v,
|
|
}
|
|
p.initLevelMaxBytes(inProgressCompactions)
|
|
return p
|
|
}
|
|
|
|
// Information about a candidate compaction level that has been identified by
|
|
// the compaction picker.
|
|
type candidateLevelInfo struct {
|
|
// The compensatedScore of the level after adjusting according to the other
|
|
// levels' sizes. For L0, the compensatedScoreRatio is equivalent to the
|
|
// uncompensatedScoreRatio as we don't account for level size compensation in
|
|
// L0.
|
|
compensatedScoreRatio float64
|
|
// The score of the level after accounting for level size compensation before
|
|
// adjusting according to other levels' sizes. For L0, the compensatedScore
|
|
// is equivalent to the uncompensatedScore as we don't account for level
|
|
// size compensation in L0.
|
|
compensatedScore float64
|
|
// The score of the level to be compacted, calculated using uncompensated file
|
|
// sizes and without any adjustments.
|
|
uncompensatedScore float64
|
|
// uncompensatedScoreRatio is the uncompensatedScore adjusted according to
|
|
// the other levels' sizes.
|
|
uncompensatedScoreRatio float64
|
|
level int
|
|
// The level to compact to.
|
|
outputLevel int
|
|
// The file in level that will be compacted. Additional files may be
|
|
// picked by the compaction, and a pickedCompaction created for the
|
|
// compaction.
|
|
file manifest.LevelFile
|
|
}
|
|
|
|
func (c *candidateLevelInfo) shouldCompact() bool {
|
|
return c.compensatedScoreRatio >= compactionScoreThreshold
|
|
}
|
|
|
|
func fileCompensation(f *fileMetadata) uint64 {
|
|
return uint64(f.Stats.PointDeletionsBytesEstimate) + f.Stats.RangeDeletionsBytesEstimate
|
|
}
|
|
|
|
// compensatedSize returns f's file size, inflated according to compaction
|
|
// priorities.
|
|
func compensatedSize(f *fileMetadata) uint64 {
|
|
// Add in the estimate of disk space that may be reclaimed by compacting the
|
|
// file's tombstones.
|
|
return f.Size + fileCompensation(f)
|
|
}
|
|
|
|
// compensatedSizeAnnotator implements manifest.Annotator, annotating B-Tree
|
|
// nodes with the sum of the files' compensated sizes. Its annotation type is
|
|
// a *uint64. Compensated sizes may change once a table's stats are loaded
|
|
// asynchronously, so its values are marked as cacheable only if a file's
|
|
// stats have been loaded.
|
|
type compensatedSizeAnnotator struct {
|
|
}
|
|
|
|
var _ manifest.Annotator = compensatedSizeAnnotator{}
|
|
|
|
func (a compensatedSizeAnnotator) Zero(dst interface{}) interface{} {
|
|
if dst == nil {
|
|
return new(uint64)
|
|
}
|
|
v := dst.(*uint64)
|
|
*v = 0
|
|
return v
|
|
}
|
|
|
|
func (a compensatedSizeAnnotator) Accumulate(
|
|
f *fileMetadata, dst interface{},
|
|
) (v interface{}, cacheOK bool) {
|
|
vptr := dst.(*uint64)
|
|
*vptr = *vptr + compensatedSize(f)
|
|
return vptr, f.StatsValid()
|
|
}
|
|
|
|
func (a compensatedSizeAnnotator) Merge(src interface{}, dst interface{}) interface{} {
|
|
srcV := src.(*uint64)
|
|
dstV := dst.(*uint64)
|
|
*dstV = *dstV + *srcV
|
|
return dstV
|
|
}
|
|
|
|
// totalCompensatedSize computes the compensated size over a file metadata
|
|
// iterator. Note that this function is linear in the files available to the
|
|
// iterator. Use the compensatedSizeAnnotator if querying the total
|
|
// compensated size of a level.
|
|
func totalCompensatedSize(iter manifest.LevelIterator) uint64 {
|
|
var sz uint64
|
|
for f := iter.First(); f != nil; f = iter.Next() {
|
|
sz += compensatedSize(f)
|
|
}
|
|
return sz
|
|
}
|
|
|
|
// compactionPickerByScore holds the state and logic for picking a compaction. A
|
|
// compaction picker is associated with a single version. A new compaction
|
|
// picker is created and initialized every time a new version is installed.
|
|
type compactionPickerByScore struct {
|
|
opts *Options
|
|
vers *version
|
|
// The level to target for L0 compactions. Levels L1 to baseLevel must be
|
|
// empty.
|
|
baseLevel int
|
|
// levelMaxBytes holds the dynamically adjusted max bytes setting for each
|
|
// level.
|
|
levelMaxBytes [numLevels]int64
|
|
}
|
|
|
|
var _ compactionPicker = &compactionPickerByScore{}
|
|
|
|
func (p *compactionPickerByScore) getScores(inProgress []compactionInfo) [numLevels]float64 {
|
|
var scores [numLevels]float64
|
|
for _, info := range p.calculateLevelScores(inProgress) {
|
|
scores[info.level] = info.compensatedScoreRatio
|
|
}
|
|
return scores
|
|
}
|
|
|
|
func (p *compactionPickerByScore) getBaseLevel() int {
|
|
if p == nil {
|
|
return 1
|
|
}
|
|
return p.baseLevel
|
|
}
|
|
|
|
// estimatedCompactionDebt estimates the number of bytes which need to be
|
|
// compacted before the LSM tree becomes stable.
|
|
func (p *compactionPickerByScore) estimatedCompactionDebt(l0ExtraSize uint64) uint64 {
|
|
if p == nil {
|
|
return 0
|
|
}
|
|
|
|
// We assume that all the bytes in L0 need to be compacted to Lbase. This is
|
|
// unlike the RocksDB logic that figures out whether L0 needs compaction.
|
|
bytesAddedToNextLevel := l0ExtraSize + p.vers.Levels[0].Size()
|
|
lbaseSize := p.vers.Levels[p.baseLevel].Size()
|
|
|
|
var compactionDebt uint64
|
|
if bytesAddedToNextLevel > 0 && lbaseSize > 0 {
|
|
// We only incur compaction debt if both L0 and Lbase contain data. If L0
|
|
// is empty, no compaction is necessary. If Lbase is empty, a move-based
|
|
// compaction from L0 would occur.
|
|
compactionDebt += bytesAddedToNextLevel + lbaseSize
|
|
}
|
|
|
|
// loop invariant: At the beginning of the loop, bytesAddedToNextLevel is the
|
|
// bytes added to `level` in the loop.
|
|
for level := p.baseLevel; level < numLevels-1; level++ {
|
|
levelSize := p.vers.Levels[level].Size() + bytesAddedToNextLevel
|
|
nextLevelSize := p.vers.Levels[level+1].Size()
|
|
if levelSize > uint64(p.levelMaxBytes[level]) {
|
|
bytesAddedToNextLevel = levelSize - uint64(p.levelMaxBytes[level])
|
|
if nextLevelSize > 0 {
|
|
// We only incur compaction debt if the next level contains data. If the
|
|
// next level is empty, a move-based compaction would be used.
|
|
levelRatio := float64(nextLevelSize) / float64(levelSize)
|
|
// The current level contributes bytesAddedToNextLevel to compactions.
|
|
// The next level contributes levelRatio * bytesAddedToNextLevel.
|
|
compactionDebt += uint64(float64(bytesAddedToNextLevel) * (levelRatio + 1))
|
|
}
|
|
} else {
|
|
// We're not moving any bytes to the next level.
|
|
bytesAddedToNextLevel = 0
|
|
}
|
|
}
|
|
return compactionDebt
|
|
}
|
|
|
|
func (p *compactionPickerByScore) initLevelMaxBytes(inProgressCompactions []compactionInfo) {
|
|
// The levelMaxBytes calculations here differ from RocksDB in two ways:
|
|
//
|
|
// 1. The use of dbSize vs maxLevelSize. RocksDB uses the size of the maximum
|
|
// level in L1-L6, rather than determining the size of the bottom level
|
|
// based on the total amount of data in the dB. The RocksDB calculation is
|
|
// problematic if L0 contains a significant fraction of data, or if the
|
|
// level sizes are roughly equal and thus there is a significant fraction
|
|
// of data outside of the largest level.
|
|
//
|
|
// 2. Not adjusting the size of Lbase based on L0. RocksDB computes
|
|
// baseBytesMax as the maximum of the configured LBaseMaxBytes and the
|
|
// size of L0. This is problematic because baseBytesMax is used to compute
|
|
// the max size of lower levels. A very large baseBytesMax will result in
|
|
// an overly large value for the size of lower levels which will caused
|
|
// those levels not to be compacted even when they should be
|
|
// compacted. This often results in "inverted" LSM shapes where Ln is
|
|
// larger than Ln+1.
|
|
|
|
// Determine the first non-empty level and the total DB size.
|
|
firstNonEmptyLevel := -1
|
|
var dbSize uint64
|
|
for level := 1; level < numLevels; level++ {
|
|
if p.vers.Levels[level].Size() > 0 {
|
|
if firstNonEmptyLevel == -1 {
|
|
firstNonEmptyLevel = level
|
|
}
|
|
dbSize += p.vers.Levels[level].Size()
|
|
}
|
|
}
|
|
for _, c := range inProgressCompactions {
|
|
if c.outputLevel == 0 || c.outputLevel == -1 {
|
|
continue
|
|
}
|
|
if c.inputs[0].level == 0 && (firstNonEmptyLevel == -1 || c.outputLevel < firstNonEmptyLevel) {
|
|
firstNonEmptyLevel = c.outputLevel
|
|
}
|
|
}
|
|
|
|
// Initialize the max-bytes setting for each level to "infinity" which will
|
|
// disallow compaction for that level. We'll fill in the actual value below
|
|
// for levels we want to allow compactions from.
|
|
for level := 0; level < numLevels; level++ {
|
|
p.levelMaxBytes[level] = math.MaxInt64
|
|
}
|
|
|
|
if dbSize == 0 {
|
|
// No levels for L1 and up contain any data. Target L0 compactions for the
|
|
// last level or to the level to which there is an ongoing L0 compaction.
|
|
p.baseLevel = numLevels - 1
|
|
if firstNonEmptyLevel >= 0 {
|
|
p.baseLevel = firstNonEmptyLevel
|
|
}
|
|
return
|
|
}
|
|
|
|
dbSize += p.vers.Levels[0].Size()
|
|
bottomLevelSize := dbSize - dbSize/uint64(p.opts.Experimental.LevelMultiplier)
|
|
|
|
curLevelSize := bottomLevelSize
|
|
for level := numLevels - 2; level >= firstNonEmptyLevel; level-- {
|
|
curLevelSize = uint64(float64(curLevelSize) / float64(p.opts.Experimental.LevelMultiplier))
|
|
}
|
|
|
|
// Compute base level (where L0 data is compacted to).
|
|
baseBytesMax := uint64(p.opts.LBaseMaxBytes)
|
|
p.baseLevel = firstNonEmptyLevel
|
|
for p.baseLevel > 1 && curLevelSize > baseBytesMax {
|
|
p.baseLevel--
|
|
curLevelSize = uint64(float64(curLevelSize) / float64(p.opts.Experimental.LevelMultiplier))
|
|
}
|
|
|
|
smoothedLevelMultiplier := 1.0
|
|
if p.baseLevel < numLevels-1 {
|
|
smoothedLevelMultiplier = math.Pow(
|
|
float64(bottomLevelSize)/float64(baseBytesMax),
|
|
1.0/float64(numLevels-p.baseLevel-1))
|
|
}
|
|
|
|
levelSize := float64(baseBytesMax)
|
|
for level := p.baseLevel; level < numLevels; level++ {
|
|
if level > p.baseLevel && levelSize > 0 {
|
|
levelSize *= smoothedLevelMultiplier
|
|
}
|
|
// Round the result since test cases use small target level sizes, which
|
|
// can be impacted by floating-point imprecision + integer truncation.
|
|
roundedLevelSize := math.Round(levelSize)
|
|
if roundedLevelSize > float64(math.MaxInt64) {
|
|
p.levelMaxBytes[level] = math.MaxInt64
|
|
} else {
|
|
p.levelMaxBytes[level] = int64(roundedLevelSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
type levelSizeAdjust struct {
|
|
incomingActualBytes uint64
|
|
outgoingActualBytes uint64
|
|
outgoingCompensatedBytes uint64
|
|
}
|
|
|
|
func (a levelSizeAdjust) compensated() uint64 {
|
|
return a.incomingActualBytes - a.outgoingCompensatedBytes
|
|
}
|
|
|
|
func (a levelSizeAdjust) actual() uint64 {
|
|
return a.incomingActualBytes - a.outgoingActualBytes
|
|
}
|
|
|
|
func calculateSizeAdjust(inProgressCompactions []compactionInfo) [numLevels]levelSizeAdjust {
|
|
// Compute size adjustments for each level based on the in-progress
|
|
// compactions. We sum the file sizes of all files leaving and entering each
|
|
// level in in-progress compactions. For outgoing files, we also sum a
|
|
// separate sum of 'compensated file sizes', which are inflated according
|
|
// to deletion estimates.
|
|
//
|
|
// When we adjust a level's size according to these values during score
|
|
// calculation, we subtract the compensated size of start level inputs to
|
|
// account for the fact that score calculation uses compensated sizes.
|
|
//
|
|
// Since compensated file sizes may be compensated because they reclaim
|
|
// space from the output level's files, we only add the real file size to
|
|
// the output level.
|
|
//
|
|
// This is slightly different from RocksDB's behavior, which simply elides
|
|
// compacting files from the level size calculation.
|
|
var sizeAdjust [numLevels]levelSizeAdjust
|
|
for i := range inProgressCompactions {
|
|
c := &inProgressCompactions[i]
|
|
// If this compaction's version edit has already been applied, there's
|
|
// no need to adjust: The LSM we'll examine will already reflect the
|
|
// new LSM state.
|
|
if c.versionEditApplied {
|
|
continue
|
|
}
|
|
|
|
for _, input := range c.inputs {
|
|
actualSize := input.files.SizeSum()
|
|
compensatedSize := totalCompensatedSize(input.files.Iter())
|
|
|
|
if input.level != c.outputLevel {
|
|
sizeAdjust[input.level].outgoingCompensatedBytes += compensatedSize
|
|
sizeAdjust[input.level].outgoingActualBytes += actualSize
|
|
if c.outputLevel != -1 {
|
|
sizeAdjust[c.outputLevel].incomingActualBytes += actualSize
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return sizeAdjust
|
|
}
|
|
|
|
func levelCompensatedSize(lm manifest.LevelMetadata) uint64 {
|
|
return *lm.Annotation(compensatedSizeAnnotator{}).(*uint64)
|
|
}
|
|
|
|
func (p *compactionPickerByScore) calculateLevelScores(
|
|
inProgressCompactions []compactionInfo,
|
|
) [numLevels]candidateLevelInfo {
|
|
var scores [numLevels]candidateLevelInfo
|
|
for i := range scores {
|
|
scores[i].level = i
|
|
scores[i].outputLevel = i + 1
|
|
}
|
|
l0UncompensatedScore := calculateL0UncompensatedScore(p.vers, p.opts, inProgressCompactions)
|
|
scores[0] = candidateLevelInfo{
|
|
outputLevel: p.baseLevel,
|
|
uncompensatedScore: l0UncompensatedScore,
|
|
compensatedScore: l0UncompensatedScore, /* No level size compensation for L0 */
|
|
}
|
|
sizeAdjust := calculateSizeAdjust(inProgressCompactions)
|
|
for level := 1; level < numLevels; level++ {
|
|
compensatedLevelSize := levelCompensatedSize(p.vers.Levels[level]) + sizeAdjust[level].compensated()
|
|
scores[level].compensatedScore = float64(compensatedLevelSize) / float64(p.levelMaxBytes[level])
|
|
scores[level].uncompensatedScore = float64(p.vers.Levels[level].Size()+sizeAdjust[level].actual()) / float64(p.levelMaxBytes[level])
|
|
}
|
|
|
|
// Adjust each level's {compensated, uncompensated}Score by the uncompensatedScore
|
|
// of the next level to get a {compensated, uncompensated}ScoreRatio. If the
|
|
// next level has a high uncompensatedScore, and is thus a priority for compaction,
|
|
// this reduces the priority for compacting the current level. If the next level
|
|
// has a low uncompensatedScore (i.e. it is below its target size), this increases
|
|
// the priority for compacting the current level.
|
|
//
|
|
// The effect of this adjustment is to help prioritize compactions in lower
|
|
// levels. The following example shows the compensatedScoreRatio and the
|
|
// compensatedScore. In this scenario, L0 has 68 sublevels. L3 (a.k.a. Lbase)
|
|
// is significantly above its target size. The original score prioritizes
|
|
// compactions from those two levels, but doing so ends up causing a future
|
|
// problem: data piles up in the higher levels, starving L5->L6 compactions,
|
|
// and to a lesser degree starving L4->L5 compactions.
|
|
//
|
|
// Note that in the example shown there is no level size compensation so the
|
|
// compensatedScore and the uncompensatedScore is the same for each level.
|
|
//
|
|
// compensatedScoreRatio compensatedScore uncompensatedScore size max-size
|
|
// L0 3.2 68.0 68.0 2.2 G -
|
|
// L3 3.2 21.1 21.1 1.3 G 64 M
|
|
// L4 3.4 6.7 6.7 3.1 G 467 M
|
|
// L5 3.4 2.0 2.0 6.6 G 3.3 G
|
|
// L6 0.6 0.6 0.6 14 G 24 G
|
|
var prevLevel int
|
|
for level := p.baseLevel; level < numLevels; level++ {
|
|
// The compensated scores, and uncompensated scores will be turned into
|
|
// ratios as they're adjusted according to other levels' sizes.
|
|
scores[prevLevel].compensatedScoreRatio = scores[prevLevel].compensatedScore
|
|
scores[prevLevel].uncompensatedScoreRatio = scores[prevLevel].uncompensatedScore
|
|
|
|
// Avoid absurdly large scores by placing a floor on the score that we'll
|
|
// adjust a level by. The value of 0.01 was chosen somewhat arbitrarily.
|
|
const minScore = 0.01
|
|
if scores[prevLevel].compensatedScoreRatio >= compactionScoreThreshold {
|
|
if scores[level].uncompensatedScore >= minScore {
|
|
scores[prevLevel].compensatedScoreRatio /= scores[level].uncompensatedScore
|
|
} else {
|
|
scores[prevLevel].compensatedScoreRatio /= minScore
|
|
}
|
|
}
|
|
if scores[prevLevel].uncompensatedScoreRatio >= compactionScoreThreshold {
|
|
if scores[level].uncompensatedScore >= minScore {
|
|
scores[prevLevel].uncompensatedScoreRatio /= scores[level].uncompensatedScore
|
|
} else {
|
|
scores[prevLevel].uncompensatedScoreRatio /= minScore
|
|
}
|
|
}
|
|
prevLevel = level
|
|
}
|
|
// Set the score ratios for the lowest level.
|
|
// INVARIANT: prevLevel == numLevels-1
|
|
scores[prevLevel].compensatedScoreRatio = scores[prevLevel].compensatedScore
|
|
scores[prevLevel].uncompensatedScoreRatio = scores[prevLevel].uncompensatedScore
|
|
|
|
sort.Sort(sortCompactionLevelsByPriority(scores[:]))
|
|
return scores
|
|
}
|
|
|
|
// calculateL0UncompensatedScore calculates a float score representing the
|
|
// relative priority of compacting L0. Level L0 is special in that files within
|
|
// L0 may overlap one another, so a different set of heuristics that take into
|
|
// account read amplification apply.
|
|
func calculateL0UncompensatedScore(
|
|
vers *version, opts *Options, inProgressCompactions []compactionInfo,
|
|
) float64 {
|
|
// Use the sublevel count to calculate the score. The base vs intra-L0
|
|
// compaction determination happens in pickAuto, not here.
|
|
score := float64(2*vers.L0Sublevels.MaxDepthAfterOngoingCompactions()) /
|
|
float64(opts.L0CompactionThreshold)
|
|
|
|
// Also calculate a score based on the file count but use it only if it
|
|
// produces a higher score than the sublevel-based one. This heuristic is
|
|
// designed to accommodate cases where L0 is accumulating non-overlapping
|
|
// files in L0. Letting too many non-overlapping files accumulate in few
|
|
// sublevels is undesirable, because:
|
|
// 1) we can produce a massive backlog to compact once files do overlap.
|
|
// 2) constructing L0 sublevels has a runtime that grows superlinearly with
|
|
// the number of files in L0 and must be done while holding D.mu.
|
|
noncompactingFiles := vers.Levels[0].Len()
|
|
for _, c := range inProgressCompactions {
|
|
for _, cl := range c.inputs {
|
|
if cl.level == 0 {
|
|
noncompactingFiles -= cl.files.Len()
|
|
}
|
|
}
|
|
}
|
|
fileScore := float64(noncompactingFiles) / float64(opts.L0CompactionFileThreshold)
|
|
if score < fileScore {
|
|
score = fileScore
|
|
}
|
|
return score
|
|
}
|
|
|
|
// pickCompactionSeedFile picks a file from `level` in the `vers` to build a
|
|
// compaction around. Currently, this function implements a heuristic similar to
|
|
// RocksDB's kMinOverlappingRatio, seeking to minimize write amplification. This
|
|
// function is linear with respect to the number of files in `level` and
|
|
// `outputLevel`.
|
|
func pickCompactionSeedFile(
|
|
vers *version, opts *Options, level, outputLevel int, earliestSnapshotSeqNum uint64,
|
|
) (manifest.LevelFile, bool) {
|
|
// Select the file within the level to compact. We want to minimize write
|
|
// amplification, but also ensure that deletes are propagated to the
|
|
// bottom level in a timely fashion so as to reclaim disk space. A table's
|
|
// smallest sequence number provides a measure of its age. The ratio of
|
|
// overlapping-bytes / table-size gives an indication of write
|
|
// amplification (a smaller ratio is preferrable).
|
|
//
|
|
// The current heuristic is based off the the RocksDB kMinOverlappingRatio
|
|
// heuristic. It chooses the file with the minimum overlapping ratio with
|
|
// the target level, which minimizes write amplification.
|
|
//
|
|
// It uses a "compensated size" for the denominator, which is the file
|
|
// size but artificially inflated by an estimate of the space that may be
|
|
// reclaimed through compaction. Currently, we only compensate for range
|
|
// deletions and only with a rough estimate of the reclaimable bytes. This
|
|
// differs from RocksDB which only compensates for point tombstones and
|
|
// only if they exceed the number of non-deletion entries in table.
|
|
//
|
|
// TODO(peter): For concurrent compactions, we may want to try harder to
|
|
// pick a seed file whose resulting compaction bounds do not overlap with
|
|
// an in-progress compaction.
|
|
|
|
cmp := opts.Comparer.Compare
|
|
startIter := vers.Levels[level].Iter()
|
|
outputIter := vers.Levels[outputLevel].Iter()
|
|
|
|
var file manifest.LevelFile
|
|
smallestRatio := uint64(math.MaxUint64)
|
|
|
|
outputFile := outputIter.First()
|
|
|
|
for f := startIter.First(); f != nil; f = startIter.Next() {
|
|
var overlappingBytes uint64
|
|
compacting := f.IsCompacting()
|
|
if compacting {
|
|
// Move on if this file is already being compacted. We'll likely
|
|
// still need to move past the overlapping output files regardless,
|
|
// but in cases where all start-level files are compacting we won't.
|
|
continue
|
|
}
|
|
|
|
// Trim any output-level files smaller than f.
|
|
for outputFile != nil && sstableKeyCompare(cmp, outputFile.Largest, f.Smallest) < 0 {
|
|
outputFile = outputIter.Next()
|
|
}
|
|
|
|
for outputFile != nil && sstableKeyCompare(cmp, outputFile.Smallest, f.Largest) <= 0 && !compacting {
|
|
overlappingBytes += outputFile.Size
|
|
compacting = compacting || outputFile.IsCompacting()
|
|
|
|
// For files in the bottommost level of the LSM, the
|
|
// Stats.RangeDeletionsBytesEstimate field is set to the estimate
|
|
// of bytes /within/ the file itself that may be dropped by
|
|
// recompacting the file. These bytes from obsolete keys would not
|
|
// need to be rewritten if we compacted `f` into `outputFile`, so
|
|
// they don't contribute to write amplification. Subtracting them
|
|
// out of the overlapping bytes helps prioritize these compactions
|
|
// that are cheaper than their file sizes suggest.
|
|
if outputLevel == numLevels-1 && outputFile.LargestSeqNum < earliestSnapshotSeqNum {
|
|
overlappingBytes -= outputFile.Stats.RangeDeletionsBytesEstimate
|
|
}
|
|
|
|
// If the file in the next level extends beyond f's largest key,
|
|
// break out and don't advance outputIter because f's successor
|
|
// might also overlap.
|
|
//
|
|
// Note, we stop as soon as we encounter an output-level file with a
|
|
// largest key beyond the input-level file's largest bound. We
|
|
// perform a simple user key comparison here using sstableKeyCompare
|
|
// which handles the potential for exclusive largest key bounds.
|
|
// There's some subtlety when the bounds are equal (eg, equal and
|
|
// inclusive, or equal and exclusive). Current Pebble doesn't split
|
|
// user keys across sstables within a level (and in format versions
|
|
// FormatSplitUserKeysMarkedCompacted and later we guarantee no
|
|
// split user keys exist within the entire LSM). In that case, we're
|
|
// assured that neither the input level nor the output level's next
|
|
// file shares the same user key, so compaction expansion will not
|
|
// include them in any compaction compacting `f`.
|
|
//
|
|
// NB: If we /did/ allow split user keys, or we're running on an
|
|
// old database with an earlier format major version where there are
|
|
// existing split user keys, this logic would be incorrect. Consider
|
|
// L1: [a#120,a#100] [a#80,a#60]
|
|
// L2: [a#55,a#45] [a#35,a#25] [a#15,a#5]
|
|
// While considering the first file in L1, [a#120,a#100], we'd skip
|
|
// past all of the files in L2. When considering the second file in
|
|
// L1, we'd improperly conclude that the second file overlaps
|
|
// nothing in the second level and is cheap to compact, when in
|
|
// reality we'd need to expand the compaction to include all 5
|
|
// files.
|
|
if sstableKeyCompare(cmp, outputFile.Largest, f.Largest) > 0 {
|
|
break
|
|
}
|
|
outputFile = outputIter.Next()
|
|
}
|
|
|
|
// If the input level file or one of the overlapping files is
|
|
// compacting, we're not going to be able to compact this file
|
|
// anyways, so skip it.
|
|
if compacting {
|
|
continue
|
|
}
|
|
|
|
compSz := compensatedSize(f)
|
|
scaledRatio := overlappingBytes * 1024 / compSz
|
|
if scaledRatio < smallestRatio {
|
|
smallestRatio = scaledRatio
|
|
file = startIter.Take()
|
|
}
|
|
}
|
|
return file, file.FileMetadata != nil
|
|
}
|
|
|
|
// pickAuto picks the best compaction, if any.
|
|
//
|
|
// On each call, pickAuto computes per-level size adjustments based on
|
|
// in-progress compactions, and computes a per-level score. The levels are
|
|
// iterated over in decreasing score order trying to find a valid compaction
|
|
// anchored at that level.
|
|
//
|
|
// If a score-based compaction cannot be found, pickAuto falls back to looking
|
|
// for an elision-only compaction to remove obsolete keys.
|
|
func (p *compactionPickerByScore) pickAuto(env compactionEnv) (pc *pickedCompaction) {
|
|
// Compaction concurrency is controlled by L0 read-amp. We allow one
|
|
// additional compaction per L0CompactionConcurrency sublevels, as well as
|
|
// one additional compaction per CompactionDebtConcurrency bytes of
|
|
// compaction debt. Compaction concurrency is tied to L0 sublevels as that
|
|
// signal is independent of the database size. We tack on the compaction
|
|
// debt as a second signal to prevent compaction concurrency from dropping
|
|
// significantly right after a base compaction finishes, and before those
|
|
// bytes have been compacted further down the LSM.
|
|
if n := len(env.inProgressCompactions); n > 0 {
|
|
l0ReadAmp := p.vers.L0Sublevels.MaxDepthAfterOngoingCompactions()
|
|
compactionDebt := p.estimatedCompactionDebt(0)
|
|
ccSignal1 := n * p.opts.Experimental.L0CompactionConcurrency
|
|
ccSignal2 := uint64(n) * p.opts.Experimental.CompactionDebtConcurrency
|
|
if l0ReadAmp < ccSignal1 && compactionDebt < ccSignal2 {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
scores := p.calculateLevelScores(env.inProgressCompactions)
|
|
|
|
// TODO(bananabrick): Either remove, or change this into an event sent to the
|
|
// EventListener.
|
|
logCompaction := func(pc *pickedCompaction) {
|
|
var buf bytes.Buffer
|
|
for i := 0; i < numLevels; i++ {
|
|
if i != 0 && i < p.baseLevel {
|
|
continue
|
|
}
|
|
|
|
var info *candidateLevelInfo
|
|
for j := range scores {
|
|
if scores[j].level == i {
|
|
info = &scores[j]
|
|
break
|
|
}
|
|
}
|
|
|
|
marker := " "
|
|
if pc.startLevel.level == info.level {
|
|
marker = "*"
|
|
}
|
|
fmt.Fprintf(&buf, " %sL%d: %5.1f %5.1f %5.1f %5.1f %8s %8s",
|
|
marker, info.level, info.compensatedScoreRatio, info.compensatedScore,
|
|
info.uncompensatedScoreRatio, info.uncompensatedScore,
|
|
humanize.Bytes.Int64(int64(totalCompensatedSize(
|
|
p.vers.Levels[info.level].Iter(),
|
|
))),
|
|
humanize.Bytes.Int64(p.levelMaxBytes[info.level]),
|
|
)
|
|
|
|
count := 0
|
|
for i := range env.inProgressCompactions {
|
|
c := &env.inProgressCompactions[i]
|
|
if c.inputs[0].level != info.level {
|
|
continue
|
|
}
|
|
count++
|
|
if count == 1 {
|
|
fmt.Fprintf(&buf, " [")
|
|
} else {
|
|
fmt.Fprintf(&buf, " ")
|
|
}
|
|
fmt.Fprintf(&buf, "L%d->L%d", c.inputs[0].level, c.outputLevel)
|
|
}
|
|
if count > 0 {
|
|
fmt.Fprintf(&buf, "]")
|
|
}
|
|
fmt.Fprintf(&buf, "\n")
|
|
}
|
|
p.opts.Logger.Infof("pickAuto: L%d->L%d\n%s",
|
|
pc.startLevel.level, pc.outputLevel.level, buf.String())
|
|
}
|
|
|
|
// Check for a score-based compaction. candidateLevelInfos are first sorted
|
|
// by whether they should be compacted, so if we find a level which shouldn't
|
|
// be compacted, we can break early.
|
|
for i := range scores {
|
|
info := &scores[i]
|
|
if !info.shouldCompact() {
|
|
break
|
|
}
|
|
if info.level == numLevels-1 {
|
|
continue
|
|
}
|
|
|
|
if info.level == 0 {
|
|
pc = pickL0(env, p.opts, p.vers, p.baseLevel)
|
|
// Fail-safe to protect against compacting the same sstable
|
|
// concurrently.
|
|
if pc != nil && !inputRangeAlreadyCompacting(env, pc) {
|
|
p.addScoresToPickedCompactionMetrics(pc, scores)
|
|
pc.score = info.compensatedScoreRatio
|
|
// TODO(bananabrick): Create an EventListener for logCompaction.
|
|
if false {
|
|
logCompaction(pc)
|
|
}
|
|
return pc
|
|
}
|
|
continue
|
|
}
|
|
|
|
// info.level > 0
|
|
var ok bool
|
|
info.file, ok = pickCompactionSeedFile(p.vers, p.opts, info.level, info.outputLevel, env.earliestSnapshotSeqNum)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
pc := pickAutoLPositive(env, p.opts, p.vers, *info, p.baseLevel, p.levelMaxBytes)
|
|
// Fail-safe to protect against compacting the same sstable concurrently.
|
|
if pc != nil && !inputRangeAlreadyCompacting(env, pc) {
|
|
p.addScoresToPickedCompactionMetrics(pc, scores)
|
|
pc.score = info.compensatedScoreRatio
|
|
// TODO(bananabrick): Create an EventListener for logCompaction.
|
|
if false {
|
|
logCompaction(pc)
|
|
}
|
|
return pc
|
|
}
|
|
}
|
|
|
|
// Check for L6 files with tombstones that may be elided. These files may
|
|
// exist if a snapshot prevented the elision of a tombstone or because of
|
|
// a move compaction. These are low-priority compactions because they
|
|
// don't help us keep up with writes, just reclaim disk space.
|
|
if pc := p.pickElisionOnlyCompaction(env); pc != nil {
|
|
return pc
|
|
}
|
|
|
|
if pc := p.pickReadTriggeredCompaction(env); pc != nil {
|
|
return pc
|
|
}
|
|
|
|
// NB: This should only be run if a read compaction wasn't
|
|
// scheduled.
|
|
//
|
|
// We won't be scheduling a read compaction right now, and in
|
|
// read heavy workloads, compactions won't be scheduled frequently
|
|
// because flushes aren't frequent. So we need to signal to the
|
|
// iterator to schedule a compaction when it adds compactions to
|
|
// the read compaction queue.
|
|
//
|
|
// We need the nil check here because without it, we have some
|
|
// tests which don't set that variable fail. Since there's a
|
|
// chance that one of those tests wouldn't want extra compactions
|
|
// to be scheduled, I added this check here, instead of
|
|
// setting rescheduleReadCompaction in those tests.
|
|
if env.readCompactionEnv.rescheduleReadCompaction != nil {
|
|
*env.readCompactionEnv.rescheduleReadCompaction = true
|
|
}
|
|
|
|
// At the lowest possible compaction-picking priority, look for files marked
|
|
// for compaction. Pebble will mark files for compaction if they have atomic
|
|
// compaction units that span multiple files. While current Pebble code does
|
|
// not construct such sstables, RocksDB and earlier versions of Pebble may
|
|
// have created them. These split user keys form sets of files that must be
|
|
// compacted together for correctness (referred to as "atomic compaction
|
|
// units" within the code). Rewrite them in-place.
|
|
//
|
|
// It's also possible that a file may have been marked for compaction by
|
|
// even earlier versions of Pebble code, since FileMetadata's
|
|
// MarkedForCompaction field is persisted in the manifest. That's okay. We
|
|
// previously would've ignored the designation, whereas now we'll re-compact
|
|
// the file in place.
|
|
if p.vers.Stats.MarkedForCompaction > 0 {
|
|
if pc := p.pickRewriteCompaction(env); pc != nil {
|
|
return pc
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *compactionPickerByScore) addScoresToPickedCompactionMetrics(
|
|
pc *pickedCompaction, candInfo [numLevels]candidateLevelInfo,
|
|
) {
|
|
|
|
// candInfo is sorted by score, not by compaction level.
|
|
infoByLevel := [numLevels]candidateLevelInfo{}
|
|
for i := range candInfo {
|
|
level := candInfo[i].level
|
|
infoByLevel[level] = candInfo[i]
|
|
}
|
|
// Gather the compaction scores for the levels participating in the compaction.
|
|
pc.pickerMetrics.scores = make([]float64, len(pc.inputs))
|
|
inputIdx := 0
|
|
for i := range infoByLevel {
|
|
if pc.inputs[inputIdx].level == infoByLevel[i].level {
|
|
pc.pickerMetrics.scores[inputIdx] = infoByLevel[i].compensatedScoreRatio
|
|
inputIdx++
|
|
}
|
|
if inputIdx == len(pc.inputs) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// elisionOnlyAnnotator implements the manifest.Annotator interface,
|
|
// annotating B-Tree nodes with the *fileMetadata of a file meeting the
|
|
// obsolete keys criteria for an elision-only compaction within the subtree.
|
|
// If multiple files meet the criteria, it chooses whichever file has the
|
|
// lowest LargestSeqNum. The lowest LargestSeqNum file will be the first
|
|
// eligible for an elision-only compaction once snapshots less than or equal
|
|
// to its LargestSeqNum are closed.
|
|
type elisionOnlyAnnotator struct{}
|
|
|
|
var _ manifest.Annotator = elisionOnlyAnnotator{}
|
|
|
|
func (a elisionOnlyAnnotator) Zero(interface{}) interface{} {
|
|
return nil
|
|
}
|
|
|
|
func (a elisionOnlyAnnotator) Accumulate(f *fileMetadata, dst interface{}) (interface{}, bool) {
|
|
if f.IsCompacting() {
|
|
return dst, true
|
|
}
|
|
if !f.StatsValid() {
|
|
return dst, false
|
|
}
|
|
// Bottommost files are large and not worthwhile to compact just
|
|
// to remove a few tombstones. Consider a file ineligible if its
|
|
// own range deletions delete less than 10% of its data and its
|
|
// deletion tombstones make up less than 10% of its entries.
|
|
//
|
|
// TODO(jackson): This does not account for duplicate user keys
|
|
// which may be collapsed. Ideally, we would have 'obsolete keys'
|
|
// statistics that would include tombstones, the keys that are
|
|
// dropped by tombstones and duplicated user keys. See #847.
|
|
//
|
|
// Note that tables that contain exclusively range keys (i.e. no point keys,
|
|
// `NumEntries` and `RangeDeletionsBytesEstimate` are both zero) are excluded
|
|
// from elision-only compactions.
|
|
// TODO(travers): Consider an alternative heuristic for elision of range-keys.
|
|
if f.Stats.RangeDeletionsBytesEstimate*10 < f.Size &&
|
|
f.Stats.NumDeletions*10 <= f.Stats.NumEntries {
|
|
return dst, true
|
|
}
|
|
if dst == nil {
|
|
return f, true
|
|
} else if dstV := dst.(*fileMetadata); dstV.LargestSeqNum > f.LargestSeqNum {
|
|
return f, true
|
|
}
|
|
return dst, true
|
|
}
|
|
|
|
func (a elisionOnlyAnnotator) Merge(v interface{}, accum interface{}) interface{} {
|
|
if v == nil {
|
|
return accum
|
|
}
|
|
// If we haven't accumulated an eligible file yet, or f's LargestSeqNum is
|
|
// less than the accumulated file's, use f.
|
|
if accum == nil {
|
|
return v
|
|
}
|
|
f := v.(*fileMetadata)
|
|
accumV := accum.(*fileMetadata)
|
|
if accumV == nil || accumV.LargestSeqNum > f.LargestSeqNum {
|
|
return f
|
|
}
|
|
return accumV
|
|
}
|
|
|
|
// markedForCompactionAnnotator implements the manifest.Annotator interface,
|
|
// annotating B-Tree nodes with the *fileMetadata of a file that is marked for
|
|
// compaction within the subtree. If multiple files meet the criteria, it
|
|
// chooses whichever file has the lowest LargestSeqNum.
|
|
type markedForCompactionAnnotator struct{}
|
|
|
|
var _ manifest.Annotator = markedForCompactionAnnotator{}
|
|
|
|
func (a markedForCompactionAnnotator) Zero(interface{}) interface{} {
|
|
return nil
|
|
}
|
|
|
|
func (a markedForCompactionAnnotator) Accumulate(
|
|
f *fileMetadata, dst interface{},
|
|
) (interface{}, bool) {
|
|
if !f.MarkedForCompaction {
|
|
// Not marked for compaction; return dst.
|
|
return dst, true
|
|
}
|
|
return markedMergeHelper(f, dst)
|
|
}
|
|
|
|
func (a markedForCompactionAnnotator) Merge(v interface{}, accum interface{}) interface{} {
|
|
if v == nil {
|
|
return accum
|
|
}
|
|
accum, _ = markedMergeHelper(v.(*fileMetadata), accum)
|
|
return accum
|
|
}
|
|
|
|
// REQUIRES: f is non-nil, and f.MarkedForCompaction=true.
|
|
func markedMergeHelper(f *fileMetadata, dst interface{}) (interface{}, bool) {
|
|
if dst == nil {
|
|
return f, true
|
|
} else if dstV := dst.(*fileMetadata); dstV.LargestSeqNum > f.LargestSeqNum {
|
|
return f, true
|
|
}
|
|
return dst, true
|
|
}
|
|
|
|
// pickElisionOnlyCompaction looks for compactions of sstables in the
|
|
// bottommost level containing obsolete records that may now be dropped.
|
|
func (p *compactionPickerByScore) pickElisionOnlyCompaction(
|
|
env compactionEnv,
|
|
) (pc *pickedCompaction) {
|
|
if p.opts.private.disableElisionOnlyCompactions {
|
|
return nil
|
|
}
|
|
v := p.vers.Levels[numLevels-1].Annotation(elisionOnlyAnnotator{})
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
candidate := v.(*fileMetadata)
|
|
if candidate.IsCompacting() || candidate.LargestSeqNum >= env.earliestSnapshotSeqNum {
|
|
return nil
|
|
}
|
|
lf := p.vers.Levels[numLevels-1].Find(p.opts.Comparer.Compare, candidate)
|
|
if lf == nil {
|
|
panic(fmt.Sprintf("file %s not found in level %d as expected", candidate.FileNum, numLevels-1))
|
|
}
|
|
|
|
// Construct a picked compaction of the elision candidate's atomic
|
|
// compaction unit.
|
|
pc = newPickedCompaction(p.opts, p.vers, numLevels-1, numLevels-1, p.baseLevel)
|
|
pc.kind = compactionKindElisionOnly
|
|
var isCompacting bool
|
|
pc.startLevel.files, isCompacting = expandToAtomicUnit(p.opts.Comparer.Compare, lf.Slice(), false /* disableIsCompacting */)
|
|
if isCompacting {
|
|
return nil
|
|
}
|
|
pc.smallest, pc.largest = manifest.KeyRange(pc.cmp, pc.startLevel.files.Iter())
|
|
// Fail-safe to protect against compacting the same sstable concurrently.
|
|
if !inputRangeAlreadyCompacting(env, pc) {
|
|
return pc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pickRewriteCompaction attempts to construct a compaction that
|
|
// rewrites a file marked for compaction. pickRewriteCompaction will
|
|
// pull in adjacent files in the file's atomic compaction unit if
|
|
// necessary. A rewrite compaction outputs files to the same level as
|
|
// the input level.
|
|
func (p *compactionPickerByScore) pickRewriteCompaction(env compactionEnv) (pc *pickedCompaction) {
|
|
for l := numLevels - 1; l >= 0; l-- {
|
|
v := p.vers.Levels[l].Annotation(markedForCompactionAnnotator{})
|
|
if v == nil {
|
|
// Try the next level.
|
|
continue
|
|
}
|
|
candidate := v.(*fileMetadata)
|
|
if candidate.IsCompacting() {
|
|
// Try the next level.
|
|
continue
|
|
}
|
|
lf := p.vers.Levels[l].Find(p.opts.Comparer.Compare, candidate)
|
|
if lf == nil {
|
|
panic(fmt.Sprintf("file %s not found in level %d as expected", candidate.FileNum, numLevels-1))
|
|
}
|
|
|
|
inputs := lf.Slice()
|
|
// L0 files generated by a flush have never been split such that
|
|
// adjacent files can contain the same user key. So we do not need to
|
|
// rewrite an atomic compaction unit for L0. Note that there is nothing
|
|
// preventing two different flushes from producing files that are
|
|
// non-overlapping from an InternalKey perspective, but span the same
|
|
// user key. However, such files cannot be in the same L0 sublevel,
|
|
// since each sublevel requires non-overlapping user keys (unlike other
|
|
// levels).
|
|
if l > 0 {
|
|
// Find this file's atomic compaction unit. This is only relevant
|
|
// for levels L1+.
|
|
var isCompacting bool
|
|
inputs, isCompacting = expandToAtomicUnit(
|
|
p.opts.Comparer.Compare,
|
|
inputs,
|
|
false, /* disableIsCompacting */
|
|
)
|
|
if isCompacting {
|
|
// Try the next level.
|
|
continue
|
|
}
|
|
}
|
|
|
|
pc = newPickedCompaction(p.opts, p.vers, l, l, p.baseLevel)
|
|
pc.outputLevel.level = l
|
|
pc.kind = compactionKindRewrite
|
|
pc.startLevel.files = inputs
|
|
pc.smallest, pc.largest = manifest.KeyRange(pc.cmp, pc.startLevel.files.Iter())
|
|
|
|
// Fail-safe to protect against compacting the same sstable concurrently.
|
|
if !inputRangeAlreadyCompacting(env, pc) {
|
|
if pc.startLevel.level == 0 {
|
|
pc.startLevel.l0SublevelInfo = generateSublevelInfo(pc.cmp, pc.startLevel.files)
|
|
}
|
|
return pc
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pickAutoLPositive picks an automatic compaction for the candidate
|
|
// file in a positive-numbered level. This function must not be used for
|
|
// L0.
|
|
func pickAutoLPositive(
|
|
env compactionEnv,
|
|
opts *Options,
|
|
vers *version,
|
|
cInfo candidateLevelInfo,
|
|
baseLevel int,
|
|
levelMaxBytes [numLevels]int64,
|
|
) (pc *pickedCompaction) {
|
|
if cInfo.level == 0 {
|
|
panic("pebble: pickAutoLPositive called for L0")
|
|
}
|
|
|
|
pc = newPickedCompaction(opts, vers, cInfo.level, defaultOutputLevel(cInfo.level, baseLevel), baseLevel)
|
|
if pc.outputLevel.level != cInfo.outputLevel {
|
|
panic("pebble: compaction picked unexpected output level")
|
|
}
|
|
pc.startLevel.files = cInfo.file.Slice()
|
|
// Files in level 0 may overlap each other, so pick up all overlapping ones.
|
|
if pc.startLevel.level == 0 {
|
|
cmp := opts.Comparer.Compare
|
|
smallest, largest := manifest.KeyRange(cmp, pc.startLevel.files.Iter())
|
|
pc.startLevel.files = vers.Overlaps(0, cmp, smallest.UserKey,
|
|
largest.UserKey, largest.IsExclusiveSentinel())
|
|
if pc.startLevel.files.Empty() {
|
|
panic("pebble: empty compaction")
|
|
}
|
|
}
|
|
|
|
if !pc.setupInputs(opts, env.diskAvailBytes, pc.startLevel) {
|
|
return nil
|
|
}
|
|
return pc.maybeAddLevel(opts, env.diskAvailBytes)
|
|
}
|
|
|
|
// maybeAddLevel maybe adds a level to the picked compaction.
|
|
func (pc *pickedCompaction) maybeAddLevel(opts *Options, diskAvailBytes uint64) *pickedCompaction {
|
|
pc.pickerMetrics.singleLevelOverlappingRatio = pc.overlappingRatio()
|
|
if pc.outputLevel.level == numLevels-1 {
|
|
// Don't add a level if the current output level is in L6
|
|
return pc
|
|
}
|
|
if !opts.Experimental.MultiLevelCompactionHeuristic.allowL0() && pc.startLevel.level == 0 {
|
|
return pc
|
|
}
|
|
if pc.compactionSize() > expandedCompactionByteSizeLimit(
|
|
opts, adjustedOutputLevel(pc.outputLevel.level, pc.baseLevel), diskAvailBytes) {
|
|
// Don't add a level if the current compaction exceeds the compaction size limit
|
|
return pc
|
|
}
|
|
return opts.Experimental.MultiLevelCompactionHeuristic.pick(pc, opts, diskAvailBytes)
|
|
}
|
|
|
|
// MultiLevelHeuristic evaluates whether to add files from the next level into the compaction.
|
|
type MultiLevelHeuristic interface {
|
|
// Evaluate returns the preferred compaction.
|
|
pick(pc *pickedCompaction, opts *Options, diskAvailBytes uint64) *pickedCompaction
|
|
|
|
// Returns if the heuristic allows L0 to be involved in ML compaction
|
|
allowL0() bool
|
|
}
|
|
|
|
// NoMultiLevel will never add an additional level to the compaction.
|
|
type NoMultiLevel struct{}
|
|
|
|
var _ MultiLevelHeuristic = (*NoMultiLevel)(nil)
|
|
|
|
func (nml NoMultiLevel) pick(
|
|
pc *pickedCompaction, opts *Options, diskAvailBytes uint64,
|
|
) *pickedCompaction {
|
|
return pc
|
|
}
|
|
|
|
func (nml NoMultiLevel) allowL0() bool {
|
|
return false
|
|
}
|
|
|
|
func (pc *pickedCompaction) predictedWriteAmp() float64 {
|
|
var bytesToCompact uint64
|
|
var higherLevelBytes uint64
|
|
for i := range pc.inputs {
|
|
levelSize := pc.inputs[i].files.SizeSum()
|
|
bytesToCompact += levelSize
|
|
if i != len(pc.inputs)-1 {
|
|
higherLevelBytes += levelSize
|
|
}
|
|
}
|
|
return float64(bytesToCompact) / float64(higherLevelBytes)
|
|
}
|
|
|
|
func (pc *pickedCompaction) overlappingRatio() float64 {
|
|
var higherLevelBytes uint64
|
|
var lowestLevelBytes uint64
|
|
for i := range pc.inputs {
|
|
levelSize := pc.inputs[i].files.SizeSum()
|
|
if i == len(pc.inputs)-1 {
|
|
lowestLevelBytes += levelSize
|
|
continue
|
|
}
|
|
higherLevelBytes += levelSize
|
|
}
|
|
return float64(lowestLevelBytes) / float64(higherLevelBytes)
|
|
}
|
|
|
|
// WriteAmpHeuristic defines a multi level compaction heuristic which will add
|
|
// an additional level to the picked compaction if it reduces predicted write
|
|
// amp of the compaction + the addPropensity constant.
|
|
type WriteAmpHeuristic struct {
|
|
// addPropensity is a constant that affects the propensity to conduct multilevel
|
|
// compactions. If positive, a multilevel compaction may get picked even if
|
|
// the single level compaction has lower write amp, and vice versa.
|
|
AddPropensity float64
|
|
|
|
// AllowL0 if true, allow l0 to be involved in a ML compaction.
|
|
AllowL0 bool
|
|
}
|
|
|
|
var _ MultiLevelHeuristic = (*WriteAmpHeuristic)(nil)
|
|
|
|
// TODO(msbutler): microbenchmark the extent to which multilevel compaction
|
|
// picking slows down the compaction picking process. This should be as fast as
|
|
// possible since Compaction-picking holds d.mu, which prevents WAL rotations,
|
|
// in-progress flushes and compactions from completing, etc. Consider ways to
|
|
// deduplicate work, given that setupInputs has already been called.
|
|
func (wa WriteAmpHeuristic) pick(
|
|
pcOrig *pickedCompaction, opts *Options, diskAvailBytes uint64,
|
|
) *pickedCompaction {
|
|
pcMulti := pcOrig.clone()
|
|
if !pcMulti.setupMultiLevelCandidate(opts, diskAvailBytes) {
|
|
return pcOrig
|
|
}
|
|
picked := pcOrig
|
|
if pcMulti.predictedWriteAmp() <= pcOrig.predictedWriteAmp()+wa.AddPropensity {
|
|
picked = pcMulti
|
|
}
|
|
// Regardless of what compaction was picked, log the multilevelOverlapping ratio.
|
|
picked.pickerMetrics.multiLevelOverlappingRatio = pcMulti.overlappingRatio()
|
|
return picked
|
|
}
|
|
|
|
func (wa WriteAmpHeuristic) allowL0() bool {
|
|
return wa.AllowL0
|
|
}
|
|
|
|
// Helper method to pick compactions originating from L0. Uses information about
|
|
// sublevels to generate a compaction.
|
|
func pickL0(env compactionEnv, opts *Options, vers *version, baseLevel int) (pc *pickedCompaction) {
|
|
// It is important to pass information about Lbase files to L0Sublevels
|
|
// so it can pick a compaction that does not conflict with an Lbase => Lbase+1
|
|
// compaction. Without this, we observed reduced concurrency of L0=>Lbase
|
|
// compactions, and increasing read amplification in L0.
|
|
//
|
|
// TODO(bilal) Remove the minCompactionDepth parameter once fixing it at 1
|
|
// has been shown to not cause a performance regression.
|
|
lcf, err := vers.L0Sublevels.PickBaseCompaction(1, vers.Levels[baseLevel].Slice())
|
|
if err != nil {
|
|
opts.Logger.Errorf("error when picking base compaction: %s", err)
|
|
return
|
|
}
|
|
if lcf != nil {
|
|
pc = newPickedCompactionFromL0(lcf, opts, vers, baseLevel, true)
|
|
pc.setupInputs(opts, env.diskAvailBytes, pc.startLevel)
|
|
if pc.startLevel.files.Empty() {
|
|
opts.Logger.Fatalf("empty compaction chosen")
|
|
}
|
|
return pc.maybeAddLevel(opts, env.diskAvailBytes)
|
|
}
|
|
|
|
// Couldn't choose a base compaction. Try choosing an intra-L0
|
|
// compaction. Note that we pass in L0CompactionThreshold here as opposed to
|
|
// 1, since choosing a single sublevel intra-L0 compaction is
|
|
// counterproductive.
|
|
lcf, err = vers.L0Sublevels.PickIntraL0Compaction(env.earliestUnflushedSeqNum, minIntraL0Count)
|
|
if err != nil {
|
|
opts.Logger.Errorf("error when picking intra-L0 compaction: %s", err)
|
|
return
|
|
}
|
|
if lcf != nil {
|
|
pc = newPickedCompactionFromL0(lcf, opts, vers, 0, false)
|
|
if !pc.setupInputs(opts, env.diskAvailBytes, pc.startLevel) {
|
|
return nil
|
|
}
|
|
if pc.startLevel.files.Empty() {
|
|
opts.Logger.Fatalf("empty compaction chosen")
|
|
}
|
|
{
|
|
iter := pc.startLevel.files.Iter()
|
|
if iter.First() == nil || iter.Next() == nil {
|
|
// A single-file intra-L0 compaction is unproductive.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
pc.smallest, pc.largest = manifest.KeyRange(pc.cmp, pc.startLevel.files.Iter())
|
|
}
|
|
return pc
|
|
}
|
|
|
|
func pickManualCompaction(
|
|
vers *version, opts *Options, env compactionEnv, baseLevel int, manual *manualCompaction,
|
|
) (pc *pickedCompaction, retryLater bool) {
|
|
outputLevel := manual.level + 1
|
|
if manual.level == 0 {
|
|
outputLevel = baseLevel
|
|
} else if manual.level < baseLevel {
|
|
// The start level for a compaction must be >= Lbase. A manual
|
|
// compaction could have been created adhering to that condition, and
|
|
// then an automatic compaction came in and compacted all of the
|
|
// sstables in Lbase to Lbase+1 which caused Lbase to change. Simply
|
|
// ignore this manual compaction as there is nothing to do (manual.level
|
|
// points to an empty level).
|
|
return nil, false
|
|
}
|
|
// This conflictsWithInProgress call is necessary for the manual compaction to
|
|
// be retried when it conflicts with an ongoing automatic compaction. Without
|
|
// it, the compaction is dropped due to pc.setupInputs returning false since
|
|
// the input/output range is already being compacted, and the manual
|
|
// compaction ends with a non-compacted LSM.
|
|
if conflictsWithInProgress(manual, outputLevel, env.inProgressCompactions, opts.Comparer.Compare) {
|
|
return nil, true
|
|
}
|
|
pc = newPickedCompaction(opts, vers, manual.level, defaultOutputLevel(manual.level, baseLevel), baseLevel)
|
|
manual.outputLevel = pc.outputLevel.level
|
|
pc.startLevel.files = vers.Overlaps(manual.level, opts.Comparer.Compare, manual.start, manual.end, false)
|
|
if pc.startLevel.files.Empty() {
|
|
// Nothing to do
|
|
return nil, false
|
|
}
|
|
if !pc.setupInputs(opts, env.diskAvailBytes, pc.startLevel) {
|
|
// setupInputs returned false indicating there's a conflicting
|
|
// concurrent compaction.
|
|
return nil, true
|
|
}
|
|
if pc = pc.maybeAddLevel(opts, env.diskAvailBytes); pc == nil {
|
|
return nil, false
|
|
}
|
|
if pc.outputLevel.level != outputLevel {
|
|
if len(pc.extraLevels) > 0 {
|
|
// multilevel compactions relax this invariant
|
|
} else {
|
|
panic("pebble: compaction picked unexpected output level")
|
|
}
|
|
}
|
|
// Fail-safe to protect against compacting the same sstable concurrently.
|
|
if inputRangeAlreadyCompacting(env, pc) {
|
|
return nil, true
|
|
}
|
|
return pc, false
|
|
}
|
|
|
|
func (p *compactionPickerByScore) pickReadTriggeredCompaction(
|
|
env compactionEnv,
|
|
) (pc *pickedCompaction) {
|
|
// If a flush is in-progress or expected to happen soon, it means more writes are taking place. We would
|
|
// soon be scheduling more write focussed compactions. In this case, skip read compactions as they are
|
|
// lower priority.
|
|
if env.readCompactionEnv.flushing || env.readCompactionEnv.readCompactions == nil {
|
|
return nil
|
|
}
|
|
for env.readCompactionEnv.readCompactions.size > 0 {
|
|
rc := env.readCompactionEnv.readCompactions.remove()
|
|
if pc = pickReadTriggeredCompactionHelper(p, rc, env); pc != nil {
|
|
break
|
|
}
|
|
}
|
|
return pc
|
|
}
|
|
|
|
func pickReadTriggeredCompactionHelper(
|
|
p *compactionPickerByScore, rc *readCompaction, env compactionEnv,
|
|
) (pc *pickedCompaction) {
|
|
cmp := p.opts.Comparer.Compare
|
|
overlapSlice := p.vers.Overlaps(rc.level, cmp, rc.start, rc.end, false /* exclusiveEnd */)
|
|
if overlapSlice.Empty() {
|
|
// If there is no overlap, then the file with the key range
|
|
// must have been compacted away. So, we don't proceed to
|
|
// compact the same key range again.
|
|
return nil
|
|
}
|
|
|
|
iter := overlapSlice.Iter()
|
|
var fileMatches bool
|
|
for f := iter.First(); f != nil; f = iter.Next() {
|
|
if f.FileNum == rc.fileNum {
|
|
fileMatches = true
|
|
break
|
|
}
|
|
}
|
|
if !fileMatches {
|
|
return nil
|
|
}
|
|
|
|
pc = newPickedCompaction(p.opts, p.vers, rc.level, defaultOutputLevel(rc.level, p.baseLevel), p.baseLevel)
|
|
|
|
pc.startLevel.files = overlapSlice
|
|
if !pc.setupInputs(p.opts, env.diskAvailBytes, pc.startLevel) {
|
|
return nil
|
|
}
|
|
if inputRangeAlreadyCompacting(env, pc) {
|
|
return nil
|
|
}
|
|
pc.kind = compactionKindRead
|
|
|
|
// Prevent read compactions which are too wide.
|
|
outputOverlaps := pc.version.Overlaps(
|
|
pc.outputLevel.level, pc.cmp, pc.smallest.UserKey,
|
|
pc.largest.UserKey, pc.largest.IsExclusiveSentinel())
|
|
if outputOverlaps.SizeSum() > pc.maxReadCompactionBytes {
|
|
return nil
|
|
}
|
|
|
|
// Prevent compactions which start with a small seed file X, but overlap
|
|
// with over allowedCompactionWidth * X file sizes in the output layer.
|
|
const allowedCompactionWidth = 35
|
|
if outputOverlaps.SizeSum() > overlapSlice.SizeSum()*allowedCompactionWidth {
|
|
return nil
|
|
}
|
|
|
|
return pc
|
|
}
|
|
|
|
func (p *compactionPickerByScore) forceBaseLevel1() {
|
|
p.baseLevel = 1
|
|
}
|
|
|
|
func inputRangeAlreadyCompacting(env compactionEnv, pc *pickedCompaction) bool {
|
|
for _, cl := range pc.inputs {
|
|
iter := cl.files.Iter()
|
|
for f := iter.First(); f != nil; f = iter.Next() {
|
|
if f.IsCompacting() {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for active compactions outputting to the same region of the key
|
|
// space in the same output level. Two potential compactions may conflict
|
|
// without sharing input files if there are no files in the output level
|
|
// that overlap with the intersection of the compactions' key spaces.
|
|
//
|
|
// Consider an active L0->Lbase compaction compacting two L0 files one
|
|
// [a-f] and the other [t-z] into Lbase.
|
|
//
|
|
// L0
|
|
// ↦ 000100 ↤ ↦ 000101 ↤
|
|
// L1
|
|
// ↦ 000004 ↤
|
|
// a b c d e f g h i j k l m n o p q r s t u v w x y z
|
|
//
|
|
// If a new file 000102 [j-p] is flushed while the existing compaction is
|
|
// still ongoing, new file would not be in any compacting sublevel
|
|
// intervals and would not overlap with any Lbase files that are also
|
|
// compacting. However, this compaction cannot be picked because the
|
|
// compaction's output key space [j-p] would overlap the existing
|
|
// compaction's output key space [a-z].
|
|
//
|
|
// L0
|
|
// ↦ 000100* ↤ ↦ 000102 ↤ ↦ 000101* ↤
|
|
// L1
|
|
// ↦ 000004* ↤
|
|
// a b c d e f g h i j k l m n o p q r s t u v w x y z
|
|
//
|
|
// * - currently compacting
|
|
if pc.outputLevel != nil && pc.outputLevel.level != 0 {
|
|
for _, c := range env.inProgressCompactions {
|
|
if pc.outputLevel.level != c.outputLevel {
|
|
continue
|
|
}
|
|
if base.InternalCompare(pc.cmp, c.largest, pc.smallest) < 0 ||
|
|
base.InternalCompare(pc.cmp, c.smallest, pc.largest) > 0 {
|
|
continue
|
|
}
|
|
|
|
// The picked compaction and the in-progress compaction c are
|
|
// outputting to the same region of the key space of the same
|
|
// level.
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// conflictsWithInProgress checks if there are any in-progress compactions with overlapping keyspace.
|
|
func conflictsWithInProgress(
|
|
manual *manualCompaction, outputLevel int, inProgressCompactions []compactionInfo, cmp Compare,
|
|
) bool {
|
|
for _, c := range inProgressCompactions {
|
|
if (c.outputLevel == manual.level || c.outputLevel == outputLevel) &&
|
|
isUserKeysOverlapping(manual.start, manual.end, c.smallest.UserKey, c.largest.UserKey, cmp) {
|
|
return true
|
|
}
|
|
for _, in := range c.inputs {
|
|
if in.files.Empty() {
|
|
continue
|
|
}
|
|
iter := in.files.Iter()
|
|
smallest := iter.First().Smallest.UserKey
|
|
largest := iter.Last().Largest.UserKey
|
|
if (in.level == manual.level || in.level == outputLevel) &&
|
|
isUserKeysOverlapping(manual.start, manual.end, smallest, largest, cmp) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isUserKeysOverlapping(x1, x2, y1, y2 []byte, cmp Compare) bool {
|
|
return cmp(x1, y2) <= 0 && cmp(y1, x2) <= 0
|
|
}
|