Files
upstream-bleve/index/scorch/scorch.go
upstream-mirror f8d3d9f0cf
Some checks failed
Coverage / coverage (push) Failing after 6s
Tests / test (1.24.x, ubuntu-latest) (push) Failing after 42s
Tests / test (1.26.x, ubuntu-latest) (push) Failing after 38s
Tests / test (1.25.x, ubuntu-latest) (push) Failing after 35s
Tests / test (1.25.x, windows-latest) (push) Has been cancelled
Tests / test (1.26.x, macos-latest) (push) Has been cancelled
Tests / test (1.26.x, windows-latest) (push) Has been cancelled
Tests / test (1.24.x, macos-latest) (push) Has been cancelled
Tests / test (1.24.x, windows-latest) (push) Has been cancelled
Tests / test (1.25.x, macos-latest) (push) Has been cancelled
snapshot of https://github.com/blevesearch/bleve.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 22:27:16 +05:00

1529 lines
42 KiB
Go

// Copyright (c) 2018 Couchbase, Inc.
//
// 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 scorch
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/RoaringBitmap/roaring/v2"
"github.com/blevesearch/bleve/v2/index/scorch/mergeplan"
"github.com/blevesearch/bleve/v2/registry"
"github.com/blevesearch/bleve/v2/util"
index "github.com/blevesearch/bleve_index_api"
segment "github.com/blevesearch/scorch_segment_api/v2"
bolt "go.etcd.io/bbolt"
)
const Name = "scorch"
const Version uint8 = 2
var ErrClosed = fmt.Errorf("scorch closed")
type Scorch struct {
nextSegmentID uint64
stats Stats
zapStats segment.Stats
iStats internalStats
readOnly bool
version uint8
config map[string]interface{}
segmentConfig map[string]interface{}
analysisQueue *index.AnalysisQueue
path string
unsafeBatch bool
rootLock sync.RWMutex
root *IndexSnapshot // holds 1 ref-count on the root
rootPersisted []chan error // closed when root is persisted
persistedCallbacks []index.BatchCallback
nextSnapshotEpoch uint64
eligibleForRemoval []uint64 // Index snapshot epochs that are safe to GC.
ineligibleForRemoval map[string]bool // Filenames that should not be GC'ed yet.
// keeps track of segments scheduled for online copy/backup operation. Each segment's filename maps to
// the count of copy schedules. Segments with non-zero counts are protected from removal by the cleanup
// operation. Counts decrement upon successful copy, allowing removal of segments with zero or absent counts.
// must be accessed within the rootLock as it is accessed by the asynchronous cleanup routine.
copyScheduled map[string]int
persisterOptions *persisterOptions
mergePlannerOptions *mergeplan.MergePlanOptions
numSnapshotsToKeep int
rollbackRetentionFactor float64
checkPoints []*snapshotMetaData
rollbackSamplingInterval time.Duration
closeCh chan struct{}
introductions chan *segmentIntroduction
persists chan *persistIntroduction
merges chan *segmentMerge
introducerNotifier chan *epochWatcher
persisterNotifier chan *epochWatcher
rootBolt *util.RootBoltImpl
asyncTasks sync.WaitGroup
trainer trainer
onEvent func(event Event) bool
onAsyncError func(err error, path string)
forceMergeRequestCh chan *mergerCtrl
segPlugin SegmentPlugin
spatialPlugin index.SpatialAnalyzerPlugin
}
// trainer interface is used for training an index that has the concept
// of "learning". Naturally, a vector index is one such thing that would
// implement this interface. There can be multiple implementations of the
// training itself even for the same index type.
//
// this component is not supposed to interact with the other master routines
// of scorch and will be used only for training the index before the actual data
// ingestion starts. The routine should also be released once the
// training is marked as complete - which can be done using the BoltTrainCompleteKey
// key and a bool value. However the struct is still maintained for the pointer to
// the instance so that we can use in the later stages of the index lifecycle.
type trainer interface {
// ephemeral
trainLoop()
// for the training state and the ingestion of the samples
train(batch *index.Batch) error
// to load the metadata from the bolt under the BoltTrainerKey
loadTrainedData(*util.BoltBucketImpl) error
// to fetch the internal data from the component
getInternal(key []byte) ([]byte, error)
// trainer specific file transfer operations
copyFileLOCKED(file string, d index.IndexDirectory) error
updateBolt(snapshotsBucket *util.BoltBucketImpl, key []byte, value []byte) error
dropFileWriterIDs(ids map[string]struct{}) error
fileWriterIDsInUse() (map[string]struct{}, error)
}
type ScorchErrorType string
func (t ScorchErrorType) Error() string {
return string(t)
}
// ErrType values for ScorchError
const (
ErrAsyncPanic = ScorchErrorType("async panic error")
ErrPersist = ScorchErrorType("persist error")
ErrCleanup = ScorchErrorType("cleanup error")
)
// ScorchError is passed to onAsyncError when errors are
// fired from scorch background processes
type ScorchError struct {
Source string
ErrMsg string
ErrType ScorchErrorType
}
func (e *ScorchError) Error() string {
return fmt.Sprintf("source: %s, %v: %s", e.Source, e.ErrType, e.ErrMsg)
}
// Lets the onAsyncError function verify what type of
// error is fired using errors.Is(...). This lets the function
// handle errors differently.
func (e *ScorchError) Unwrap() error {
return e.ErrType
}
func NewScorchError(source, errMsg string, errType ScorchErrorType) error {
return &ScorchError{
Source: source,
ErrMsg: errMsg,
ErrType: errType,
}
}
type internalStats struct {
persistEpoch uint64
persistSnapshotSize uint64
mergeEpoch uint64
mergeSnapshotSize uint64
newSegBufBytesAdded uint64
newSegBufBytesRemoved uint64
analysisBytesAdded uint64
analysisBytesRemoved uint64
}
func NewScorch(storeName string,
config map[string]interface{},
analysisQueue *index.AnalysisQueue,
) (index.Index, error) {
rv := &Scorch{
version: Version,
config: config,
analysisQueue: analysisQueue,
nextSnapshotEpoch: 1,
closeCh: make(chan struct{}),
ineligibleForRemoval: map[string]bool{},
forceMergeRequestCh: make(chan *mergerCtrl, 1),
segPlugin: defaultSegmentPlugin,
copyScheduled: map[string]int{},
segmentConfig: make(map[string]interface{}),
}
forcedSegmentType, forcedSegmentVersion, err := configForceSegmentTypeVersion(config)
if err != nil {
return nil, err
}
if forcedSegmentType != "" && forcedSegmentVersion != 0 {
err := rv.loadSegmentPlugin(forcedSegmentType,
uint32(forcedSegmentVersion))
if err != nil {
return nil, err
}
}
segConfig, ok := config["segmentConfig"].(map[string]interface{})
if ok {
rv.segmentConfig = segConfig
}
// NOTE: always register the stats handler in the config map to track zap layer stats
rv.segmentConfig[segment.StatsKey] = &rv.zapStats
typ, ok := config["spatialPlugin"].(string)
if ok {
if err := rv.loadSpatialAnalyzerPlugin(typ); err != nil {
return nil, err
}
}
rv.root = &IndexSnapshot{parent: rv, refs: 1, creator: "NewScorch"}
ro, ok := config["read_only"].(bool)
if ok {
rv.readOnly = ro
}
ub, ok := config["unsafe_batch"].(bool)
if ok {
rv.unsafeBatch = ub
}
ecbName, ok := config["eventCallbackName"].(string)
if ok {
rv.onEvent = RegistryEventCallbacks[ecbName]
}
aecbName, ok := config["asyncErrorCallbackName"].(string)
if ok {
rv.onAsyncError = RegistryAsyncErrorCallbacks[aecbName]
}
rv.numSnapshotsToKeep = NumSnapshotsToKeep
if v, ok := rv.config["numSnapshotsToKeep"]; ok {
var n int
var err error
if n, err = parseToInteger(v); err != nil {
return nil, fmt.Errorf("numSnapshotsToKeep parse err: %v", err)
}
if n > 0 {
rv.numSnapshotsToKeep = n
}
}
rv.rollbackSamplingInterval = RollbackSamplingInterval
if v, ok := rv.config["rollbackSamplingInterval"]; ok {
var d time.Duration
var err error
if d, err = parseToTimeDuration(v); err != nil {
return nil, fmt.Errorf("rollbackSamplingInterval parse err: %v", err)
}
rv.rollbackSamplingInterval = d
}
rv.rollbackRetentionFactor = RollbackRetentionFactor
if v, ok := rv.config["rollbackRetentionFactor"]; ok {
var r float64
var err error
if r, err = parseToFloat(v); err != nil {
return nil, fmt.Errorf("rollbackRetentionFactor parse err: %v", err)
}
if r < 0 || r > 1 {
return nil, fmt.Errorf("rollbackRetentionFactor must be between 0 and 1")
}
rv.rollbackRetentionFactor = r
}
po, err := rv.parsePersisterOptions()
if err != nil {
return nil, err
}
rv.persisterOptions = po
mpo, err := rv.parseMergePlannerOptions(po)
if err != nil {
return nil, err
}
rv.mergePlannerOptions = mpo
if trainer := initTrainer(rv, config); trainer != nil {
rv.trainer = trainer
}
return rv, nil
}
// configForceSegmentTypeVersion checks if the caller has requested a
// specific segment type/version
func configForceSegmentTypeVersion(config map[string]interface{}) (string, uint32, error) {
forcedSegmentVersion, err := parseToInteger(config["forceSegmentVersion"])
if err != nil {
return "", 0, nil
}
forcedSegmentType, ok := config["forceSegmentType"].(string)
if !ok {
return "", 0, fmt.Errorf(
"forceSegmentVersion set to %d, must also specify forceSegmentType", forcedSegmentVersion)
}
return forcedSegmentType, uint32(forcedSegmentVersion), nil
}
func (s *Scorch) NumEventsBlocking() uint64 {
eventsCompleted := atomic.LoadUint64(&s.stats.TotEventTriggerCompleted)
eventsStarted := atomic.LoadUint64(&s.stats.TotEventTriggerStarted)
return eventsStarted - eventsCompleted
}
func (s *Scorch) fireEvent(kind EventKind, dur time.Duration) bool {
res := true
if s.onEvent != nil {
atomic.AddUint64(&s.stats.TotEventTriggerStarted, 1)
res = s.onEvent(Event{Kind: kind, Scorch: s, Duration: dur})
atomic.AddUint64(&s.stats.TotEventTriggerCompleted, 1)
}
return res
}
func (s *Scorch) fireAsyncError(err error) {
if s.onAsyncError != nil {
s.onAsyncError(err, s.path)
}
atomic.AddUint64(&s.stats.TotOnErrors, 1)
}
func (s *Scorch) Open() error {
if s.rootBolt == nil {
err := s.openBolt()
if err != nil {
return err
}
}
s.asyncTasks.Add(1)
go s.introducerLoop()
if s.trainer != nil {
s.asyncTasks.Add(1)
go s.trainer.trainLoop()
}
if !s.readOnly && s.path != "" {
s.asyncTasks.Add(1)
go s.persisterLoop()
s.asyncTasks.Add(1)
go s.mergerLoop()
}
return nil
}
func (s *Scorch) openBolt() error {
var ok bool
s.path, ok = s.config["path"].(string)
if !ok {
return fmt.Errorf("must specify path")
}
if s.path == "" {
s.unsafeBatch = true
}
// bolt options
rootBoltOpt := *bolt.DefaultOptions
if s.readOnly {
rootBoltOpt.ReadOnly = true
rootBoltOpt.OpenFile = func(path string, flag int, mode os.FileMode) (*os.File, error) {
// Bolt appends an O_CREATE flag regardless.
// See - https://github.com/etcd-io/bbolt/blob/v1.3.5/db.go#L210
// Use os.O_RDONLY only if path exists (#1623)
if _, err := os.Stat(path); os.IsNotExist(err) {
return os.OpenFile(path, flag, mode)
}
return os.OpenFile(path, os.O_RDONLY, mode)
}
} else {
if s.path != "" {
err := os.MkdirAll(s.path, 0o700)
if err != nil {
return err
}
}
}
if boltTimeoutStr, ok := s.config["bolt_timeout"].(string); ok {
var err error
boltTimeout, err := time.ParseDuration(boltTimeoutStr)
if err != nil {
return fmt.Errorf("invalid duration specified for bolt_timeout: %v", err)
}
rootBoltOpt.Timeout = boltTimeout
}
rootBoltPath := s.path + string(os.PathSeparator) + "root.bolt"
var err error
if s.path != "" {
s.rootBolt, err = util.OpenBolt(rootBoltPath, 0o600, &rootBoltOpt)
if err != nil {
return err
}
// now see if there is any existing state to load
err = s.loadFromBolt()
if err != nil {
_ = s.Close()
return err
}
}
atomic.StoreUint64(&s.stats.TotFileSegmentsAtRoot, uint64(len(s.root.segment)))
s.introductions = make(chan *segmentIntroduction)
s.persists = make(chan *persistIntroduction)
s.merges = make(chan *segmentMerge)
s.introducerNotifier = make(chan *epochWatcher, 1)
s.persisterNotifier = make(chan *epochWatcher, 1)
s.closeCh = make(chan struct{})
s.forceMergeRequestCh = make(chan *mergerCtrl, 1)
if !s.readOnly && s.path != "" {
err := s.removeOldZapFiles() // Before persister or merger create any new files.
if err != nil {
_ = s.Close()
return err
}
}
return nil
}
func (s *Scorch) Close() (err error) {
startTime := time.Now()
defer func() {
s.fireEvent(EventKindClose, time.Since(startTime))
}()
s.fireEvent(EventKindCloseStart, 0)
// signal to async tasks we want to close
close(s.closeCh)
// wait for them to close
s.asyncTasks.Wait()
// now close the root bolt
if s.rootBolt != nil {
err = s.rootBolt.Close()
s.rootLock.Lock()
if s.root != nil {
err2 := s.root.DecRef()
if err == nil {
err = err2
}
}
s.root = nil
s.rootBolt = nil
s.rootLock.Unlock()
}
return
}
func (s *Scorch) Update(doc index.Document) error {
b := index.NewBatch()
b.Update(doc)
return s.Batch(b)
}
func (s *Scorch) Delete(id string) error {
b := index.NewBatch()
b.Delete(id)
return s.Batch(b)
}
func (s *Scorch) isTrained(batch *index.Batch) (bool, error) {
trained := true
if len(batch.IndexOps) > 0 && s.trainer != nil {
val, err := s.getInternal(util.BoltTrainCompleteKey)
if err != nil {
return false, err
}
if val != nil {
trained, err = strconv.ParseBool(string(val))
if err != nil {
return false, err
}
}
}
return trained, nil
}
// Batch applices a batch of changes to the index atomically
func (s *Scorch) Batch(batch *index.Batch) (err error) {
start := time.Now()
// notify handlers that we're about to index a batch of data
s.fireEvent(EventKindBatchIntroductionStart, 0)
defer func() {
s.fireEvent(EventKindBatchIntroduction, time.Since(start))
}()
trained, err := s.isTrained(batch)
if err != nil {
return err
}
if !trained {
return fmt.Errorf("index is not trained yet")
}
resultChan := make(chan index.Document, len(batch.IndexOps))
var numUpdates uint64
var numDeletes uint64
var numPlainTextBytes uint64
var ids []string
for docID, doc := range batch.IndexOps {
if doc != nil {
// insert _id field
doc.AddIDField()
numUpdates++
numPlainTextBytes += doc.NumPlainTextBytes()
} else {
numDeletes++
}
ids = append(ids, docID)
}
// FIXME could sort ids list concurrent with analysis?
if numUpdates > 0 {
go func() {
for k := range batch.IndexOps {
doc := batch.IndexOps[k]
if doc != nil {
// put the work on the queue
s.analysisQueue.Queue(func() {
analyze(doc, s.setSpatialAnalyzerPlugin)
resultChan <- doc
})
}
}
}()
}
// wait for analysis result
analysisResults := make([]index.Document, int(numUpdates))
var itemsDeQueued uint64
var totalAnalysisSize int
for itemsDeQueued < numUpdates {
result := <-resultChan
resultSize := result.Size()
// check if the document is searchable by the index
if result.Indexed() {
atomic.AddUint64(&s.stats.TotMutationsFiltered, 1)
}
atomic.AddUint64(&s.iStats.analysisBytesAdded, uint64(resultSize))
totalAnalysisSize += resultSize
analysisResults[itemsDeQueued] = result
itemsDeQueued++
}
close(resultChan)
defer atomic.AddUint64(&s.iStats.analysisBytesRemoved, uint64(totalAnalysisSize))
atomic.AddUint64(&s.stats.TotAnalysisTime, uint64(time.Since(start)))
indexStart := time.Now()
var newSegment segment.Segment
var bufBytes uint64
if len(analysisResults) > 0 {
newSegment, bufBytes, err = s.segPlugin.NewUsing(analysisResults, s.segmentConfig)
if err != nil {
return err
}
if segB, ok := newSegment.(segment.DiskStatsReporter); ok {
atomic.AddUint64(&s.stats.TotBytesWrittenAtIndexTime,
segB.BytesWritten())
}
atomic.AddUint64(&s.iStats.newSegBufBytesAdded, bufBytes)
} else {
atomic.AddUint64(&s.stats.TotBatchesEmpty, 1)
}
err = s.prepareSegment(newSegment, ids, batch.InternalOps, batch.PersistedCallback())
if err != nil {
if newSegment != nil {
_ = newSegment.Close()
}
atomic.AddUint64(&s.stats.TotOnErrors, 1)
} else {
atomic.AddUint64(&s.stats.TotUpdates, numUpdates)
atomic.AddUint64(&s.stats.TotDeletes, numDeletes)
atomic.AddUint64(&s.stats.TotBatches, 1)
atomic.AddUint64(&s.stats.TotIndexedPlainTextBytes, numPlainTextBytes)
}
atomic.AddUint64(&s.iStats.newSegBufBytesRemoved, bufBytes)
atomic.AddUint64(&s.stats.TotIndexTime, uint64(time.Since(indexStart)))
return err
}
func (s *Scorch) getInternal(key []byte) ([]byte, error) {
s.rootLock.RLock()
defer s.rootLock.RUnlock()
switch string(key) {
case string(util.BoltTrainCompleteKey):
if s.trainer != nil {
return s.trainer.getInternal(key)
} else {
return nil, fmt.Errorf("get on BoltTrainCompleteKey is not supported" +
" with this build")
}
}
return nil, nil
}
func (s *Scorch) Train(batch *index.Batch) error {
if s.trainer != nil {
return s.trainer.train(batch)
}
return fmt.Errorf("training is not supported with this build")
}
func (s *Scorch) prepareSegment(newSegment segment.Segment, ids []string,
internalOps map[string][]byte, persistedCallback index.BatchCallback) error {
// new introduction
introduction := &segmentIntroduction{
id: atomic.AddUint64(&s.nextSegmentID, 1),
data: newSegment,
ids: ids,
internal: internalOps,
applied: make(chan error),
persistedCallback: persistedCallback,
}
if !s.unsafeBatch {
introduction.persisted = make(chan error, 1)
}
// optimistically prepare obsoletes outside of rootLock
s.rootLock.RLock()
root := s.root
root.AddRef()
s.rootLock.RUnlock()
defer func() { _ = root.DecRef() }()
introduction.obsoletes = make(map[uint64]*roaring.Bitmap, len(root.segment))
for _, seg := range root.segment {
delta, err := seg.segment.DocNumbers(ids)
if err != nil {
return err
}
introduction.obsoletes[seg.id] = delta
}
introStartTime := time.Now()
s.introductions <- introduction
// block until this segment is applied
err := <-introduction.applied
if err != nil {
return err
}
if introduction.persisted != nil {
err = <-introduction.persisted
}
introTime := uint64(time.Since(introStartTime))
atomic.AddUint64(&s.stats.TotBatchIntroTime, introTime)
if atomic.LoadUint64(&s.stats.MaxBatchIntroTime) < introTime {
atomic.StoreUint64(&s.stats.MaxBatchIntroTime, introTime)
}
return err
}
func (s *Scorch) SetInternal(key, val []byte) error {
b := index.NewBatch()
b.SetInternal(key, val)
return s.Batch(b)
}
func (s *Scorch) DeleteInternal(key []byte) error {
b := index.NewBatch()
b.DeleteInternal(key)
return s.Batch(b)
}
// Reader returns a low-level accessor on the index data. Close it to
// release associated resources.
func (s *Scorch) Reader() (index.IndexReader, error) {
return s.currentSnapshot(), nil
}
func (s *Scorch) currentSnapshot() *IndexSnapshot {
s.rootLock.RLock()
rv := s.root
if rv != nil {
rv.AddRef()
}
s.rootLock.RUnlock()
return rv
}
func (s *Scorch) Stats() json.Marshaler {
return &s.stats
}
func (s *Scorch) BytesReadQueryTime() uint64 {
return s.stats.TotBytesReadAtQueryTime
}
func (s *Scorch) diskFileStats(rootSegmentPaths map[string]struct{}) (uint64,
uint64, uint64,
) {
var numFilesOnDisk, numBytesUsedDisk, numBytesOnDiskByRoot uint64
if s.path != "" {
files, err := os.ReadDir(s.path)
if err == nil {
for _, f := range files {
if !f.IsDir() {
if finfo, err := f.Info(); err == nil {
numBytesUsedDisk += uint64(finfo.Size())
numFilesOnDisk++
if rootSegmentPaths != nil {
fname := s.path + string(os.PathSeparator) + finfo.Name()
if _, fileAtRoot := rootSegmentPaths[fname]; fileAtRoot {
numBytesOnDiskByRoot += uint64(finfo.Size())
}
}
}
}
}
}
}
// if no root files path given, then consider all disk files.
if rootSegmentPaths == nil {
return numFilesOnDisk, numBytesUsedDisk, numBytesUsedDisk
}
return numFilesOnDisk, numBytesUsedDisk, numBytesOnDiskByRoot
}
func (s *Scorch) StatsMap() map[string]interface{} {
m := s.stats.ToMap()
indexSnapshot := s.currentSnapshot()
if indexSnapshot == nil {
return nil
}
defer func() {
_ = indexSnapshot.Close()
}()
rootSegPaths := indexSnapshot.diskSegmentsPaths()
s.rootLock.RLock()
m["CurFilesIneligibleForRemoval"] = uint64(len(s.ineligibleForRemoval))
s.rootLock.RUnlock()
numFilesOnDisk, numBytesUsedDisk, numBytesOnDiskByRoot := s.diskFileStats(rootSegPaths)
m["CurOnDiskBytes"] = numBytesUsedDisk
m["CurOnDiskFiles"] = numFilesOnDisk
// TODO: consider one day removing these backwards compatible
// names for apps using the old names
m["updates"] = m["TotUpdates"]
m["deletes"] = m["TotDeletes"]
m["batches"] = m["TotBatches"]
m["errors"] = m["TotOnErrors"]
m["analysis_time"] = m["TotAnalysisTime"]
m["index_time"] = m["TotIndexTime"]
m["term_searchers_started"] = m["TotTermSearchersStarted"]
m["term_searchers_finished"] = m["TotTermSearchersFinished"]
m["knn_searches"] = m["TotKNNSearches"]
m["synonym_searches"] = m["TotSynonymSearches"]
m["total_mutations_filtered"] = m["TotMutationsFiltered"]
m["num_bytes_read_at_query_time"] = m["TotBytesReadAtQueryTime"]
m["num_plain_text_bytes_indexed"] = m["TotIndexedPlainTextBytes"]
m["num_bytes_written_at_index_time"] = m["TotBytesWrittenAtIndexTime"]
m["num_items_introduced"] = m["TotIntroducedItems"]
m["num_items_persisted"] = m["TotPersistedItems"]
m["num_recs_to_persist"] = m["TotItemsToPersist"]
// total disk bytes found in index directory inclusive of older snapshots
m["num_bytes_used_disk"] = numBytesUsedDisk
// total disk bytes by the latest root index, exclusive of older snapshots
m["num_bytes_used_disk_by_root"] = numBytesOnDiskByRoot
// num_bytes_used_disk_by_root_reclaimable is an approximation about the
// reclaimable disk space in an index. (eg: from a full compaction)
m["num_bytes_used_disk_by_root_reclaimable"] = uint64(float64(numBytesOnDiskByRoot) *
indexSnapshot.reClaimableDocsRatio())
m["num_files_on_disk"] = numFilesOnDisk
m["num_root_memorysegments"] = m["TotMemorySegmentsAtRoot"]
m["num_root_filesegments"] = m["TotFileSegmentsAtRoot"]
m["num_persister_nap_pause_completed"] = m["TotPersisterNapPauseCompleted"]
m["num_persister_nap_merger_break"] = m["TotPersisterMergerNapBreak"]
m["total_compaction_written_bytes"] = m["TotFileMergeWrittenBytes"]
// the bool stat `index_bgthreads_active` indicates whether the background routines
// (which are responsible for the index to attain a steady state) are still
// doing some work.
if rootEpoch, ok := m["CurRootEpoch"].(uint64); ok {
if lastMergedEpoch, ok := m["LastMergedEpoch"].(uint64); ok {
if lastPersistedEpoch, ok := m["LastPersistedEpoch"].(uint64); ok {
m["index_bgthreads_active"] = !(lastMergedEpoch == rootEpoch && lastPersistedEpoch == rootEpoch)
}
}
}
// calculate the aggregate of all the segment's field stats
aggFieldStats := newFieldStats()
for _, segmentSnapshot := range indexSnapshot.Segments() {
if segmentSnapshot.stats != nil {
aggFieldStats.Aggregate(segmentSnapshot.stats)
}
}
aggFieldStatsMap := aggFieldStats.Fetch()
for statName, stats := range aggFieldStatsMap {
for fieldName, val := range stats {
m["field:"+fieldName+":"+statName] = val
}
}
aggVectorStats := newFieldStats()
for _, segmentSnapshot := range indexSnapshot.Segments() {
if vsr, ok := segmentSnapshot.Segment().(segment.VectorFieldStatsReporter); ok {
segStats := newFieldStats()
vsr.UpdateVectorFieldStats(segStats)
aggVectorStats.Aggregate(segStats)
}
}
for statName, stats := range aggVectorStats.Fetch() {
for fieldName, val := range stats {
m["field:"+fieldName+":"+statName] = val
}
}
// zap layer stats that are continously updated throughout the index lifecycle
m[segment.StatsKey] = s.zapStats.StatsMap()
return m
}
func (s *Scorch) Analyze(d index.Document) {
analyze(d, s.setSpatialAnalyzerPlugin)
}
type customAnalyzerPluginInitFunc func(field index.Field)
func (s *Scorch) setSpatialAnalyzerPlugin(f index.Field) {
if s.segPlugin != nil {
// check whether the current field is a custom tokenizable
// spatial field then set the spatial analyser plugin for
// overriding the tokenisation during the analysis stage.
if sf, ok := f.(index.TokenizableSpatialField); ok {
sf.SetSpatialAnalyzerPlugin(s.spatialPlugin)
}
}
}
func analyze(d index.Document, fn customAnalyzerPluginInitFunc) {
d.VisitFields(func(field index.Field) {
if field.Options().IsIndexed() {
if fn != nil {
fn(field)
}
field.Analyze()
if d.HasComposite() && field.Name() != "_id" {
// see if any of the composite fields need this
d.VisitComposite(func(cf index.CompositeField) {
cf.Compose(field.Name(), field.AnalyzedLength(), field.AnalyzedTokenFrequencies())
})
// Since the encoded geoShape is only necessary within the doc values
// of the geoShapeField, it has been removed from the field's term dictionary.
// However, '_all' field uses its term dictionary as its docValues, so it
// becomes necessary to add the geoShape into the '_all' field's term dictionary
if f, ok := field.(index.GeoShapeField); ok {
d.VisitComposite(func(cf index.CompositeField) {
geoshape := f.EncodedShape()
cf.Compose(field.Name(), 1, index.TokenFrequencies{
string(geoshape): &index.TokenFreq{
Term: geoshape,
Locations: []*index.TokenLocation{
{
Start: 0,
End: len(geoshape),
Position: 1,
},
},
},
})
})
}
}
}
})
if nd, ok := d.(index.NestedDocument); ok {
nd.VisitNestedDocuments(func(doc index.Document) {
doc.AddIDField()
analyze(doc, fn)
})
}
}
func (s *Scorch) AddEligibleForRemoval(epoch uint64) {
s.rootLock.Lock()
if s.root == nil || s.root.epoch != epoch {
s.eligibleForRemoval = append(s.eligibleForRemoval, epoch)
}
s.rootLock.Unlock()
}
func (s *Scorch) MemoryUsed() (memUsed uint64) {
indexSnapshot := s.currentSnapshot()
if indexSnapshot == nil {
return
}
defer func() {
_ = indexSnapshot.Close()
}()
// Account for current root snapshot overhead
memUsed += uint64(indexSnapshot.Size())
// Account for snapshot that the persister may be working on
persistEpoch := atomic.LoadUint64(&s.iStats.persistEpoch)
persistSnapshotSize := atomic.LoadUint64(&s.iStats.persistSnapshotSize)
if persistEpoch != 0 && indexSnapshot.epoch > persistEpoch {
// the snapshot that the persister is working on isn't the same as
// the current snapshot
memUsed += persistSnapshotSize
}
// Account for snapshot that the merger may be working on
mergeEpoch := atomic.LoadUint64(&s.iStats.mergeEpoch)
mergeSnapshotSize := atomic.LoadUint64(&s.iStats.mergeSnapshotSize)
if mergeEpoch != 0 && indexSnapshot.epoch > mergeEpoch {
// the snapshot that the merger is working on isn't the same as
// the current snapshot
memUsed += mergeSnapshotSize
}
memUsed += (atomic.LoadUint64(&s.iStats.newSegBufBytesAdded) -
atomic.LoadUint64(&s.iStats.newSegBufBytesRemoved))
memUsed += (atomic.LoadUint64(&s.iStats.analysisBytesAdded) -
atomic.LoadUint64(&s.iStats.analysisBytesRemoved))
return memUsed
}
func (s *Scorch) markIneligibleForRemoval(filename string) {
s.rootLock.Lock()
s.ineligibleForRemoval[filename] = true
s.rootLock.Unlock()
}
func (s *Scorch) unmarkIneligibleForRemoval(filename string) {
s.rootLock.Lock()
delete(s.ineligibleForRemoval, filename)
s.rootLock.Unlock()
}
func init() {
err := registry.RegisterIndexType(Name, NewScorch)
if err != nil {
panic(err)
}
}
func parseToTimeDuration(i interface{}) (time.Duration, error) {
switch v := i.(type) {
case string:
return time.ParseDuration(v)
default:
return 0, fmt.Errorf("expects a duration string")
}
}
func parseToInteger(i interface{}) (int, error) {
switch v := i.(type) {
case float64:
return int(v), nil
case int:
return v, nil
default:
return 0, fmt.Errorf("expects int or float64 value")
}
}
func parseToFloat(i interface{}) (float64, error) {
switch v := i.(type) {
case float64:
return v, nil
case float32:
return float64(v), nil
case int:
return float64(v), nil
default:
return 0, fmt.Errorf("expects float64, float32 or int value")
}
}
// Holds Zap's field level stats at a segment level
type fieldStats struct {
// StatName -> FieldName -> value
statMap map[string]map[string]uint64
}
// Add the data into the map after checking if the statname is valid
func (fs *fieldStats) Store(statName, fieldName string, value uint64) {
if _, exists := fs.statMap[statName]; !exists {
fs.statMap[statName] = make(map[string]uint64)
}
fs.statMap[statName][fieldName] = value
}
// Combine the given stats map with the existing map
func (fs *fieldStats) Aggregate(stats segment.FieldStats) {
statMap := stats.Fetch()
if statMap == nil {
return
}
for statName, statMap := range statMap {
if _, exists := fs.statMap[statName]; !exists {
fs.statMap[statName] = make(map[string]uint64)
}
for fieldName, val := range statMap {
if _, exists := fs.statMap[statName][fieldName]; !exists {
fs.statMap[statName][fieldName] = 0
}
fs.statMap[statName][fieldName] += val
}
}
}
// Returns the stats map
func (fs *fieldStats) Fetch() map[string]map[string]uint64 {
if fs == nil {
return nil
}
return fs.statMap
}
// Initializes an empty stats map
func newFieldStats() *fieldStats {
rv := &fieldStats{
statMap: map[string]map[string]uint64{},
}
return rv
}
// CopyReader returns a low-level accessor for index data, ensuring persisted segments
// remain on disk for backup, preventing race conditions with the persister/merger cleanup.
// Close the reader after backup to allow segment removal by the persister/merger.
func (s *Scorch) CopyReader() index.CopyReader {
s.rootLock.Lock()
rv := s.root
if rv != nil {
rv.AddRef()
var fileName string
// schedule a backup for all the segments from the root. Note that the
// both the unpersisted and persisted segments are scheduled for backup.
// because during the backup, the unpersisted segments may get persisted and
// hence we need to protect both the unpersisted and persisted segments from removal
// by the cleanup routine during the online backup
for _, seg := range rv.segment {
if perSeg, ok := seg.segment.(segment.PersistedSegment); ok {
// segment is persisted
fileName = filepath.Base(perSeg.Path())
} else {
// segment is not persisted
// the name of the segment file that is generated if the
// the segment is persisted in the future.
fileName = zapFileName(seg.id)
}
rv.parent.copyScheduled[fileName]++
}
}
s.rootLock.Unlock()
return rv
}
func (s *Scorch) SetPathInBolt(key []byte, value []byte) error {
tx, err := s.rootBolt.Begin(true)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
snapshotsBucket, err := tx.CreateBucketIfNotExists(util.BoltSnapshotsBucket)
if err != nil {
return err
}
if s.trainer != nil {
// currently this is specific to trained index file update
err = s.trainer.updateBolt(snapshotsBucket, key, value)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return s.rootBolt.Sync()
}
// CopyFile copies a specific file to a destination directory which has an access to a bleve index
// doing a io.Copy() isn't enough because the file needs to be tracked in bolt file as well
func (s *Scorch) CopyFile(file string, d index.IndexDirectory) error {
s.rootLock.Lock()
defer s.rootLock.Unlock()
dest, err := d.GetWriter(filepath.Join("store", file))
if err != nil {
return err
}
source, err := os.Open(filepath.Join(s.path, file))
if err != nil {
return err
}
defer source.Close()
defer dest.Close()
_, err = io.Copy(dest, source)
if err != nil {
return err
}
// this code is currently specific to copying trained data but is future proofed for other files
// to be updated in the dest's bolt
err = s.trainer.copyFileLOCKED(file, d)
return err
}
// external API to fire a scorch event (EventKindIndexStart) externally from bleve
func (s *Scorch) FireIndexEvent() {
s.fireEvent(EventKindIndexStart, 0)
}
// Updates bolt db with the given field info. Existing field info already in bolt
// will be merged before persisting. The index mapping is also overwritted both
// in bolt as well as the index snapshot
func (s *Scorch) UpdateFields(fieldInfo map[string]*index.UpdateFieldInfo, mappingBytes []byte) error {
err := s.updateBolt(fieldInfo, mappingBytes)
if err != nil {
return err
}
// Pass the update field info to all snapshots and segment bases
s.root.UpdateFieldsInfo(fieldInfo)
return nil
}
func (s *Scorch) OpenMeta() error {
if s.rootBolt == nil {
err := s.openBolt()
if err != nil {
return err
}
}
return nil
}
// Merge and update deleted field info and rewrite index mapping
func (s *Scorch) updateBolt(fieldInfo map[string]*index.UpdateFieldInfo, mappingBytes []byte) error {
filePath := s.path + string(os.PathSeparator) + "root.bolt"
return s.rootBolt.Update(func(tx *util.BoltTxImpl) error {
snapshots := tx.Bucket(util.BoltSnapshotsBucket)
if snapshots == nil {
return nil
}
c := snapshots.Cursor()
for k, _ := c.Last(); k != nil; k, _ = c.Prev() {
_, _, err := decodeUvarintAscending(k)
if err != nil {
fmt.Printf("unable to parse segment epoch %x, continuing", k)
continue
}
snapshot := snapshots.GetBucket(k)
metaBucket := snapshot.GetBucket(util.BoltMetaDataKey)
if metaBucket == nil {
return fmt.Errorf("meta-data bucket missing")
}
writer, err := util.NewFileWriter([]byte(filePath))
if err != nil {
return fmt.Errorf("unable to load correct writer: %v", err)
}
fileWriterID, err := metaBucket.Get(util.BoltMetaDataFileWriterIDKey, nil)
if err != nil {
return fmt.Errorf("unable to get file writer id: %v", err)
}
if fileWriterID == nil {
return fmt.Errorf("file writer id missing in meta data")
}
reader, err := util.NewFileReader(string(fileWriterID), []byte(filePath))
if err != nil {
return fmt.Errorf("unable to load correct reader: %v", err)
}
err = metaBucket.Put(util.BoltMetaDataFileWriterIDKey, []byte(writer.Id()), writer)
if err != nil {
return err
}
cc := snapshot.Cursor()
for kk, _ := cc.First(); kk != nil; kk, _ = cc.Next() {
if kk[0] == util.BoltInternalKey[0] {
internalBucket := snapshot.GetBucket(kk)
if internalBucket == nil {
return fmt.Errorf("segment key, but bucket missing %x", kk)
}
internalVals := make(map[string][]byte)
err := internalBucket.ForEach(func(key []byte, val []byte) error {
internalVals[string(key)] = val
return nil
}, reader)
if err != nil {
return err
}
for key, val := range internalVals {
err = internalBucket.Put([]byte(key), val, writer)
if err != nil {
return err
}
}
} else if kk[0] != util.BoltMetaDataKey[0] {
segmentBucket := snapshot.GetBucket(kk)
if segmentBucket == nil {
return fmt.Errorf("segment key, but bucket missing %x", kk)
}
var updatedFields map[string]*index.UpdateFieldInfo
updatedFieldBytes, err := segmentBucket.Get(util.BoltUpdatedFieldsKey, reader)
if err != nil {
return fmt.Errorf("error getting updated field bytes: %v", err)
}
if updatedFieldBytes != nil {
err = json.Unmarshal(updatedFieldBytes, &updatedFields)
if err != nil {
return fmt.Errorf("error reading updated field bytes: %v", err)
}
for field, info := range fieldInfo {
if val, ok := updatedFields[field]; ok {
updatedFields[field] = &index.UpdateFieldInfo{
Deleted: info.Deleted || val.Deleted,
Store: info.Store || val.Store,
DocValues: info.DocValues || val.DocValues,
Index: info.Index || val.Index,
}
} else {
updatedFields[field] = info
}
}
} else {
updatedFields = fieldInfo
}
buf, err := json.Marshal(updatedFields)
if err != nil {
return err
}
err = segmentBucket.Put(util.BoltUpdatedFieldsKey, buf, writer)
if err != nil {
return err
}
deletedBytes, err := segmentBucket.Get(util.BoltDeletedKey, reader)
if err != nil {
return fmt.Errorf("error getting deleted bytes: %v", err)
}
if deletedBytes != nil {
err = segmentBucket.Put(util.BoltDeletedKey, deletedBytes, writer)
if err != nil {
return err
}
}
statBytes, err := segmentBucket.Get(util.BoltStatsKey, reader)
if err != nil {
return fmt.Errorf("error getting stats bytes: %v", err)
}
if statBytes != nil {
err = segmentBucket.Put(util.BoltStatsKey, statBytes, writer)
if err != nil {
return err
}
}
}
}
}
return nil
})
}
// returns the set of file callback writer ids in use by all of the segments and boltdb
func (s *Scorch) FileWriterIDsInUse() (map[string]struct{}, error) {
s.rootLock.RLock()
keyMap := make(map[string]struct{})
for _, segmentSnapShot := range s.root.segment {
if seg, ok := segmentSnapShot.segment.(segment.SegmentWithCallbacks); ok {
keyMap[seg.CallbackId()] = struct{}{}
}
}
if s.trainer != nil {
trainerKeys, err := s.trainer.fileWriterIDsInUse()
if err != nil {
return nil, err
}
for k, _ := range trainerKeys {
keyMap[k] = struct{}{}
}
}
boltKeys, err := s.boltFileWriterIDsInUse()
if err != nil {
return nil, err
}
for k, _ := range boltKeys {
keyMap[k] = struct{}{}
}
s.rootLock.RUnlock()
return keyMap, nil
}
// removes all file callback writer ids in use from all of the segments and boltdb
// boltdb is updated with the latest callback writer while segments are force
// merged blockingly until snapshot is persisted with the latest callback writer
func (s *Scorch) DropFileWriterIDs(ids map[string]struct{}) error {
err := s.removeBoltFileWriterIDs(ids)
if err != nil {
return err
}
if s.trainer != nil {
err := s.trainer.dropFileWriterIDs(ids)
if err != nil {
return err
}
}
s.rootLock.Lock()
// create a done channel to ensure success of merge
ctx := context.Background()
doneCh := make(chan error)
ctx = context.WithValue(ctx, mergeDoneKey, doneCh)
// PARTIAL ROLLBACK WILL NOT BE SUPPORTED DURING THIS OPERATION
// this is done because all of the rollback snapshots
// are likely to have the same sequence numbers and
// morever, it is not functionally correct to hold
// data with writer ids that have been removed
prevNumSnapshotsToKeep := s.numSnapshotsToKeep
s.numSnapshotsToKeep = 1
defer func() {
// reset rollback snapshot retention
s.rootLock.Lock()
s.numSnapshotsToKeep = prevNumSnapshotsToKeep
s.rootLock.Unlock()
}()
// track the zapx files that are expected to be removed after
// the merge so that we can block until they are removed by the persister
filePaths := make([]string, 0)
var mergePlanner mergePlanFunc = func(ourSnapshot *IndexSnapshot) (*mergeplan.MergePlan, error) {
// Create a merge plan with the filtered segments and force a merge
// to remove the callback from the segments.
atomic.AddUint64(&s.stats.TotFileMergePlan, 1)
// filter all segments that have callbacks that need to be removed
// and add them to the list of segments to compact
segsToCompact := make([]mergeplan.Segment, 0)
for _, ss := range ourSnapshot.segment {
// only persisted segments needs to be checked
if _, ok := ss.segment.(segment.PersistedSegment); ok {
if segWithCallbacks, ok := ss.segment.(segment.SegmentWithCallbacks); ok {
if _, ok := ids[segWithCallbacks.CallbackId()]; ok {
segsToCompact = append(segsToCompact, ss)
filePaths = append(filePaths, zapFileName(ss.id))
}
}
}
}
// attempt a merge plan with the default merge planner options
mergePlan, err := mergeplan.Plan(segsToCompact, s.mergePlannerOptions)
if err != nil {
atomic.AddUint64(&s.stats.TotFileMergePlanErr, 1)
return nil, fmt.Errorf("merge plan creation err: %v", err)
}
// create a map to track segments included in the default merge plan
segDictionary := make(map[uint64]bool)
for _, seg := range segsToCompact {
segDictionary[seg.Id()] = true
}
// create a merge plan if the default merge planner is unable
// to create one with the given segments
if mergePlan == nil {
mergePlan = &mergeplan.MergePlan{
Tasks: make([]*mergeplan.MergeTask, 0),
}
}
// mark all segments included in the default merge plan
for _, task := range mergePlan.Tasks {
for _, seg := range task.Segments {
segDictionary[seg.Id()] = false
}
}
// Create additional merge tasks for segments that are unable to be merged
for _, seg := range segsToCompact {
if segDictionary[seg.Id()] {
mergePlan.Tasks = append(mergePlan.Tasks, &mergeplan.MergeTask{
Segments: []mergeplan.Segment{seg},
})
}
}
return mergePlan, nil
}
// set the merge plan func in the context for the merger to use when it receives the merge request
// this is to ensure that the merge request is triggered with the latest snapshot, thus avoiding
// any races
ctx = context.WithValue(ctx, mergePlanFuncKey, mergePlanner)
// trigger the merge with the force merge plan
s.forceMergeRequestCh <- &mergerCtrl{
ctx: ctx,
}
s.rootLock.Unlock()
// blockingly wait for merge to complete
err = <-doneCh
close(doneCh)
if err != nil {
return err
}
// wait for files to be cleaned up by persister
err = s.waitTillFileCleanup(filePaths)
if err != nil {
return err
}
return nil
}
// waitTillFileCleanup blocks until the given files are cleaned up by the persister or merger or
// returns an error after a timeout. It does so by checking the index directory every 5 seconds
// for the presence of the given files, and returns once they are no longer present.
func (s *Scorch) waitTillFileCleanup(filePaths []string) error {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
timeout := time.After(5 * time.Minute)
for {
select {
case <-ticker.C:
files, err := os.ReadDir(s.path)
if err != nil {
return err
}
for _, f := range files {
fname := f.Name()
if filepath.Ext(fname) == ".zap" {
for _, filePath := range filePaths {
if fname == filePath {
continue
}
}
}
}
return nil
case <-timeout:
return fmt.Errorf("timeout waiting for file cleanup for files: %v", filePaths)
}
}
}