Repository: blevesearch/bleve
Pull request #1566 (2021-03-03 19:09:52)

Fire async error on panic in scorch async routine
Hi,

for yet unknown reason, my project suffer with corrupted indices on the storage. Slice dimensions are sometimes stored wrongly in zap files, but the error is not detected until read, so it is not easy to debug it. When such index is being read, it often results in go panic. I'm able to rebuild the corrupted index, so there's a workaround, but I need to catch such error first.

The error sometimes strikes during open, which can be handled by user (my) code easily. But sometimes it appears later, during asynchronous scorch mergerLoop operations for example. And here I'm currently helpless, panic in scorch internal goroutine makes my app crash.

Thus here is a pull request adding panic handling to persister, merger and introducer. I've found there's already an async error reporting mechanism via `fireAsyncError`, so I've used it.

I my code, I need to know which index failed, because I open multiple indices. Hence I created new error type which carries required information. I also considered passing whole scorch struct pointer, but I think that's too internal.

Another question is, what to do if there's no `onAsyncError` handler set. I prefer the panic to be silently catched and async task terminated (inspired by `parseMergePlannerOptions` handling). But the previous behaviour which leads to program crash can be preserved as well, if you think it is more appropriate.

Best regards



Patch:
diff --git a/analysis/lang/in/scripts.go b/analysis/lang/in/scripts.go
index 26446cb1..cc32f102 100644
--- a/analysis/lang/in/scripts.go
+++ b/analysis/lang/in/scripts.go
@@ -17,8 +17,8 @@ package in
 import (
 	"unicode"
 
+	"github.com/bits-and-blooms/bitset"
 	"github.com/blevesearch/bleve/v2/analysis"
-	"github.com/willf/bitset"
 )
 
 type ScriptData struct {
diff --git a/analysis/token/hierarchy/hierarchy.go b/analysis/token/hierarchy/hierarchy.go
new file mode 100644
index 00000000..5a4414a2
--- /dev/null
+++ b/analysis/token/hierarchy/hierarchy.go
@@ -0,0 +1,92 @@
+package hierarchy
+
+import (
+	"bytes"
+	"fmt"
+	"math"
+
+	"github.com/blevesearch/bleve/v2/analysis"
+	"github.com/blevesearch/bleve/v2/registry"
+)
+
+const Name = "hierarchy"
+
+type HierarchyFilter struct {
+	maxLevels  int
+	delimiter  []byte
+	splitInput bool
+}
+
+func NewHierarchyFilter(delimiter []byte, maxLevels int, splitInput bool) *HierarchyFilter {
+	return &HierarchyFilter{
+		maxLevels:  maxLevels,
+		delimiter:  delimiter,
+		splitInput: splitInput,
+	}
+}
+
+func (s *HierarchyFilter) Filter(input analysis.TokenStream) analysis.TokenStream {
+	rv := make(analysis.TokenStream, 0, s.maxLevels)
+
+	var soFar [][]byte
+	for _, token := range input {
+		if s.splitInput {
+			parts := bytes.Split(token.Term, s.delimiter)
+			for _, part := range parts {
+				soFar, rv = s.buildToken(rv, soFar, part)
+				if len(soFar) >= s.maxLevels {
+					return rv
+				}
+			}
+		} else {
+			soFar, rv = s.buildToken(rv, soFar, token.Term)
+			if len(soFar) >= s.maxLevels {
+				return rv
+			}
+		}
+	}
+
+	return rv
+}
+
+func (s *HierarchyFilter) buildToken(tokenStream analysis.TokenStream, soFar [][]byte, part []byte) (
+	[][]byte, analysis.TokenStream) {
+
+	soFar = append(soFar, part)
+	term := bytes.Join(soFar, s.delimiter)
+
+	tokenStream = append(tokenStream, &analysis.Token{
+		Type:     analysis.Shingle,
+		Term:     term,
+		Start:    0,
+		End:      len(term),
+		Position: 1,
+	})
+
+	return soFar, tokenStream
+}
+
+func HierarchyFilterConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.TokenFilter, error) {
+	max := math.MaxInt64
+	maxVal, ok := config["max"].(float64)
+	if ok {
+		max = int(maxVal)
+	}
+
+	splitInput := true
+	splitInputVal, ok := config["split_input"].(bool)
+	if ok {
+		splitInput = splitInputVal
+	}
+
+	delimiter, ok := config["delimiter"].(string)
+	if !ok {
+		return nil, fmt.Errorf("must specify delimiter")
+	}
+
+	return NewHierarchyFilter([]byte(delimiter), max, splitInput), nil
+}
+
+func init() {
+	registry.RegisterTokenFilter(Name, HierarchyFilterConstructor)
+}
diff --git a/go.mod b/go.mod
index a1a6b597..1ee36d1a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,19 +3,20 @@ module github.com/blevesearch/bleve/v2
 go 1.13
 
 require (
-	github.com/RoaringBitmap/roaring v0.4.23
-	github.com/blevesearch/bleve_index_api v1.0.0
+	github.com/RoaringBitmap/roaring v0.7.3
+	github.com/bits-and-blooms/bitset v1.2.0
+	github.com/blevesearch/bleve_index_api v1.0.1
 	github.com/blevesearch/go-porterstemmer v1.0.3
 	github.com/blevesearch/scorch_segment_api/v2 v2.0.1
 	github.com/blevesearch/segment v0.9.0
 	github.com/blevesearch/snowballstem v0.9.0
 	github.com/blevesearch/upsidedown_store_api v1.0.1
-	github.com/blevesearch/vellum v1.0.3
-	github.com/blevesearch/zapx/v11 v11.2.0
-	github.com/blevesearch/zapx/v12 v12.2.0
-	github.com/blevesearch/zapx/v13 v13.2.0
-	github.com/blevesearch/zapx/v14 v14.2.0
-	github.com/blevesearch/zapx/v15 v15.2.0
+	github.com/blevesearch/vellum v1.0.5
+	github.com/blevesearch/zapx/v11 v11.2.2
+	github.com/blevesearch/zapx/v12 v12.2.2
+	github.com/blevesearch/zapx/v13 v13.2.2
+	github.com/blevesearch/zapx/v14 v14.2.2
+	github.com/blevesearch/zapx/v15 v15.2.2
 	github.com/couchbase/moss v0.1.0
 	github.com/golang/protobuf v1.3.2
 	github.com/kljensen/snowball v0.6.0
@@ -23,7 +24,6 @@ require (
 	github.com/spf13/cobra v0.0.5
 	github.com/steveyen/gtreap v0.1.0
 	github.com/syndtr/goleveldb v1.0.0
-	github.com/willf/bitset v1.1.10
 	go.etcd.io/bbolt v1.3.5
 	golang.org/x/text v0.3.0
 )
diff --git a/go.sum b/go.sum
index e1e8edd1..1087a75a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,14 @@
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/RoaringBitmap/roaring v0.4.23 h1:gpyfd12QohbqhFO4NVDUdoPOCXsyahYRQhINmlHxKeo=
+github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74=
 github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
+github.com/RoaringBitmap/roaring v0.7.3 h1:RwirWpvFONt2EwHHEHhER7S4BHZkyj3qL5LXLlnQPZ4=
+github.com/RoaringBitmap/roaring v0.7.3/go.mod h1:jdT9ykXwHFNdJbEtxePexlFYH9LXucApeS0/+/g+p1I=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/blevesearch/bleve_index_api v1.0.0 h1:Ds3XeuTxjXCkG6pgIwWDRyooJKNIuOKemnN0N0IkhTU=
+github.com/bits-and-blooms/bitset v1.2.0 h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA=
+github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
 github.com/blevesearch/bleve_index_api v1.0.0/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4=
+github.com/blevesearch/bleve_index_api v1.0.1 h1:nx9++0hnyiGOHJwQQYfsUGzpRdEVE5LsylmmngQvaFk=
+github.com/blevesearch/bleve_index_api v1.0.1/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4=
 github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
 github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
 github.com/blevesearch/mmap-go v1.0.2 h1:JtMHb+FgQCTTYIhtMvimw15dJwu1Y5lrZDMOFXVWPk0=
@@ -16,18 +21,18 @@ github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD
 github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
 github.com/blevesearch/upsidedown_store_api v1.0.1 h1:1SYRwyoFLwG3sj0ed89RLtM15amfX2pXlYbFOnF8zNU=
 github.com/blevesearch/upsidedown_store_api v1.0.1/go.mod h1:MQDVGpHZrpe3Uy26zJBf/a8h0FZY6xJbthIMm8myH2Q=
-github.com/blevesearch/vellum v1.0.3 h1:U86G41A7CtXNzzpIJHM8lSTUqz1Mp8U870TkcdCzZc8=
-github.com/blevesearch/vellum v1.0.3/go.mod h1:2u5ax02KeDuNWu4/C+hVQMD6uLN4txH1JbtpaDNLJRo=
-github.com/blevesearch/zapx/v11 v11.2.0 h1:GBkCJYsyj3eIU4+aiLPxoMz1PYvDbQZl/oXHIBZIP60=
-github.com/blevesearch/zapx/v11 v11.2.0/go.mod h1:gN/a0alGw1FZt/YGTo1G6Z6XpDkeOfujX5exY9sCQQM=
-github.com/blevesearch/zapx/v12 v12.2.0 h1:dyRcSoZVO1jktL4UpGkCEF1AYa3xhKPirh4/N+Va+Ww=
-github.com/blevesearch/zapx/v12 v12.2.0/go.mod h1:fdjwvCwWWwJW/EYTYGtAp3gBA0geCYGLcVTtJEZnY6A=
-github.com/blevesearch/zapx/v13 v13.2.0 h1:mUqbaqQABp8nBE4t4q2qMyHCCq4sykoV8r7aJk4ih3s=
-github.com/blevesearch/zapx/v13 v13.2.0/go.mod h1:o5rAy/lRS5JpAbITdrOHBS/TugWYbkcYZTz6VfEinAQ=
-github.com/blevesearch/zapx/v14 v14.2.0 h1:UsfRqvM9RJxKNKrkR1U7aYc1cv9MWx719fsAjbF6joI=
-github.com/blevesearch/zapx/v14 v14.2.0/go.mod h1:GNgZusc1p4ot040cBQMRGEZobvwjCquiEKYh1xLFK9g=
-github.com/blevesearch/zapx/v15 v15.2.0 h1:ZpibwcrrOaeslkOw3sJ7npP7KDgRHI/DkACjKTqFwyM=
-github.com/blevesearch/zapx/v15 v15.2.0/go.mod h1:MmQceLpWfME4n1WrBFIwplhWmaQbQqLQARpaKUEOs/A=
+github.com/blevesearch/vellum v1.0.5 h1:L5dJ7hKauRVbuH7I8uqLeSK92CPPY6FfrbAmLhAug8A=
+github.com/blevesearch/vellum v1.0.5/go.mod h1:atE0EH3fvk43zzS7t1YNdNC7DbmcC3uz+eMD5xZ2OyQ=
+github.com/blevesearch/zapx/v11 v11.2.2 h1:yeHRnGA4UPxVm1roONbp7VRm+SUx95AleE4rU8w4pc4=
+github.com/blevesearch/zapx/v11 v11.2.2/go.mod h1:qunXXAB8awrvPgnHdqbPFMW01N93bhKFxkEWxYQgp8w=
+github.com/blevesearch/zapx/v12 v12.2.2 h1:aK6r0DbMMI8+MnrqkmFwWxUr8ZwVwQf8owbdVv7uhKs=
+github.com/blevesearch/zapx/v12 v12.2.2/go.mod h1:6reJkgolYR1r7GC6SwbuRGhvMWin+Ou/n2Cd7DdvYzY=
+github.com/blevesearch/zapx/v13 v13.2.2 h1:6oa7kZhywrRT3CeuN0bIZC6EviQkbPOQpvdR5pnxCks=
+github.com/blevesearch/zapx/v13 v13.2.2/go.mod h1:EeLDRSUIMxBFwD4hZxVioHLR7gpY4VF7kdUoImp4Vy8=
+github.com/blevesearch/zapx/v14 v14.2.2 h1:lRZCBvQIByW8F+mCoY4uA6Uen2DCj7K+wCh5nVKNxew=
+github.com/blevesearch/zapx/v14 v14.2.2/go.mod h1:zb01unR63/DfV2nvyAvgj64K8AKtj7WP6w23jtuHntQ=
+github.com/blevesearch/zapx/v15 v15.2.2 h1:5+oWWAQTV3M0UNor05qrZujzxIXVuwtYU3ppZ7Y1aNI=
+github.com/blevesearch/zapx/v15 v15.2.2/go.mod h1:I4QVJ432LKkZyNK1kZkh3OweKa+NSblZzIF0YSSExak=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@@ -39,11 +44,11 @@ github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwc
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dvyukov/go-fuzz v0.0.0-20210429054444-fca39067bc72/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
+github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
 github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 h1:Ujru1hufTHVb++eG6OuNDKMxZnGIvF6o/u8q/8h2+I4=
 github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
-github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31 h1:gclg6gY70GLy3PbkQ1AERPfmLMMagS60DKF78eWwLn8=
 github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
@@ -51,14 +56,12 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw=
 github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/kljensen/snowball v0.6.0 h1:6DZLCcZeL0cLfodx+Md4/OLC6b/bfurWUOUGs1ydfOU=
 github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
@@ -74,12 +77,12 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
 github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ=
 github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563 h1:dY6ETXrvDG7Sa4vE8ZQG4yqWg6UnOcbqTAahkV813vQ=
 github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/robertkrimen/godocdown v0.0.0-20130622164427-0bfa04905481/go.mod h1:C9WhFzY47SzYBIvzFqSvHIR6ROgDo4TtdTuRaOMjF/s=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -89,34 +92,55 @@ github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb6
 github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/stephens2424/writerset v1.0.2/go.mod h1:aS2JhsMn6eA7e82oNmW4rfsgAOp9COBTTl8mzkwADnc=
 github.com/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
 github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
 github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
-github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
 github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc=
 github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
 go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200928182047-19e03678916f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
diff --git a/index.go b/index.go
index e08271e5..e297122f 100644
--- a/index.go
+++ b/index.go
@@ -16,6 +16,7 @@ package bleve
 
 import (
 	"context"
+
 	"github.com/blevesearch/bleve/v2/index/upsidedown"
 
 	"github.com/blevesearch/bleve/v2/document"
@@ -306,3 +307,15 @@ type Builder interface {
 func NewBuilder(path string, mapping mapping.IndexMapping, config map[string]interface{}) (Builder, error) {
 	return newBuilder(path, mapping, config)
 }
+
+// IndexCopyable is an index which supports an online copy operation
+// of the index.
+type IndexCopyable interface {
+	// CopyTo creates a fully functional copy of the index at the
+	// specified destination directory implementation.
+	CopyTo(d index.Directory) error
+}
+
+// FileSystemDirectory is the default implementation for the
+// index.Directory interface.
+type FileSystemDirectory string
diff --git a/index/scorch/builder.go b/index/scorch/builder.go
index 3311bd02..5e64230c 100644
--- a/index/scorch/builder.go
+++ b/index/scorch/builder.go
@@ -304,7 +304,7 @@ func (o *Builder) Close() error {
 	}
 
 	// fill the root bolt with this fake index snapshot
-	_, _, err = prepareBoltSnapshot(is, tx, o.path, o.segPlugin)
+	_, _, err = prepareBoltSnapshot(is, tx, o.path, o.segPlugin, nil)
 	if err != nil {
 		_ = tx.Rollback()
 		_ = rootBolt.Close()
diff --git a/index/scorch/introducer.go b/index/scorch/introducer.go
index 8516d411..74373c4a 100644
--- a/index/scorch/introducer.go
+++ b/index/scorch/introducer.go
@@ -46,6 +46,17 @@ type epochWatcher struct {
 }
 
 func (s *Scorch) introducerLoop() {
+	defer func() {
+		if r := recover(); r != nil {
+			s.fireAsyncError(&AsyncPanicError{
+				Source: "introducer",
+				Path:   s.path,
+			})
+		}
+
+		s.asyncTasks.Done()
+	}()
+
 	var epochWatchers []*epochWatcher
 OUTER:
 	for {
@@ -88,8 +99,6 @@ OUTER:
 		}
 		epochWatchers = epochWatchersNext
 	}
-
-	s.asyncTasks.Done()
 }
 
 func (s *Scorch) introduceSegment(next *segmentIntroduction) error {
diff --git a/index/scorch/merge.go b/index/scorch/merge.go
index 1e269afc..422527c6 100644
--- a/index/scorch/merge.go
+++ b/index/scorch/merge.go
@@ -29,12 +29,22 @@ import (
 )
 
 func (s *Scorch) mergerLoop() {
+	defer func() {
+		if r := recover(); r != nil {
+			s.fireAsyncError(&AsyncPanicError{
+				Source: "merger",
+				Path:   s.path,
+			})
+		}
+
+		s.asyncTasks.Done()
+	}()
+
 	var lastEpochMergePlanned uint64
 	var ctrlMsg *mergerCtrl
 	mergePlannerOptions, err := s.parseMergePlannerOptions()
 	if err != nil {
 		s.fireAsyncError(fmt.Errorf("mergePlannerOption json parsing err: %v", err))
-		s.asyncTasks.Done()
 		return
 	}
 	ctrlMsgDflt := &mergerCtrl{ctx: context.Background(),
@@ -130,8 +140,6 @@ OUTER:
 
 		atomic.AddUint64(&s.stats.TotFileMergeLoopEnd, 1)
 	}
-
-	s.asyncTasks.Done()
 }
 
 type mergerCtrl struct {
@@ -209,32 +217,32 @@ func (s *Scorch) parseMergePlannerOptions() (*mergeplan.MergePlanOptions,
 }
 
 type closeChWrapper struct {
-	ch1     chan struct{}
-	ctx     context.Context
-	closeCh chan struct{}
+	ch1      chan struct{}
+	ctx      context.Context
+	closeCh  chan struct{}
+	cancelCh chan struct{}
 }
 
 func newCloseChWrapper(ch1 chan struct{},
 	ctx context.Context) *closeChWrapper {
-	return &closeChWrapper{ch1: ch1,
-		ctx:     ctx,
-		closeCh: make(chan struct{})}
+	return &closeChWrapper{
+		ch1:      ch1,
+		ctx:      ctx,
+		closeCh:  make(chan struct{}),
+		cancelCh: make(chan struct{}),
+	}
 }
 
 func (w *closeChWrapper) close() {
-	select {
-	case <-w.closeCh:
-	default:
-		close(w.closeCh)
-	}
+	close(w.closeCh)
 }
 
 func (w *closeChWrapper) listen() {
 	select {
 	case <-w.ch1:
-		w.close()
+		close(w.cancelCh)
 	case <-w.ctx.Done():
-		w.close()
+		close(w.cancelCh)
 	case <-w.closeCh:
 	}
 }
@@ -320,7 +328,7 @@ func (s *Scorch) planMergeAtSnapshot(ctx context.Context,
 
 			atomic.AddUint64(&s.stats.TotFileMergeZapBeg, 1)
 			newDocNums, _, err := s.segPlugin.Merge(segmentsToMerge, docsToDrop, path,
-				cw.closeCh, s)
+				cw.cancelCh, s)
 			atomic.AddUint64(&s.stats.TotFileMergeZapEnd, 1)
 
 			fileMergeZapTime := uint64(time.Since(fileMergeZapStartTime))
diff --git a/index/scorch/persister.go b/index/scorch/persister.go
index 36a0379e..670f0f82 100644
--- a/index/scorch/persister.go
+++ b/index/scorch/persister.go
@@ -19,6 +19,7 @@ import (
 	"encoding/binary"
 	"encoding/json"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"log"
 	"math"
@@ -82,7 +83,16 @@ type persisterOptions struct {
 type notificationChan chan struct{}
 
 func (s *Scorch) persisterLoop() {
-	defer s.asyncTasks.Done()
+	defer func() {
+		if r := recover(); r != nil {
+			s.fireAsyncError(&AsyncPanicError{
+				Source: "persister",
+				Path:   s.path,
+			})
+		}
+
+		s.asyncTasks.Done()
+	}()
 
 	var persistWatchers []*epochWatcher
 	var lastPersistedEpoch, lastMergedEpoch uint64
@@ -93,7 +103,6 @@ func (s *Scorch) persisterLoop() {
 	po, err := s.parsePersisterOptions()
 	if err != nil {
 		s.fireAsyncError(fmt.Errorf("persisterOptions json parsing err: %v", err))
-		s.asyncTasks.Done()
 		return
 	}
 
@@ -428,8 +437,59 @@ func (s *Scorch) persistSnapshotMaybeMerge(snapshot *IndexSnapshot) (
 	return true, nil
 }
 
+func copyToDirectory(srcPath string, d index.Directory) (int64, error) {
+	if d == nil {
+		return 0, nil
+	}
+
+	dest, err := d.GetWriter(filepath.Join("store", filepath.Base(srcPath)))
+	if err != nil {
+		return 0, fmt.Errorf("GetWriter err: %v", err)
+	}
+
+	sourceFileStat, err := os.Stat(srcPath)
+	if err != nil {
+		return 0, err
+	}
+
+	if !sourceFileStat.Mode().IsRegular() {
+		return 0, fmt.Errorf("%s is not a regular file", srcPath)
+	}
+
+	source, err := os.Open(srcPath)
+	if err != nil {
+		return 0, err
+	}
+	defer source.Close()
+	defer dest.Close()
+	return io.Copy(dest, source)
+}
+
+func persistToDirectory(seg segment.UnpersistedSegment, d index.Directory,
+	path string) error {
+	if d == nil {
+		return seg.Persist(path)
+	}
+
+	sg, ok := seg.(io.WriterTo)
+	if !ok {
+		return fmt.Errorf("no io.WriterTo segment implementation found")
+	}
+
+	w, err := d.GetWriter(filepath.Join("store", filepath.Base(path)))
+	if err != nil {
+		return err
+	}
+
+	_, err = sg.WriteTo(w)
+	w.Close()
+
+	return err
+}
+
 func prepareBoltSnapshot(snapshot *IndexSnapshot, tx *bolt.Tx, path string,
-	segPlugin SegmentPlugin) ([]string, map[uint64]string, error) {
+	segPlugin SegmentPlugin, d index.Directory) (
+	[]string, map[uint64]string, error) {
 	snapshotsBucket, err := tx.CreateBucketIfNotExists(boltSnapshotsBucket)
 	if err != nil {
 		return nil, nil, err
@@ -482,7 +542,11 @@ func prepareBoltSnapshot(snapshot *IndexSnapshot, tx *bolt.Tx, path string,
 		switch seg := segmentSnapshot.segment.(type) {
 		case segment.PersistedSegment:
 			segPath := seg.Path()
-			filename := strings.TrimPrefix(segPath, path+string(os.PathSeparator))
+			_, err = copyToDirectory(segPath, d)
+			if err != nil {
+				return nil, nil, fmt.Errorf("segment: %s copy err: %v", segPath, err)
+			}
+			filename := filepath.Base(segPath)
 			err = snapshotSegmentBucket.Put(boltPathKey, []byte(filename))
 			if err != nil {
 				return nil, nil, err
@@ -491,10 +555,10 @@ func prepareBoltSnapshot(snapshot *IndexSnapshot, tx *bolt.Tx, path string,
 		case segment.UnpersistedSegment:
 			// need to persist this to disk
 			filename := zapFileName(segmentSnapshot.id)
-			path := path + string(os.PathSeparator) + filename
-			err = seg.Persist(path)
+			path := filepath.Join(path, filename)
+			err := persistToDirectory(seg, d, path)
 			if err != nil {
-				return nil, nil, fmt.Errorf("error persisting segment: %v", err)
+				return nil, nil, fmt.Errorf("segment: %s persist err: %v", path, err)
 			}
 			newSegmentPaths[segmentSnapshot.id] = path
 			err = snapshotSegmentBucket.Put(boltPathKey, []byte(filename))
@@ -535,7 +599,7 @@ func (s *Scorch) persistSnapshotDirect(snapshot *IndexSnapshot) (err error) {
 		}
 	}()
 
-	filenames, newSegmentPaths, err := prepareBoltSnapshot(snapshot, tx, s.path, s.segPlugin)
+	filenames, newSegmentPaths, err := prepareBoltSnapshot(snapshot, tx, s.path, s.segPlugin, nil)
 	if err != nil {
 		return err
 	}
diff --git a/index/scorch/scorch.go b/index/scorch/scorch.go
index fa1aaebb..7d258f66 100644
--- a/index/scorch/scorch.go
+++ b/index/scorch/scorch.go
@@ -75,6 +75,16 @@ type Scorch struct {
 	segPlugin SegmentPlugin
 }
 
+// AsyncPanicError is passed to scorch asyncErrorHandler when panic occurs in scorch background process
+type AsyncPanicError struct {
+	Source string
+	Path   string
+}
+
+func (e *AsyncPanicError) Error() string {
+	return fmt.Sprintf("%s panic when processing %s", e.Source, e.Path)
+}
+
 type internalStats struct {
 	persistEpoch          uint64
 	persistSnapshotSize   uint64
diff --git a/index/scorch/snapshot_index.go b/index/scorch/snapshot_index.go
index ac2f3441..98282eae 100644
--- a/index/scorch/snapshot_index.go
+++ b/index/scorch/snapshot_index.go
@@ -18,6 +18,8 @@ import (
 	"container/heap"
 	"encoding/binary"
 	"fmt"
+	"os"
+	"path/filepath"
 	"reflect"
 	"sort"
 	"sync"
@@ -29,6 +31,7 @@ import (
 	segment "github.com/blevesearch/scorch_segment_api/v2"
 	"github.com/blevesearch/vellum"
 	lev "github.com/blevesearch/vellum/levenshtein"
+	bolt "go.etcd.io/bbolt"
 )
 
 // re usable, threadsafe levenshtein builders
@@ -762,3 +765,46 @@ OUTER:
 	}
 	return rv
 }
+
+func (i *IndexSnapshot) CopyTo(d index.Directory) error {
+	// get the root bolt file.
+	w, err := d.GetWriter(filepath.Join("store", "root.bolt"))
+	if err != nil || w == nil {
+		return fmt.Errorf("failed to create the root.bolt file, err: %v", err)
+	}
+	rootFile, ok := w.(*os.File)
+	if !ok {
+		return fmt.Errorf("invalid root.bolt file found")
+	}
+
+	copyBolt, err := bolt.Open(rootFile.Name(), 0600, nil)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		w.Close()
+		if cerr := copyBolt.Close(); cerr != nil && err == nil {
+			err = cerr
+		}
+	}()
+
+	// start a write transaction
+	tx, err := copyBolt.Begin(true)
+	if err != nil {
+		return err
+	}
+
+	_, _, err = prepareBoltSnapshot(i, tx, "", i.parent.segPlugin, d)
+	if err != nil {
+		_ = tx.Rollback()
+		return fmt.Errorf("error backing up index snapshot: %v", err)
+	}
+
+	// commit bolt data
+	err = tx.Commit()
+	if err != nil {
+		return fmt.Errorf("error commit tx to backup root bolt: %v", err)
+	}
+
+	return copyBolt.Sync()
+}
diff --git a/index_impl.go b/index_impl.go
index 879a3663..8a9cfd3b 100644
--- a/index_impl.go
+++ b/index_impl.go
@@ -18,7 +18,9 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"io"
 	"os"
+	"path/filepath"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -910,3 +912,49 @@ func (m *searchHitSorter) Less(i, j int) bool {
 	c := m.sort.Compare(m.cachedScoring, m.cachedDesc, m.hits[i], m.hits[j])
 	return c < 0
 }
+
+func (i *indexImpl) CopyTo(d index.Directory) (err error) {
+	i.mutex.RLock()
+	defer i.mutex.RUnlock()
+
+	if !i.open {
+		return ErrorIndexClosed
+	}
+
+	indexReader, err := i.i.Reader()
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if cerr := indexReader.Close(); err == nil && cerr != nil {
+			err = cerr
+		}
+	}()
+
+	irc, ok := indexReader.(IndexCopyable)
+	if !ok {
+		return fmt.Errorf("index implementation does not support copy")
+	}
+
+	err = irc.CopyTo(d)
+	if err != nil {
+		return fmt.Errorf("error copying index metadata: %v", err)
+	}
+
+	// copy the metadata
+	return i.meta.CopyTo(d)
+}
+
+func (f FileSystemDirectory) GetWriter(filePath string) (io.WriteCloser,
+	error) {
+	dir, file := filepath.Split(filePath)
+	if dir != "" {
+		err := os.MkdirAll(filepath.Join(string(f), dir), os.ModePerm)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return os.OpenFile(filepath.Join(string(f), dir, file),
+		os.O_RDWR|os.O_CREATE, 0600)
+}
diff --git a/index_meta.go b/index_meta.go
index fe0ddeba..711e0c1b 100644
--- a/index_meta.go
+++ b/index_meta.go
@@ -16,11 +16,13 @@ package bleve
 
 import (
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
 
 	"github.com/blevesearch/bleve/v2/index/upsidedown"
+	index "github.com/blevesearch/bleve_index_api"
 )
 
 const metaFilename = "index_meta.json"
@@ -92,6 +94,23 @@ func (i *indexMeta) Save(path string) (err error) {
 	return nil
 }
 
+func (i *indexMeta) CopyTo(d index.Directory) (err error) {
+	metaBytes, err := json.Marshal(i)
+	if err != nil {
+		return err
+	}
+
+	w, err := d.GetWriter(metaFilename)
+	if w == nil || err != nil {
+		return fmt.Errorf("invalid writer for file: %s, err: %v",
+			metaFilename, err)
+	}
+	defer w.Close()
+
+	_, err = w.Write(metaBytes)
+	return err
+}
+
 func indexMetaPath(path string) string {
 	return filepath.Join(path, metaFilename)
 }
diff --git a/search/collector/topn.go b/search/collector/topn.go
index aa1d65bd..13d31e06 100644
--- a/search/collector/topn.go
+++ b/search/collector/topn.go
@@ -83,7 +83,7 @@ func NewTopNCollector(size int, skip int, sort search.SortOrder) *TopNCollector
 	return newTopNCollector(size, skip, sort)
 }
 
-// NewTopNCollector builds a collector to find the top 'size' hits
+// NewTopNCollectorAfter builds a collector to find the top 'size' hits
 // skipping over the first 'skip' hits
 // ordering hits by the provided sort order
 func NewTopNCollectorAfter(size int, sort search.SortOrder, after []string) *TopNCollector {
@@ -235,9 +235,7 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher,
 
 	// compute search duration
 	hc.took = time.Since(startTime)
-	if err != nil {
-		return err
-	}
+
 	// finalize actual results
 	err = hc.finalizeResults(reader)
 	if err != nil {
diff --git a/search/search.go b/search/search.go
index 9277d529..d2dd3371 100644
--- a/search/search.go
+++ b/search/search.go
@@ -270,7 +270,7 @@ func (dm *DocumentMatch) Complete(prealloc []Location) []Location {
 		var needsDedupe bool
 
 		for i, ftl := range dm.FieldTermLocations {
-			if lastField != ftl.Field {
+			if i == 0 || lastField != ftl.Field {
 				lastField = ftl.Field
 
 				if dm.Locations == nil {
diff --git a/search/sort.go b/search/sort.go
index 496db47f..9ed9a78c 100644
--- a/search/sort.go
+++ b/search/sort.go
@@ -21,12 +21,13 @@ import (
 	"math"
 	"sort"
 	"strings"
+	"unicode/utf8"
 
 	"github.com/blevesearch/bleve/v2/geo"
 	"github.com/blevesearch/bleve/v2/numeric"
 )
 
-var HighTerm = strings.Repeat(string([]byte{0xff}), 10)
+var HighTerm = strings.Repeat(string(utf8.MaxRune), 3)
 var LowTerm = string([]byte{0x00})
 
 type SearchSort interface {


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f57aa35e..ac4529f7 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,7 +8,7 @@ jobs:
   test:
     strategy:
       matrix:
-        go-version: [1.13.x, 1.14.x, 1.15.x]
+        go-version: [1.13.x, 1.14.x, 1.15.x, 1.16.x, 1.17.x]
         platform: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.platform }}
     steps:
diff --git a/analysis/token/hierarchy/hierarchy_test.go b/analysis/token/hierarchy/hierarchy_test.go
new file mode 100644
index 00000000..74d24bfd
--- /dev/null
+++ b/analysis/token/hierarchy/hierarchy_test.go
@@ -0,0 +1,229 @@
+package hierarchy
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/blevesearch/bleve/v2/analysis"
+)
+
+func TestHierarchyFilter(t *testing.T) {
+
+	tests := []struct {
+		name       string
+		delimiter  string
+		max        int
+		splitInput bool
+
+		input  analysis.TokenStream
+		output analysis.TokenStream
+	}{
+		{
+			name: "single token a/b/c, delimiter /",
+			input: analysis.TokenStream{
+				&analysis.Token{
+					Term: []byte("a/b/c"),
+				},
+			},
+			output: analysis.TokenStream{
+				&analysis.Token{
+					Term:     []byte("a"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      1,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      3,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b/c"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      5,
+					Position: 1,
+				},
+			},
+			delimiter:  "/",
+			max:        10,
+			splitInput: true,
+		},
+		{
+			name: "multiple tokens already split a b c, delimiter /",
+			input: analysis.TokenStream{
+				&analysis.Token{
+					Term: []byte("a"),
+				},
+				&analysis.Token{
+					Term: []byte("b"),
+				},
+				&analysis.Token{
+					Term: []byte("c"),
+				},
+			},
+			output: analysis.TokenStream{
+				&analysis.Token{
+					Term:     []byte("a"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      1,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      3,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b/c"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      5,
+					Position: 1,
+				},
+			},
+			delimiter:  "/",
+			max:        10,
+			splitInput: true,
+		},
+		{
+			name: "single token a/b/c, delimiter /, limit 2",
+			input: analysis.TokenStream{
+				&analysis.Token{
+					Term: []byte("a/b/c"),
+				},
+			},
+			output: analysis.TokenStream{
+				&analysis.Token{
+					Term:     []byte("a"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      1,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      3,
+					Position: 1,
+				},
+			},
+			delimiter:  "/",
+			max:        2,
+			splitInput: true,
+		},
+		{
+			name: "multiple tokens already split a b c, delimiter /, limit 2",
+			input: analysis.TokenStream{
+				&analysis.Token{
+					Term: []byte("a"),
+				},
+				&analysis.Token{
+					Term: []byte("b"),
+				},
+				&analysis.Token{
+					Term: []byte("c"),
+				},
+			},
+			output: analysis.TokenStream{
+				&analysis.Token{
+					Term:     []byte("a"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      1,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      3,
+					Position: 1,
+				},
+			},
+			delimiter:  "/",
+			max:        2,
+			splitInput: true,
+		},
+
+		{
+			name: "single token a/b/c, delimiter /, no split",
+			input: analysis.TokenStream{
+				&analysis.Token{
+					Term: []byte("a/b/c"),
+				},
+			},
+			output: analysis.TokenStream{
+				&analysis.Token{
+					Term:     []byte("a/b/c"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      5,
+					Position: 1,
+				},
+			},
+			delimiter:  "/",
+			max:        10,
+			splitInput: false,
+		},
+		{
+			name: "multiple tokens already split a b c, delimiter /, no split",
+			input: analysis.TokenStream{
+				&analysis.Token{
+					Term: []byte("a"),
+				},
+				&analysis.Token{
+					Term: []byte("b"),
+				},
+				&analysis.Token{
+					Term: []byte("c"),
+				},
+			},
+			output: analysis.TokenStream{
+				&analysis.Token{
+					Term:     []byte("a"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      1,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      3,
+					Position: 1,
+				},
+				&analysis.Token{
+					Term:     []byte("a/b/c"),
+					Type:     analysis.Shingle,
+					Start:    0,
+					End:      5,
+					Position: 1,
+				},
+			},
+			delimiter:  "/",
+			max:        10,
+			splitInput: false,
+		},
+	}
+
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			filter := NewHierarchyFilter([]byte(test.delimiter), test.max, test.splitInput)
+			actual := filter.Filter(test.input)
+			if !reflect.DeepEqual(actual, test.output) {
+				t.Errorf("expected %s, got %s", test.output, actual)
+			}
+		})
+	}
+
+}
diff --git a/index_test.go b/index_test.go
index a3d0d1dc..c0af3689 100644
--- a/index_test.go
+++ b/index_test.go
@@ -2261,3 +2261,227 @@ func TestIndexMappingDocValuesDynamic(t *testing.T) {
 		t.Fatalf("Expected DocValuesDynamic to remain false after the index mapping edit")
 	}
 }
+
+func TestCopyIndex(t *testing.T) {
+	tmpIndexPath := createTmpIndexPath(t)
+	defer cleanupTmpIndexPath(t, tmpIndexPath)
+
+	idx, err := New(tmpIndexPath, NewIndexMapping())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		err := idx.Close()
+		if err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	doca := map[string]interface{}{
+		"name": "tester",
+		"desc": "gophercon india testing",
+	}
+	err = idx.Index("a", doca)
+	if err != nil {
+		t.Error(err)
+	}
+
+	docy := map[string]interface{}{
+		"name": "jasper",
+		"desc": "clojure",
+	}
+	err = idx.Index("y", docy)
+	if err != nil {
+		t.Error(err)
+	}
+
+	err = idx.Delete("y")
+	if err != nil {
+		t.Error(err)
+	}
+
+	docx := map[string]interface{}{
+		"name": "rose",
+		"desc": "xoogler",
+	}
+	err = idx.Index("x", docx)
+	if err != nil {
+		t.Error(err)
+	}
+
+	err = idx.SetInternal([]byte("status"), []byte("pending"))
+	if err != nil {
+		t.Error(err)
+	}
+
+	docb := map[string]interface{}{
+		"name": "sree",
+		"desc": "cbft janitor",
+	}
+	batch := idx.NewBatch()
+	err = batch.Index("b", docb)
+	if err != nil {
+		t.Error(err)
+	}
+	batch.Delete("x")
+	batch.SetInternal([]byte("batchi"), []byte("batchv"))
+	batch.DeleteInternal([]byte("status"))
+	err = idx.Batch(batch)
+	if err != nil {
+		t.Error(err)
+	}
+	val, err := idx.GetInternal([]byte("batchi"))
+	if err != nil {
+		t.Error(err)
+	}
+	if string(val) != "batchv" {
+		t.Errorf("expected 'batchv', got '%s'", val)
+	}
+	val, err = idx.GetInternal([]byte("status"))
+	if err != nil {
+		t.Error(err)
+	}
+	if val != nil {
+		t.Errorf("expected nil, got '%s'", val)
+	}
+
+	err = idx.SetInternal([]byte("seqno"), []byte("7"))
+	if err != nil {
+		t.Error(err)
+	}
+	err = idx.SetInternal([]byte("status"), []byte("ready"))
+	if err != nil {
+		t.Error(err)
+	}
+	err = idx.DeleteInternal([]byte("status"))
+	if err != nil {
+		t.Error(err)
+	}
+	val, err = idx.GetInternal([]byte("status"))
+	if err != nil {
+		t.Error(err)
+	}
+	if val != nil {
+		t.Errorf("expected nil, got '%s'", val)
+	}
+
+	val, err = idx.GetInternal([]byte("seqno"))
+	if err != nil {
+		t.Error(err)
+	}
+	if string(val) != "7" {
+		t.Errorf("expected '7', got '%s'", val)
+	}
+
+	count, err := idx.DocCount()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if count != 2 {
+		t.Errorf("expected doc count 2, got %d", count)
+	}
+
+	doc, err := idx.Document("a")
+	if err != nil {
+		t.Fatal(err)
+	}
+	foundNameField := false
+	doc.VisitFields(func(field index.Field) {
+		if field.Name() == "name" && string(field.Value()) == "tester" {
+			foundNameField = true
+		}
+	})
+	if !foundNameField {
+		t.Errorf("expected to find field named 'name' with value 'tester'")
+	}
+
+	fields, err := idx.Fields()
+	if err != nil {
+		t.Fatal(err)
+	}
+	expectedFields := map[string]bool{
+		"_all": false,
+		"name": false,
+		"desc": false,
+	}
+	if len(fields) < len(expectedFields) {
+		t.Fatalf("expected %d fields got %d", len(expectedFields), len(fields))
+	}
+	for _, f := range fields {
+		expectedFields[f] = true
+	}
+	for ef, efp := range expectedFields {
+		if !efp {
+			t.Errorf("field %s is missing", ef)
+		}
+	}
+
+	// now create a copy of the index, and repeat assertions on it
+	copyableIndex, ok := idx.(IndexCopyable)
+	if !ok {
+		t.Fatal("index doesn't support copy")
+	}
+	backupIndexPath := createTmpIndexPath(t)
+	defer cleanupTmpIndexPath(t, backupIndexPath)
+
+	err = copyableIndex.CopyTo(FileSystemDirectory(backupIndexPath))
+	if err != nil {
+		t.Fatalf("error copying the index: %v", err)
+	}
+
+	// open the copied  index
+	idxCopied, err := Open(backupIndexPath)
+	if err != nil {
+		t.Fatalf("unable to open copy index")
+	}
+	defer func() {
+		err := idxCopied.Close()
+		if err != nil {
+			t.Fatalf("error closing copy index: %v", err)
+		}
+	}()
+
+	// assertions on copied index
+	copyCount, err := idxCopied.DocCount()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if copyCount != 2 {
+		t.Errorf("expected doc count 2, got %d", copyCount)
+	}
+
+	copyDoc, err := idxCopied.Document("a")
+	if err != nil {
+		t.Fatal(err)
+	}
+	copyFoundNameField := false
+	copyDoc.VisitFields(func(field index.Field) {
+		if field.Name() == "name" && string(field.Value()) == "tester" {
+			copyFoundNameField = true
+		}
+	})
+	if !copyFoundNameField {
+		t.Errorf("expected copy index to find field named 'name' with value 'tester'")
+	}
+
+	copyFields, err := idx.Fields()
+	if err != nil {
+		t.Fatal(err)
+	}
+	copyExpectedFields := map[string]bool{
+		"_all": false,
+		"name": false,
+		"desc": false,
+	}
+	if len(copyFields) < len(copyExpectedFields) {
+		t.Fatalf("expected %d fields got %d", len(copyExpectedFields), len(copyFields))
+	}
+	for _, f := range copyFields {
+		copyExpectedFields[f] = true
+	}
+	for ef, efp := range copyExpectedFields {
+		if !efp {
+			t.Errorf("copy field %s is missing", ef)
+		}
+	}
+}
diff --git a/search/search_test.go b/search/search_test.go
index f15dc061..af813911 100644
--- a/search/search_test.go
+++ b/search/search_test.go
@@ -15,6 +15,7 @@
 package search
 
 import (
+	"encoding/json"
 	"reflect"
 	"testing"
 )
@@ -74,3 +75,20 @@ func TestLocationsDedupe(t *testing.T) {
 		}
 	}
 }
+
+func TestMarshallingHighTerm(t *testing.T) {
+	highTermBytes, err := json.Marshal(HighTerm)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var unmarshalledHighTerm string
+	err = json.Unmarshal(highTermBytes, &unmarshalledHighTerm)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if unmarshalledHighTerm != HighTerm {
+		t.Fatalf("unexpected %x != %x", unmarshalledHighTerm, HighTerm)
+	}
+}