mirror of https://github.com/perkeep/perkeep.git
1209 lines
32 KiB
Go
1209 lines
32 KiB
Go
/*
|
|
Copyright 2013 The Camlistore Authors
|
|
|
|
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 index
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"camlistore.org/pkg/blob"
|
|
"camlistore.org/pkg/context"
|
|
"camlistore.org/pkg/osutil"
|
|
"camlistore.org/pkg/schema"
|
|
"camlistore.org/pkg/sorted"
|
|
"camlistore.org/pkg/strutil"
|
|
"camlistore.org/pkg/syncutil"
|
|
"camlistore.org/pkg/types/camtypes"
|
|
)
|
|
|
|
// Corpus is an in-memory summary of all of a user's blobs' metadata.
|
|
type Corpus struct {
|
|
mu sync.RWMutex
|
|
//mu syncutil.RWMutexTracker // when debugging
|
|
|
|
// building is true at start while scanning all rows in the
|
|
// index. While building, certain invariants (like things
|
|
// being sorted) can be temporarily violated and fixed at the
|
|
// end of scan.
|
|
building bool
|
|
|
|
// gen is incremented on every blob received.
|
|
// It's used as a query cache invalidator.
|
|
gen int64
|
|
|
|
strs map[string]string // interned strings
|
|
brOfStr map[string]blob.Ref // blob.Parse fast path
|
|
brInterns int64 // blob.Ref -> blob.Ref, via br method
|
|
|
|
blobs map[blob.Ref]*camtypes.BlobMeta
|
|
sumBlobBytes int64
|
|
|
|
// camlBlobs maps from camliType ("file") to blobref to the meta.
|
|
// The value is the same one in blobs.
|
|
camBlobs map[string]map[blob.Ref]*camtypes.BlobMeta
|
|
|
|
// TODO: add GoLLRB to third_party; keep sorted BlobMeta
|
|
keyId map[blob.Ref]string
|
|
files map[blob.Ref]camtypes.FileInfo
|
|
permanodes map[blob.Ref]*PermanodeMeta
|
|
imageInfo map[blob.Ref]camtypes.ImageInfo // keyed by fileref (not wholeref)
|
|
fileWholeRef map[blob.Ref]blob.Ref // fileref -> its wholeref (TODO: multi-valued?)
|
|
gps map[blob.Ref]latLong // wholeRef -> GPS coordinates
|
|
|
|
// edge tracks "forward" edges. e.g. from a directory's static-set to
|
|
// its members. Permanodes' camliMembers aren't tracked, since they
|
|
// can be obtained from permanodes.Claims.
|
|
// TODO: implement
|
|
edge map[blob.Ref][]edge
|
|
|
|
// edgeBack tracks "backward" edges. e.g. from a file back to
|
|
// any directories it's part of.
|
|
// The map is from target (e.g. file) => owner (static-set).
|
|
// This only tracks static data structures, not permanodes.
|
|
// TODO: implement
|
|
edgeBack map[blob.Ref]map[blob.Ref]bool
|
|
|
|
// claimBack allows hopping backwards from a Claim's Value
|
|
// when the Value is a blobref. It allows, for example,
|
|
// finding the parents of camliMember claims. If a permanode
|
|
// parent set A has a camliMembers B and C, it allows finding
|
|
// A from either B and C.
|
|
// The slice is not sorted.
|
|
claimBack map[blob.Ref][]*camtypes.Claim
|
|
|
|
// TOOD: use deletedCache instead?
|
|
deletedBy map[blob.Ref]blob.Ref // key is deleted by value
|
|
// deletes tracks deletions of claims and permanodes. The key is
|
|
// the blobref of a claim or permanode. The values, sorted newest first,
|
|
// contain the blobref of the claim responsible for the deletion, as well
|
|
// as the date when that deletion happened.
|
|
deletes map[blob.Ref][]deletion
|
|
|
|
mediaTags map[blob.Ref]map[string]string // wholeref -> "album" -> "foo"
|
|
|
|
// scratch string slice
|
|
ss []string
|
|
}
|
|
|
|
type latLong struct {
|
|
lat, long float64
|
|
}
|
|
|
|
// RLock locks the Corpus for reads. It must be used for any "Locked" methods.
|
|
func (c *Corpus) RLock() { c.mu.RLock() }
|
|
|
|
// RUnlock unlocks the Corpus for reads.
|
|
func (c *Corpus) RUnlock() { c.mu.RUnlock() }
|
|
|
|
// IsDeleted reports whether the provided blobref (of a permanode or claim) should be considered deleted.
|
|
func (c *Corpus) IsDeleted(br blob.Ref) bool {
|
|
c.RLock()
|
|
defer c.RUnlock()
|
|
return c.IsDeletedLocked(br)
|
|
}
|
|
|
|
// IsDeletedLocked is the version of IsDeleted that assumes the Corpus is already locked with RLock.
|
|
func (c *Corpus) IsDeletedLocked(br blob.Ref) bool {
|
|
for _, v := range c.deletes[br] {
|
|
if !c.IsDeletedLocked(v.deleter) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type edge struct {
|
|
edgeType string
|
|
peer blob.Ref
|
|
}
|
|
|
|
type PermanodeMeta struct {
|
|
// TODO: OwnerKeyId string
|
|
Claims []*camtypes.Claim // sorted by camtypes.ClaimsByDate
|
|
}
|
|
|
|
func newCorpus() *Corpus {
|
|
return &Corpus{
|
|
blobs: make(map[blob.Ref]*camtypes.BlobMeta),
|
|
camBlobs: make(map[string]map[blob.Ref]*camtypes.BlobMeta),
|
|
files: make(map[blob.Ref]camtypes.FileInfo),
|
|
permanodes: make(map[blob.Ref]*PermanodeMeta),
|
|
imageInfo: make(map[blob.Ref]camtypes.ImageInfo),
|
|
deletedBy: make(map[blob.Ref]blob.Ref),
|
|
keyId: make(map[blob.Ref]string),
|
|
brOfStr: make(map[string]blob.Ref),
|
|
fileWholeRef: make(map[blob.Ref]blob.Ref),
|
|
gps: make(map[blob.Ref]latLong),
|
|
mediaTags: make(map[blob.Ref]map[string]string),
|
|
deletes: make(map[blob.Ref][]deletion),
|
|
claimBack: make(map[blob.Ref][]*camtypes.Claim),
|
|
}
|
|
}
|
|
|
|
func NewCorpusFromStorage(s sorted.KeyValue) (*Corpus, error) {
|
|
if s == nil {
|
|
return nil, errors.New("storage is nil")
|
|
}
|
|
c := newCorpus()
|
|
return c, c.scanFromStorage(s)
|
|
}
|
|
|
|
func (x *Index) KeepInMemory() (*Corpus, error) {
|
|
var err error
|
|
x.corpus, err = NewCorpusFromStorage(x.s)
|
|
return x.corpus, err
|
|
}
|
|
|
|
// PreventStorageAccessForTesting causes any access to the index's underlying
|
|
// Storage interface to panic.
|
|
func (x *Index) PreventStorageAccessForTesting() {
|
|
x.s = crashStorage{}
|
|
}
|
|
|
|
type crashStorage struct {
|
|
sorted.KeyValue
|
|
}
|
|
|
|
func (crashStorage) Get(key string) (string, error) {
|
|
panic(fmt.Sprintf("unexpected KeyValue.Get(%q) called", key))
|
|
}
|
|
|
|
func (crashStorage) Find(start, end string) sorted.Iterator {
|
|
panic(fmt.Sprintf("unexpected KeyValue.Find(%q, %q) called", start, end))
|
|
}
|
|
|
|
// *********** Updating the corpus
|
|
|
|
var corpusMergeFunc = map[string]func(c *Corpus, k, v []byte) error{
|
|
"have": nil, // redundant with "meta"
|
|
"recpn": nil, // unneeded.
|
|
"meta": (*Corpus).mergeMetaRow,
|
|
"signerkeyid": (*Corpus).mergeSignerKeyIdRow,
|
|
"claim": (*Corpus).mergeClaimRow,
|
|
"fileinfo": (*Corpus).mergeFileInfoRow,
|
|
"filetimes": (*Corpus).mergeFileTimesRow,
|
|
"imagesize": (*Corpus).mergeImageSizeRow,
|
|
"wholetofile": (*Corpus).mergeWholeToFileRow,
|
|
"exifgps": (*Corpus).mergeEXIFGPSRow,
|
|
"exiftag": nil, // not using any for now
|
|
"signerattrvalue": nil, // ignoring for now
|
|
"mediatag": (*Corpus).mergeMediaTag,
|
|
}
|
|
|
|
func memstats() *runtime.MemStats {
|
|
ms := new(runtime.MemStats)
|
|
runtime.GC()
|
|
runtime.ReadMemStats(ms)
|
|
return ms
|
|
}
|
|
|
|
var logCorpusStats = true // set to false in tests
|
|
|
|
var slurpPrefixes = []string{
|
|
"meta:", // must be first
|
|
"signerkeyid:",
|
|
"claim|",
|
|
"fileinfo|",
|
|
"filetimes|",
|
|
"imagesize|",
|
|
"wholetofile|",
|
|
"exifgps|",
|
|
"mediatag|",
|
|
}
|
|
|
|
// Key types (without trailing punctuation) that we slurp to memory at start.
|
|
var slurpedKeyType = make(map[string]bool)
|
|
|
|
func init() {
|
|
for _, prefix := range slurpPrefixes {
|
|
slurpedKeyType[typeOfKey(prefix)] = true
|
|
}
|
|
}
|
|
|
|
func (c *Corpus) scanFromStorage(s sorted.KeyValue) error {
|
|
c.building = true
|
|
|
|
var ms0 *runtime.MemStats
|
|
if logCorpusStats {
|
|
ms0 = memstats()
|
|
log.Printf("Slurping corpus to memory from index...")
|
|
log.Printf("Slurping corpus to memory from index... (1/%d: meta rows)", len(slurpPrefixes))
|
|
}
|
|
|
|
// We do the "meta" rows first, before the prefixes below, because it
|
|
// populates the blobs map (used for blobref interning) and the camBlobs
|
|
// map (used for hinting the size of other maps)
|
|
if err := c.scanPrefix(s, "meta:"); err != nil {
|
|
return err
|
|
}
|
|
c.files = make(map[blob.Ref]camtypes.FileInfo, len(c.camBlobs["file"]))
|
|
c.permanodes = make(map[blob.Ref]*PermanodeMeta, len(c.camBlobs["permanode"]))
|
|
cpu0 := osutil.CPUUsage()
|
|
|
|
var grp syncutil.Group
|
|
for i, prefix := range slurpPrefixes[1:] {
|
|
if logCorpusStats {
|
|
log.Printf("Slurping corpus to memory from index... (%d/%d: prefix %q)", i+2, len(slurpPrefixes),
|
|
prefix[:len(prefix)-1])
|
|
}
|
|
prefix := prefix
|
|
grp.Go(func() error { return c.scanPrefix(s, prefix) })
|
|
}
|
|
if err := grp.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Post-load optimizations and restoration of invariants.
|
|
for _, pm := range c.permanodes {
|
|
// Restore invariants violated during building:
|
|
sort.Sort(camtypes.ClaimPtrsByDate(pm.Claims))
|
|
|
|
// And intern some stuff.
|
|
for _, cl := range pm.Claims {
|
|
cl.BlobRef = c.br(cl.BlobRef)
|
|
cl.Signer = c.br(cl.Signer)
|
|
cl.Permanode = c.br(cl.Permanode)
|
|
cl.Target = c.br(cl.Target)
|
|
}
|
|
|
|
}
|
|
c.brOfStr = nil // drop this now.
|
|
c.building = false
|
|
// log.V(1).Printf("interned blob.Ref = %d", c.brInterns)
|
|
|
|
if err := c.initDeletes(s); err != nil {
|
|
return fmt.Errorf("Could not populate the corpus deletes: %v", err)
|
|
}
|
|
|
|
if logCorpusStats {
|
|
cpu := osutil.CPUUsage() - cpu0
|
|
ms1 := memstats()
|
|
memUsed := ms1.Alloc - ms0.Alloc
|
|
if ms1.Alloc < ms0.Alloc {
|
|
memUsed = 0
|
|
}
|
|
log.Printf("Corpus stats: %.3f MiB mem: %d blobs (%.3f GiB) (%d schema (%d permanode, %d file (%d image), ...)",
|
|
float64(memUsed)/(1<<20),
|
|
len(c.blobs),
|
|
float64(c.sumBlobBytes)/(1<<30),
|
|
c.numSchemaBlobsLocked(),
|
|
len(c.permanodes),
|
|
len(c.files),
|
|
len(c.imageInfo))
|
|
log.Printf("Corpus scanning CPU usage: %v", cpu)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// initDeletes populates the corpus deletes from the delete entries in s.
|
|
func (c *Corpus) initDeletes(s sorted.KeyValue) (err error) {
|
|
it := queryPrefix(s, keyDeleted)
|
|
defer closeIterator(it, &err)
|
|
for it.Next() {
|
|
cl, ok := kvDeleted(it.Key())
|
|
if !ok {
|
|
return fmt.Errorf("Bogus keyDeleted entry key: want |\"deleted\"|<deleted blobref>|<reverse claimdate>|<deleter claim>|, got %q", it.Key())
|
|
}
|
|
targetDeletions := append(c.deletes[cl.Target],
|
|
deletion{
|
|
deleter: cl.BlobRef,
|
|
when: cl.Date,
|
|
})
|
|
sort.Sort(sort.Reverse(byDeletionDate(targetDeletions)))
|
|
c.deletes[cl.Target] = targetDeletions
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *Corpus) numSchemaBlobsLocked() (n int64) {
|
|
for _, m := range c.camBlobs {
|
|
n += int64(len(m))
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Corpus) scanPrefix(s sorted.KeyValue, prefix string) (err error) {
|
|
typeKey := typeOfKey(prefix)
|
|
fn, ok := corpusMergeFunc[typeKey]
|
|
if !ok {
|
|
panic("No registered merge func for prefix " + prefix)
|
|
}
|
|
|
|
n, t0 := 0, time.Now()
|
|
it := queryPrefixString(s, prefix)
|
|
defer closeIterator(it, &err)
|
|
for it.Next() {
|
|
n++
|
|
if n == 1 {
|
|
// Let the query be sent off and responses start flowing in before
|
|
// we take the lock. And if no rows: no lock.
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
}
|
|
if err := fn(c, it.KeyBytes(), it.ValueBytes()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if logCorpusStats {
|
|
d := time.Since(t0)
|
|
log.Printf("Scanned prefix %q: %d rows, %v", prefix[:len(prefix)-1], n, d)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) addBlob(br blob.Ref, mm *mutationMap) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if _, dup := c.blobs[br]; dup {
|
|
return nil
|
|
}
|
|
c.gen++
|
|
for k, v := range mm.kv {
|
|
kt := typeOfKey(k)
|
|
if !slurpedKeyType[kt] {
|
|
continue
|
|
}
|
|
if err := corpusMergeFunc[kt](c, []byte(k), []byte(v)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, cl := range mm.deletes {
|
|
if err := c.updateDeletes(cl); err != nil {
|
|
return fmt.Errorf("Could not update the deletes cache after deletion from %v: %v", cl, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateDeletes updates the corpus deletes with the delete claim deleteClaim.
|
|
// deleteClaim is trusted to be a valid delete Claim.
|
|
func (c *Corpus) updateDeletes(deleteClaim schema.Claim) error {
|
|
target := c.br(deleteClaim.Target())
|
|
deleter := deleteClaim.Blob()
|
|
when, err := deleter.ClaimDate()
|
|
if err != nil {
|
|
return fmt.Errorf("Could not get date of delete claim %v: %v", deleteClaim, err)
|
|
}
|
|
del := deletion{
|
|
deleter: c.br(deleter.BlobRef()),
|
|
when: when,
|
|
}
|
|
for _, v := range c.deletes[target] {
|
|
if v == del {
|
|
return nil
|
|
}
|
|
}
|
|
targetDeletions := append(c.deletes[target], del)
|
|
sort.Sort(sort.Reverse(byDeletionDate(targetDeletions)))
|
|
c.deletes[target] = targetDeletions
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) mergeMetaRow(k, v []byte) error {
|
|
bm, ok := kvBlobMeta_bytes(k, v)
|
|
if !ok {
|
|
return fmt.Errorf("bogus meta row: %q -> %q", k, v)
|
|
}
|
|
return c.mergeBlobMeta(bm)
|
|
}
|
|
|
|
func (c *Corpus) mergeBlobMeta(bm camtypes.BlobMeta) error {
|
|
if _, dup := c.blobs[bm.Ref]; dup {
|
|
panic("dup blob seen")
|
|
}
|
|
bm.CamliType = c.str(bm.CamliType)
|
|
|
|
c.blobs[bm.Ref] = &bm
|
|
c.sumBlobBytes += int64(bm.Size)
|
|
if bm.CamliType != "" {
|
|
m, ok := c.camBlobs[bm.CamliType]
|
|
if !ok {
|
|
m = make(map[blob.Ref]*camtypes.BlobMeta)
|
|
c.camBlobs[bm.CamliType] = m
|
|
}
|
|
m[bm.Ref] = &bm
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) mergeSignerKeyIdRow(k, v []byte) error {
|
|
br, ok := blob.ParseBytes(k[len("signerkeyid:"):])
|
|
if !ok {
|
|
return fmt.Errorf("bogus signerid row: %q -> %q", k, v)
|
|
}
|
|
c.keyId[br] = string(v)
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) mergeClaimRow(k, v []byte) error {
|
|
// TODO: update kvClaim to take []byte instead of string
|
|
cl, ok := kvClaim(string(k), string(v), c.blobParse)
|
|
if !ok || !cl.Permanode.Valid() {
|
|
return fmt.Errorf("bogus claim row: %q -> %q", k, v)
|
|
}
|
|
cl.Type = c.str(cl.Type)
|
|
cl.Attr = c.str(cl.Attr)
|
|
cl.Value = c.str(cl.Value) // less likely to intern, but some (tags) do
|
|
|
|
pn := c.br(cl.Permanode)
|
|
pm, ok := c.permanodes[pn]
|
|
if !ok {
|
|
pm = new(PermanodeMeta)
|
|
c.permanodes[pn] = pm
|
|
}
|
|
pm.Claims = append(pm.Claims, &cl)
|
|
if !c.building {
|
|
// Unless we're still starting up (at which we sort at
|
|
// the end instead), keep this sorted.
|
|
sort.Sort(camtypes.ClaimPtrsByDate(pm.Claims))
|
|
}
|
|
|
|
if vbr, ok := blob.Parse(cl.Value); ok {
|
|
c.claimBack[vbr] = append(c.claimBack[vbr], &cl)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) mergeFileInfoRow(k, v []byte) error {
|
|
// fileinfo|sha1-579f7f246bd420d486ddeb0dadbb256cfaf8bf6b" "5|some-stuff.txt|"
|
|
pipe := bytes.IndexByte(k, '|')
|
|
if pipe < 0 {
|
|
return fmt.Errorf("unexpected fileinfo key %q", k)
|
|
}
|
|
br, ok := blob.ParseBytes(k[pipe+1:])
|
|
if !ok {
|
|
return fmt.Errorf("unexpected fileinfo blobref in key %q", k)
|
|
}
|
|
|
|
// TODO: could at least use strutil.ParseUintBytes to not stringify and retain
|
|
// the length bytes of v.
|
|
c.ss = strutil.AppendSplitN(c.ss[:0], string(v), "|", 3)
|
|
if len(c.ss) != 3 {
|
|
return fmt.Errorf("unexpected fileinfo value %q", k)
|
|
}
|
|
size, err := strconv.ParseInt(c.ss[0], 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("unexpected fileinfo value %q", k)
|
|
}
|
|
c.mutateFileInfo(br, func(fi *camtypes.FileInfo) {
|
|
fi.Size = size
|
|
fi.FileName = c.str(urld(c.ss[1]))
|
|
fi.MIMEType = c.str(urld(c.ss[2]))
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) mergeFileTimesRow(k, v []byte) error {
|
|
if len(v) == 0 {
|
|
return nil
|
|
}
|
|
// "filetimes|sha1-579f7f246bd420d486ddeb0dadbb256cfaf8bf6b" "1970-01-01T00%3A02%3A03Z"
|
|
pipe := bytes.IndexByte(k, '|')
|
|
if pipe < 0 {
|
|
return fmt.Errorf("unexpected fileinfo key %q", k)
|
|
}
|
|
br, ok := blob.ParseBytes(k[pipe+1:])
|
|
if !ok {
|
|
return fmt.Errorf("unexpected filetimes blobref in key %q", k)
|
|
}
|
|
c.ss = strutil.AppendSplitN(c.ss[:0], urld(string(v)), ",", -1)
|
|
times := c.ss
|
|
c.mutateFileInfo(br, func(fi *camtypes.FileInfo) {
|
|
updateFileInfoTimes(fi, times)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (c *Corpus) mutateFileInfo(br blob.Ref, fn func(*camtypes.FileInfo)) {
|
|
br = c.br(br)
|
|
fi := c.files[br] // use zero value if not present
|
|
fn(&fi)
|
|
c.files[br] = fi
|
|
}
|
|
|
|
func (c *Corpus) mergeImageSizeRow(k, v []byte) error {
|
|
br, okk := blob.ParseBytes(k[len("imagesize|"):])
|
|
ii, okv := kvImageInfo(v)
|
|
if !okk || !okv {
|
|
return fmt.Errorf("bogus row %q = %q", k, v)
|
|
}
|
|
br = c.br(br)
|
|
c.imageInfo[br] = ii
|
|
return nil
|
|
}
|
|
|
|
// "wholetofile|sha1-17b53c7c3e664d3613dfdce50ef1f2a09e8f04b5|sha1-fb88f3eab3acfcf3cfc8cd77ae4366f6f975d227" -> "1"
|
|
func (c *Corpus) mergeWholeToFileRow(k, v []byte) error {
|
|
pair := k[len("wholetofile|"):]
|
|
pipe := bytes.IndexByte(pair, '|')
|
|
if pipe < 0 {
|
|
return fmt.Errorf("bogus row %q = %q", k, v)
|
|
}
|
|
wholeRef, ok1 := blob.ParseBytes(pair[:pipe])
|
|
fileRef, ok2 := blob.ParseBytes(pair[pipe+1:])
|
|
if !ok1 || !ok2 {
|
|
return fmt.Errorf("bogus row %q = %q", k, v)
|
|
}
|
|
c.fileWholeRef[fileRef] = wholeRef
|
|
return nil
|
|
}
|
|
|
|
// "mediatag|sha1-2b219be9d9691b4f8090e7ee2690098097f59566|album" = "Some+Album+Name"
|
|
func (c *Corpus) mergeMediaTag(k, v []byte) error {
|
|
f := strings.Split(string(k), "|")
|
|
if len(f) != 3 {
|
|
return fmt.Errorf("unexpected key %q", k)
|
|
}
|
|
wholeRef, ok := blob.Parse(f[1])
|
|
if !ok {
|
|
return fmt.Errorf("failed to parse wholeref from key %q", k)
|
|
}
|
|
tm, ok := c.mediaTags[wholeRef]
|
|
if !ok {
|
|
tm = make(map[string]string)
|
|
c.mediaTags[wholeRef] = tm
|
|
}
|
|
tm[c.str(f[2])] = c.str(urld(string(v)))
|
|
return nil
|
|
}
|
|
|
|
// "exifgps|sha1-17b53c7c3e664d3613dfdce50ef1f2a09e8f04b5" -> "-122.39897155555556|37.61952208333334"
|
|
func (c *Corpus) mergeEXIFGPSRow(k, v []byte) error {
|
|
wholeRef, ok := blob.ParseBytes(k[len("exifgps|"):])
|
|
pipe := bytes.IndexByte(v, '|')
|
|
if pipe < 0 || !ok {
|
|
return fmt.Errorf("bogus row %q = %q", k, v)
|
|
}
|
|
lat, err := strconv.ParseFloat(string(v[:pipe]), 64)
|
|
long, err1 := strconv.ParseFloat(string(v[pipe+1:]), 64)
|
|
if err != nil || err1 != nil {
|
|
return fmt.Errorf("bogus row %q = %q", k, v)
|
|
}
|
|
c.gps[wholeRef] = latLong{lat, long}
|
|
return nil
|
|
}
|
|
|
|
// This enables the blob.Parse fast path cache, which reduces CPU (via
|
|
// reduced GC from new garbage), but increases memory usage, even
|
|
// though it shouldn't. The GC should fully discard the brOfStr map
|
|
// (which we nil out at the end of parsing), but the Go GC doesn't
|
|
// seem to clear it all.
|
|
// TODO: investigate / file bugs.
|
|
const useBlobParseCache = false
|
|
|
|
func (c *Corpus) blobParse(v string) (br blob.Ref, ok bool) {
|
|
if useBlobParseCache {
|
|
br, ok = c.brOfStr[v]
|
|
if ok {
|
|
return
|
|
}
|
|
}
|
|
return blob.Parse(v)
|
|
}
|
|
|
|
// str returns s, interned.
|
|
func (c *Corpus) str(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if s, ok := c.strs[s]; ok {
|
|
return s
|
|
}
|
|
if c.strs == nil {
|
|
c.strs = make(map[string]string)
|
|
}
|
|
c.strs[s] = s
|
|
return s
|
|
}
|
|
|
|
// br returns br, interned.
|
|
func (c *Corpus) br(br blob.Ref) blob.Ref {
|
|
if bm, ok := c.blobs[br]; ok {
|
|
c.brInterns++
|
|
return bm.Ref
|
|
}
|
|
return br
|
|
}
|
|
|
|
// *********** Reading from the corpus
|
|
|
|
// EnumerateCamliBlobsLocked sends just camlistore meta blobs to ch.
|
|
//
|
|
// The Corpus must already be locked with RLock.
|
|
//
|
|
// If camType is empty, all camlistore blobs are sent, otherwise it specifies
|
|
// the camliType to send.
|
|
// ch is closed at the end. The err will either be nil or context.ErrCanceled.
|
|
func (c *Corpus) EnumerateCamliBlobsLocked(ctx *context.Context, camType string, ch chan<- camtypes.BlobMeta) error {
|
|
defer close(ch)
|
|
for t, m := range c.camBlobs {
|
|
if camType != "" && camType != t {
|
|
continue
|
|
}
|
|
for _, bm := range m {
|
|
select {
|
|
case ch <- *bm:
|
|
case <-ctx.Done():
|
|
return context.ErrCanceled
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnumerateBlobMetaLocked sends all known blobs to ch, or until the context is canceled.
|
|
//
|
|
// The Corpus must already be locked with RLock.
|
|
func (c *Corpus) EnumerateBlobMetaLocked(ctx *context.Context, ch chan<- camtypes.BlobMeta) error {
|
|
defer close(ch)
|
|
for _, bm := range c.blobs {
|
|
select {
|
|
case ch <- *bm:
|
|
case <-ctx.Done():
|
|
return context.ErrCanceled
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pnAndTime is a value type wrapping a permanode blobref and its modtime.
|
|
// It's used by EnumeratePermanodesLastModified and EnumeratePermanodesCreated.
|
|
type pnAndTime struct {
|
|
pn blob.Ref
|
|
t time.Time
|
|
}
|
|
|
|
type byPermanodeTime []pnAndTime
|
|
|
|
func (s byPermanodeTime) Len() int { return len(s) }
|
|
func (s byPermanodeTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
|
func (s byPermanodeTime) Less(i, j int) bool {
|
|
if s[i].t.Equal(s[j].t) {
|
|
return s[i].pn.Less(s[j].pn)
|
|
}
|
|
return s[i].t.Before(s[j].t)
|
|
}
|
|
|
|
func (c *Corpus) permanodesByModtimeLocked() []pnAndTime {
|
|
pns := make([]pnAndTime, 0, len(c.permanodes))
|
|
for pn := range c.permanodes {
|
|
if c.IsDeletedLocked(pn) {
|
|
continue
|
|
}
|
|
if modt, ok := c.PermanodeModtimeLocked(pn); ok {
|
|
pns = append(pns, pnAndTime{pn, modt})
|
|
}
|
|
}
|
|
return pns
|
|
}
|
|
|
|
// corpus must be (read) locked.
|
|
func (c *Corpus) sendPermanodes(ctx *context.Context, ch chan<- camtypes.BlobMeta, pns []pnAndTime) error {
|
|
for _, cand := range pns {
|
|
bm := c.blobs[cand.pn]
|
|
if bm == nil {
|
|
continue
|
|
}
|
|
select {
|
|
case ch <- *bm:
|
|
continue
|
|
case <-ctx.Done():
|
|
return context.ErrCanceled
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnumeratePermanodesLastModified sends all permanodes, sorted by most recently modified first, to ch,
|
|
// or until ctx is done.
|
|
//
|
|
// The Corpus must already be locked with RLock.
|
|
func (c *Corpus) EnumeratePermanodesLastModifiedLocked(ctx *context.Context, ch chan<- camtypes.BlobMeta) error {
|
|
defer close(ch)
|
|
|
|
pns := c.permanodesByModtimeLocked()
|
|
sort.Sort(sort.Reverse(byPermanodeTime(pns)))
|
|
return c.sendPermanodes(ctx, ch, pns)
|
|
}
|
|
|
|
func (c *Corpus) permanodesByTimeLocked() []pnAndTime {
|
|
// TODO: cache this
|
|
pns := make([]pnAndTime, 0, len(c.permanodes))
|
|
for pn := range c.permanodes {
|
|
if c.IsDeletedLocked(pn) {
|
|
continue
|
|
}
|
|
if pt, ok := c.PermanodeAnyTimeLocked(pn); ok {
|
|
pns = append(pns, pnAndTime{pn, pt})
|
|
}
|
|
}
|
|
return pns
|
|
}
|
|
|
|
// EnumeratePermanodesCreatedLocked sends all permanodes to ch, or until ctx is done.
|
|
// They are sorted using the contents creation date if any, the permanode modtime
|
|
// otherwise, and in the order specified by newestFirst.
|
|
//
|
|
// The Corpus must already be locked with RLock.
|
|
func (c *Corpus) EnumeratePermanodesCreatedLocked(ctx *context.Context, ch chan<- camtypes.BlobMeta, newestFirst bool) error {
|
|
defer close(ch)
|
|
|
|
pns := c.permanodesByTimeLocked()
|
|
if newestFirst {
|
|
sort.Sort(sort.Reverse(byPermanodeTime(pns)))
|
|
} else {
|
|
sort.Sort(byPermanodeTime(pns))
|
|
}
|
|
|
|
return c.sendPermanodes(ctx, ch, pns)
|
|
}
|
|
|
|
func (c *Corpus) GetBlobMeta(br blob.Ref) (camtypes.BlobMeta, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.GetBlobMetaLocked(br)
|
|
}
|
|
|
|
func (c *Corpus) GetBlobMetaLocked(br blob.Ref) (camtypes.BlobMeta, error) {
|
|
bm, ok := c.blobs[br]
|
|
if !ok {
|
|
return camtypes.BlobMeta{}, os.ErrNotExist
|
|
}
|
|
return *bm, nil
|
|
}
|
|
|
|
func (c *Corpus) KeyId(signer blob.Ref) (string, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if v, ok := c.keyId[signer]; ok {
|
|
return v, nil
|
|
}
|
|
return "", sorted.ErrNotFound
|
|
}
|
|
|
|
var (
|
|
errUnsupportedNodeType = errors.New("unsupported nodeType")
|
|
errNoNodeAttr = errors.New("attribute not found")
|
|
)
|
|
|
|
// typeSpecificNodeTimeLocked returns the time that is set as a specific permanode attribute.
|
|
// That attribute, if any, depends on the nodeType ("camliNodeType" attribute) value, which
|
|
// may be empty as well.
|
|
func (c *Corpus) typeSpecificNodeTimeLocked(nodeType string, pn blob.Ref) (t time.Time, err error) {
|
|
attr := ""
|
|
switch nodeType {
|
|
case "foursquare.com:checkin":
|
|
attr = "startDate"
|
|
// TODO(mpl): other nodeTypes from importers
|
|
default:
|
|
return t, errUnsupportedNodeType
|
|
}
|
|
timeStr := c.PermanodeAttrValueLocked(pn, attr, time.Time{}, blob.Ref{})
|
|
if timeStr == "" {
|
|
return t, errNoNodeAttr
|
|
}
|
|
return time.Parse(time.RFC3339, timeStr)
|
|
}
|
|
|
|
// PermanodeTimeLocked returns the time of the content in permanode.
|
|
func (c *Corpus) PermanodeTimeLocked(pn blob.Ref) (t time.Time, ok bool) {
|
|
// TODO(bradfitz): keep this time property cached on the permanode / files
|
|
|
|
// TODO(bradfitz): finish implmenting all these
|
|
|
|
// Priorities:
|
|
// -- Permanode explicit "camliTime" property
|
|
// -- EXIF GPS time
|
|
// -- Exif camera time - this one is actually already in the FileInfo,
|
|
// because we use schema.FileTime (which returns the EXIF time, if available)
|
|
// to index the time when receiving a file.
|
|
// -- File time
|
|
// -- File modtime
|
|
// -- camliContent claim set time
|
|
|
|
// First check the type-specific time (e.g. from importers)
|
|
nodeType := c.PermanodeAttrValueLocked(pn, "camliNodeType", time.Time{}, blob.Ref{})
|
|
if nodeType != "" {
|
|
if t, err := c.typeSpecificNodeTimeLocked(nodeType, pn); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
|
|
// Otherwise check time from the FileInfo
|
|
ccRef, ccTime, ok := c.pnCamliContentLocked(pn)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
fi, ok := c.files[ccRef]
|
|
if ok {
|
|
if fi.Time != nil {
|
|
return time.Time(*fi.Time), true
|
|
}
|
|
if fi.ModTime != nil {
|
|
return time.Time(*fi.ModTime), true
|
|
}
|
|
}
|
|
return ccTime, true
|
|
}
|
|
|
|
// PermanodeAnyTimeLocked returns the time that best qualifies the permanode.
|
|
// It tries content-specific times first, the permanode modtime otherwise.
|
|
func (c *Corpus) PermanodeAnyTimeLocked(pn blob.Ref) (t time.Time, ok bool) {
|
|
if t, ok := c.PermanodeTimeLocked(pn); ok {
|
|
return t, ok
|
|
}
|
|
return c.PermanodeModtimeLocked(pn)
|
|
}
|
|
|
|
func (c *Corpus) pnCamliContentLocked(pn blob.Ref) (cc blob.Ref, t time.Time, ok bool) {
|
|
// TODO(bradfitz): keep this property cached
|
|
pm, ok := c.permanodes[pn]
|
|
if !ok {
|
|
return
|
|
}
|
|
for _, cl := range pm.Claims {
|
|
if cl.Attr != "camliContent" {
|
|
continue
|
|
}
|
|
// TODO: pass down the 'PermanodeConstraint.At' parameter, and then do: if cl.Date.After(at) { continue }
|
|
switch cl.Type {
|
|
case string(schema.DelAttributeClaim):
|
|
cc = blob.Ref{}
|
|
t = time.Time{}
|
|
case string(schema.SetAttributeClaim):
|
|
cc = blob.ParseOrZero(cl.Value)
|
|
t = cl.Date
|
|
}
|
|
}
|
|
return cc, t, cc.Valid()
|
|
|
|
}
|
|
|
|
// PermanodeModtime returns the latest modification time of the given
|
|
// permanode.
|
|
//
|
|
// The ok value is true only if the permanode is known and has any
|
|
// non-deleted claims. A deleted claim is ignored and neither its
|
|
// claim date nor the date of the delete claim affect the modtime of
|
|
// the permanode.
|
|
func (c *Corpus) PermanodeModtime(pn blob.Ref) (t time.Time, ok bool) {
|
|
// TODO: figure out behavior wrt mutations by different people
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.PermanodeModtimeLocked(pn)
|
|
}
|
|
|
|
// PermanodeModtimeLocked is like PermanodeModtime but for when the Corpus is
|
|
// already locked via RLock.
|
|
func (c *Corpus) PermanodeModtimeLocked(pn blob.Ref) (t time.Time, ok bool) {
|
|
pm, ok := c.permanodes[pn]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Note: We intentionally don't try to derive any information
|
|
// (except the owner, elsewhere) from the permanode blob
|
|
// itself. Even though the permanode blob sometimes has the
|
|
// GPG signature time, we intentionally ignore it.
|
|
for _, cl := range pm.Claims {
|
|
if c.IsDeletedLocked(cl.BlobRef) {
|
|
continue
|
|
}
|
|
if cl.Date.After(t) {
|
|
t = cl.Date
|
|
}
|
|
}
|
|
return t, !t.IsZero()
|
|
}
|
|
|
|
// AppendPermanodeAttrValues appends to dst all the values for the attribute
|
|
// attr set on permaNode.
|
|
// signerFilter is optional.
|
|
// dst must start with length 0 (laziness, mostly)
|
|
func (c *Corpus) AppendPermanodeAttrValues(dst []string,
|
|
permaNode blob.Ref,
|
|
attr string,
|
|
at time.Time,
|
|
signerFilter blob.Ref) []string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.AppendPermanodeAttrValuesLocked(dst, permaNode, attr, at, signerFilter)
|
|
}
|
|
|
|
// PermanodeAttrValueLocked returns a single-valued attribute or "".
|
|
func (c *Corpus) PermanodeAttrValueLocked(permaNode blob.Ref,
|
|
attr string,
|
|
at time.Time,
|
|
signerFilter blob.Ref) string {
|
|
pm, ok := c.permanodes[permaNode]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
var v string
|
|
for _, cl := range pm.Claims {
|
|
if cl.Attr != attr || cl.Date.After(at) {
|
|
continue
|
|
}
|
|
if signerFilter.Valid() && signerFilter != cl.Signer {
|
|
continue
|
|
}
|
|
switch cl.Type {
|
|
case string(schema.DelAttributeClaim):
|
|
if cl.Value == "" {
|
|
v = ""
|
|
} else if v == cl.Value {
|
|
v = ""
|
|
}
|
|
case string(schema.SetAttributeClaim):
|
|
v = cl.Value
|
|
case string(schema.AddAttributeClaim):
|
|
if v == "" {
|
|
v = cl.Value
|
|
}
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (c *Corpus) AppendPermanodeAttrValuesLocked(dst []string,
|
|
permaNode blob.Ref,
|
|
attr string,
|
|
at time.Time,
|
|
signerFilter blob.Ref) []string {
|
|
if len(dst) > 0 {
|
|
panic("len(dst) must be 0")
|
|
}
|
|
pm, ok := c.permanodes[permaNode]
|
|
if !ok {
|
|
return dst
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
for _, cl := range pm.Claims {
|
|
if cl.Attr != attr || cl.Date.After(at) {
|
|
continue
|
|
}
|
|
if signerFilter.Valid() && signerFilter != cl.Signer {
|
|
continue
|
|
}
|
|
switch cl.Type {
|
|
case string(schema.DelAttributeClaim):
|
|
if cl.Value == "" {
|
|
dst = dst[:0] // delete all
|
|
} else {
|
|
for i := 0; i < len(dst); i++ {
|
|
v := dst[i]
|
|
if v == cl.Value {
|
|
copy(dst[i:], dst[i+1:])
|
|
dst = dst[:len(dst)-1]
|
|
i--
|
|
}
|
|
}
|
|
}
|
|
case string(schema.SetAttributeClaim):
|
|
dst = append(dst[:0], cl.Value)
|
|
case string(schema.AddAttributeClaim):
|
|
dst = append(dst, cl.Value)
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func (c *Corpus) AppendClaims(dst []camtypes.Claim, permaNode blob.Ref,
|
|
signerFilter blob.Ref,
|
|
attrFilter string) ([]camtypes.Claim, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
pm, ok := c.permanodes[permaNode]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
for _, cl := range pm.Claims {
|
|
if c.IsDeletedLocked(cl.BlobRef) {
|
|
continue
|
|
}
|
|
if signerFilter.Valid() && cl.Signer != signerFilter {
|
|
continue
|
|
}
|
|
if attrFilter != "" && cl.Attr != attrFilter {
|
|
continue
|
|
}
|
|
dst = append(dst, *cl)
|
|
}
|
|
return dst, nil
|
|
}
|
|
|
|
func (c *Corpus) GetFileInfo(fileRef blob.Ref) (fi camtypes.FileInfo, err error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.GetFileInfoLocked(fileRef)
|
|
}
|
|
|
|
func (c *Corpus) GetFileInfoLocked(fileRef blob.Ref) (fi camtypes.FileInfo, err error) {
|
|
fi, ok := c.files[fileRef]
|
|
if !ok {
|
|
err = os.ErrNotExist
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Corpus) GetImageInfo(fileRef blob.Ref) (ii camtypes.ImageInfo, err error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.GetImageInfoLocked(fileRef)
|
|
}
|
|
|
|
func (c *Corpus) GetImageInfoLocked(fileRef blob.Ref) (ii camtypes.ImageInfo, err error) {
|
|
ii, ok := c.imageInfo[fileRef]
|
|
if !ok {
|
|
err = os.ErrNotExist
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Corpus) GetMediaTags(fileRef blob.Ref) (map[string]string, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.GetMediaTagsLocked(fileRef)
|
|
}
|
|
|
|
func (c *Corpus) GetMediaTagsLocked(fileRef blob.Ref) (map[string]string, error) {
|
|
wholeRef, ok := c.fileWholeRef[fileRef]
|
|
if !ok {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
tags, ok := c.mediaTags[wholeRef]
|
|
if !ok {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
func (c *Corpus) FileLatLongLocked(fileRef blob.Ref) (lat, long float64, ok bool) {
|
|
wholeRef, ok := c.fileWholeRef[fileRef]
|
|
if !ok {
|
|
return
|
|
}
|
|
ll, ok := c.gps[wholeRef]
|
|
if !ok {
|
|
return
|
|
}
|
|
return ll.lat, ll.long, true
|
|
}
|
|
|
|
// zero value of at means current
|
|
func (c *Corpus) PermanodeLatLongLocked(pn blob.Ref, at time.Time) (lat, long float64, ok bool) {
|
|
nodeType := c.PermanodeAttrValueLocked(pn, "camliNodeType", at, blob.Ref{})
|
|
if nodeType == "" {
|
|
return
|
|
}
|
|
// TODO: make these pluggable, e.g. registered from an importer or something?
|
|
// How will that work when they're out-of-process?
|
|
if nodeType == "foursquare.com:checkin" {
|
|
venuePn, hasVenue := blob.Parse(c.PermanodeAttrValueLocked(pn, "foursquareVenuePermanode", at, blob.Ref{}))
|
|
if !hasVenue {
|
|
return
|
|
}
|
|
return c.PermanodeLatLongLocked(venuePn, at)
|
|
}
|
|
if nodeType == "foursquare.com:venue" {
|
|
var err error
|
|
lat, err = strconv.ParseFloat(c.PermanodeAttrValueLocked(pn, "latitude", at, blob.Ref{}), 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
long, err = strconv.ParseFloat(c.PermanodeAttrValueLocked(pn, "longitude", at, blob.Ref{}), 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
return lat, long, true
|
|
}
|
|
return
|
|
}
|
|
|
|
// ForeachClaimBackLocked calls fn for each claim with a value referencing br.
|
|
// If at is zero, all claims are yielded.
|
|
// If at is non-zero, claims after that point are skipped.
|
|
// If fn returns false, iteration ends.
|
|
// Iteration is in an undefined order.
|
|
func (c *Corpus) ForeachClaimBackLocked(value blob.Ref, at time.Time, fn func(*camtypes.Claim) bool) {
|
|
for _, cl := range c.claimBack[value] {
|
|
if !at.IsZero() && cl.Date.After(at) {
|
|
continue
|
|
}
|
|
if !fn(cl) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// PermanodeHasAttrValueLocked reports whether the permanode pn at
|
|
// time at (zero means now) has the given attribute with the given
|
|
// value. If the attribute is multi-valued, any may match.
|
|
func (c *Corpus) PermanodeHasAttrValueLocked(pn blob.Ref, at time.Time, attr, val string) bool {
|
|
pm, ok := c.permanodes[pn]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
ret := false
|
|
for _, cl := range pm.Claims {
|
|
if cl.Attr != attr {
|
|
continue
|
|
}
|
|
if cl.Date.After(at) {
|
|
break
|
|
}
|
|
switch cl.Type {
|
|
case string(schema.DelAttributeClaim):
|
|
if cl.Value == "" || cl.Value == val {
|
|
ret = false
|
|
}
|
|
case string(schema.SetAttributeClaim):
|
|
ret = (cl.Value == val)
|
|
case string(schema.AddAttributeClaim):
|
|
if cl.Value == val {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// SetVerboseCorpusLogging controls corpus setup verbosity. It's on by default
|
|
// but used to disable verbose logging in tests.
|
|
func SetVerboseCorpusLogging(v bool) {
|
|
logCorpusStats = v
|
|
}
|