2016-12-11 06:50:00 -08:00
|
|
|
package tsdb
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/binary"
|
|
|
|
"fmt"
|
2016-12-11 23:12:19 -08:00
|
|
|
"strings"
|
2016-12-11 06:50:00 -08:00
|
|
|
|
|
|
|
"github.com/fabxc/tsdb/chunks"
|
2016-12-21 00:39:01 -08:00
|
|
|
"github.com/fabxc/tsdb/labels"
|
2016-12-19 13:29:49 -08:00
|
|
|
"github.com/pkg/errors"
|
2016-12-11 06:50:00 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
// SeriesReader provides reading access of serialized time series data.
|
|
|
|
type SeriesReader interface {
|
2016-12-11 23:12:19 -08:00
|
|
|
// Chunk returns the series data chunk with the given reference.
|
2017-02-18 08:33:20 -08:00
|
|
|
Chunk(ref uint64) (chunks.Chunk, error)
|
2016-12-11 06:50:00 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// seriesReader implements a SeriesReader for a serialized byte stream
|
|
|
|
// of series data.
|
|
|
|
type seriesReader struct {
|
2017-02-18 08:33:20 -08:00
|
|
|
// The underlying bytes holding the encoded series data.
|
|
|
|
bs [][]byte
|
2016-12-11 06:50:00 -08:00
|
|
|
}
|
|
|
|
|
2017-02-18 08:33:20 -08:00
|
|
|
func newSeriesReader(bs [][]byte) (*seriesReader, error) {
|
|
|
|
s := &seriesReader{bs: bs}
|
|
|
|
|
|
|
|
for i, b := range bs {
|
|
|
|
if len(b) < 4 {
|
|
|
|
return nil, errors.Wrapf(errInvalidSize, "validate magic in segment %d", i)
|
|
|
|
}
|
|
|
|
// Verify magic number.
|
|
|
|
if m := binary.BigEndian.Uint32(b[:4]); m != MagicSeries {
|
|
|
|
return nil, fmt.Errorf("invalid magic number %x", m)
|
|
|
|
}
|
2016-12-11 06:50:00 -08:00
|
|
|
}
|
2017-02-18 08:33:20 -08:00
|
|
|
return s, nil
|
2016-12-11 06:50:00 -08:00
|
|
|
}
|
|
|
|
|
2017-02-18 08:33:20 -08:00
|
|
|
func (s *seriesReader) Chunk(ref uint64) (chunks.Chunk, error) {
|
|
|
|
var (
|
|
|
|
seq = int(ref >> 32)
|
|
|
|
off = int((ref << 32) >> 32)
|
|
|
|
)
|
|
|
|
if seq >= len(s.bs) {
|
|
|
|
return nil, errors.Errorf("reference sequence %d out of range", seq)
|
2016-12-20 04:10:37 -08:00
|
|
|
}
|
2017-02-18 08:33:20 -08:00
|
|
|
b := s.bs[seq]
|
|
|
|
|
|
|
|
if int(off) >= len(b) {
|
|
|
|
return nil, errors.Errorf("offset %d beyond data size %d", off, len(b))
|
|
|
|
}
|
|
|
|
b = b[off:]
|
2016-12-11 06:50:00 -08:00
|
|
|
|
|
|
|
l, n := binary.Uvarint(b)
|
|
|
|
if n < 0 {
|
|
|
|
return nil, fmt.Errorf("reading chunk length failed")
|
|
|
|
}
|
|
|
|
b = b[n:]
|
|
|
|
enc := chunks.Encoding(b[0])
|
|
|
|
|
|
|
|
c, err := chunks.FromData(enc, b[1:1+l])
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return c, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// IndexReader provides reading access of serialized index data.
|
|
|
|
type IndexReader interface {
|
2016-12-11 23:12:19 -08:00
|
|
|
// LabelValues returns the possible label values
|
|
|
|
LabelValues(names ...string) (StringTuples, error)
|
|
|
|
|
2016-12-14 09:38:46 -08:00
|
|
|
// Postings returns the postings list iterator for the label pair.
|
2016-12-14 12:58:29 -08:00
|
|
|
Postings(name, value string) (Postings, error)
|
2016-12-11 23:12:19 -08:00
|
|
|
|
|
|
|
// Series returns the series for the given reference.
|
2016-12-31 06:35:08 -08:00
|
|
|
Series(ref uint32) (labels.Labels, []ChunkMeta, error)
|
|
|
|
|
|
|
|
// LabelIndices returns the label pairs for which indices exist.
|
|
|
|
LabelIndices() ([][]string, error)
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// StringTuples provides access to a sorted list of string tuples.
|
|
|
|
type StringTuples interface {
|
|
|
|
// Total number of tuples in the list.
|
|
|
|
Len() int
|
|
|
|
// At returns the tuple at position i.
|
2016-12-12 02:38:43 -08:00
|
|
|
At(i int) ([]string, error)
|
2016-12-11 06:50:00 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
type indexReader struct {
|
|
|
|
// The underlying byte slice holding the encoded series data.
|
|
|
|
b []byte
|
|
|
|
|
2016-12-11 23:12:19 -08:00
|
|
|
// Cached hashmaps of section offsets.
|
|
|
|
labels map[string]uint32
|
|
|
|
postings map[string]uint32
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
errInvalidSize = fmt.Errorf("invalid size")
|
|
|
|
errInvalidFlag = fmt.Errorf("invalid flag")
|
|
|
|
)
|
|
|
|
|
2017-02-18 08:33:20 -08:00
|
|
|
func newIndexReader(b []byte) (*indexReader, error) {
|
2017-01-19 10:45:52 -08:00
|
|
|
if len(b) < 4 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "index header")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
2017-02-18 08:33:20 -08:00
|
|
|
r := &indexReader{b: b}
|
2016-12-11 23:12:19 -08:00
|
|
|
|
|
|
|
// Verify magic number.
|
|
|
|
if m := binary.BigEndian.Uint32(b[:4]); m != MagicIndex {
|
|
|
|
return nil, fmt.Errorf("invalid magic number %x", m)
|
|
|
|
}
|
|
|
|
|
|
|
|
var err error
|
|
|
|
// The last two 4 bytes hold the pointers to the hashmaps.
|
|
|
|
loff := binary.BigEndian.Uint32(b[len(b)-8 : len(b)-4])
|
|
|
|
poff := binary.BigEndian.Uint32(b[len(b)-4:])
|
|
|
|
|
2016-12-19 13:29:49 -08:00
|
|
|
f, b, err := r.section(loff)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrapf(err, "label index hashmap section at %d", loff)
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
2016-12-19 13:29:49 -08:00
|
|
|
if r.labels, err = readHashmap(f, b); err != nil {
|
|
|
|
return nil, errors.Wrap(err, "read label index hashmap")
|
|
|
|
}
|
|
|
|
f, b, err = r.section(poff)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrapf(err, "postings hashmap section at %d", loff)
|
|
|
|
}
|
|
|
|
if r.postings, err = readHashmap(f, b); err != nil {
|
|
|
|
return nil, errors.Wrap(err, "read postings hashmap")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
return r, nil
|
|
|
|
}
|
|
|
|
|
2016-12-19 13:29:49 -08:00
|
|
|
func readHashmap(flag byte, b []byte) (map[string]uint32, error) {
|
2016-12-11 23:12:19 -08:00
|
|
|
if flag != flagStd {
|
|
|
|
return nil, errInvalidFlag
|
|
|
|
}
|
|
|
|
h := make(map[string]uint32, 512)
|
|
|
|
|
|
|
|
for len(b) > 0 {
|
|
|
|
l, n := binary.Uvarint(b)
|
|
|
|
if n < 1 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "read key length")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
2016-12-15 02:24:20 -08:00
|
|
|
b = b[n:]
|
|
|
|
|
|
|
|
if len(b) < int(l) {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "read key")
|
2016-12-15 02:24:20 -08:00
|
|
|
}
|
|
|
|
s := string(b[:l])
|
|
|
|
b = b[l:]
|
2016-12-11 23:12:19 -08:00
|
|
|
|
|
|
|
o, n := binary.Uvarint(b)
|
|
|
|
if n < 1 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "read offset value")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
b = b[n:]
|
|
|
|
|
|
|
|
h[s] = uint32(o)
|
|
|
|
}
|
|
|
|
|
|
|
|
return h, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *indexReader) section(o uint32) (byte, []byte, error) {
|
|
|
|
b := r.b[o:]
|
|
|
|
|
|
|
|
if len(b) < 5 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return 0, nil, errors.Wrap(errInvalidSize, "read header")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
|
2016-12-15 02:24:20 -08:00
|
|
|
flag := b[0]
|
2016-12-11 23:12:19 -08:00
|
|
|
l := binary.BigEndian.Uint32(b[1:5])
|
|
|
|
|
|
|
|
b = b[5:]
|
|
|
|
|
2016-12-15 02:24:20 -08:00
|
|
|
// b must have the given length plus 4 bytes for the CRC32 checksum.
|
|
|
|
if len(b) < int(l)+4 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return 0, nil, errors.Wrap(errInvalidSize, "section content")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
2016-12-15 02:24:20 -08:00
|
|
|
return flag, b[:l], nil
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
|
2016-12-31 06:35:08 -08:00
|
|
|
func (r *indexReader) lookupSymbol(o uint32) (string, error) {
|
2017-01-19 05:01:38 -08:00
|
|
|
if int(o) > len(r.b) {
|
|
|
|
return "", errors.Errorf("invalid symbol offset %d", o)
|
|
|
|
}
|
2016-12-11 23:12:19 -08:00
|
|
|
l, n := binary.Uvarint(r.b[o:])
|
|
|
|
if n < 0 {
|
2017-01-19 05:01:38 -08:00
|
|
|
return "", errors.New("reading symbol length failed")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
end := int(o) + n + int(l)
|
|
|
|
if end > len(r.b) {
|
2017-01-19 05:01:38 -08:00
|
|
|
return "", errors.New("invalid length")
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
2017-01-02 01:34:55 -08:00
|
|
|
b := r.b[int(o)+n : end]
|
2016-12-11 23:12:19 -08:00
|
|
|
|
2017-01-02 01:34:55 -08:00
|
|
|
return yoloString(b), nil
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
func (r *indexReader) LabelValues(names ...string) (StringTuples, error) {
|
|
|
|
key := strings.Join(names, string(sep))
|
|
|
|
off, ok := r.labels[key]
|
|
|
|
if !ok {
|
|
|
|
return nil, fmt.Errorf("label index doesn't exist")
|
|
|
|
}
|
|
|
|
|
|
|
|
flag, b, err := r.section(off)
|
|
|
|
if err != nil {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrapf(err, "section at %d", off)
|
2016-12-11 23:12:19 -08:00
|
|
|
}
|
2016-12-12 02:38:43 -08:00
|
|
|
if flag != flagStd {
|
|
|
|
return nil, errInvalidFlag
|
|
|
|
}
|
2016-12-12 06:39:55 -08:00
|
|
|
l, n := binary.Uvarint(b)
|
|
|
|
if n < 1 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "read label index size")
|
2016-12-12 02:38:43 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
st := &serializedStringTuples{
|
2016-12-12 06:39:55 -08:00
|
|
|
l: int(l),
|
|
|
|
b: b[n:],
|
2016-12-12 02:38:43 -08:00
|
|
|
lookup: r.lookupSymbol,
|
|
|
|
}
|
|
|
|
return st, nil
|
|
|
|
}
|
|
|
|
|
2016-12-31 06:35:08 -08:00
|
|
|
func (r *indexReader) LabelIndices() ([][]string, error) {
|
|
|
|
res := [][]string{}
|
|
|
|
|
|
|
|
for s := range r.labels {
|
|
|
|
res = append(res, strings.Split(s, string(sep)))
|
|
|
|
}
|
|
|
|
return res, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *indexReader) Series(ref uint32) (labels.Labels, []ChunkMeta, error) {
|
2016-12-12 06:39:55 -08:00
|
|
|
k, n := binary.Uvarint(r.b[ref:])
|
|
|
|
if n < 1 {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "number of labels")
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
b := r.b[int(ref)+n:]
|
2016-12-21 00:39:01 -08:00
|
|
|
lbls := make(labels.Labels, 0, k)
|
2016-12-12 06:39:55 -08:00
|
|
|
|
2016-12-31 06:35:08 -08:00
|
|
|
for i := 0; i < 2*int(k); i += 2 {
|
|
|
|
o, m := binary.Uvarint(b)
|
|
|
|
if m < 1 {
|
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "symbol offset")
|
|
|
|
}
|
|
|
|
n, err := r.lookupSymbol(uint32(o))
|
2016-12-12 06:39:55 -08:00
|
|
|
if err != nil {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(err, "symbol lookup")
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
2016-12-31 06:35:08 -08:00
|
|
|
b = b[m:]
|
|
|
|
|
|
|
|
o, m = binary.Uvarint(b)
|
|
|
|
if m < 1 {
|
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "symbol offset")
|
|
|
|
}
|
|
|
|
v, err := r.lookupSymbol(uint32(o))
|
2016-12-12 06:39:55 -08:00
|
|
|
if err != nil {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(err, "symbol lookup")
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
2016-12-31 06:35:08 -08:00
|
|
|
b = b[m:]
|
|
|
|
|
2016-12-21 00:39:01 -08:00
|
|
|
lbls = append(lbls, labels.Label{
|
2016-12-31 06:35:08 -08:00
|
|
|
Name: n,
|
|
|
|
Value: v,
|
2016-12-12 06:39:55 -08:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2016-12-16 03:13:17 -08:00
|
|
|
// Read the chunks meta data.
|
|
|
|
k, n = binary.Uvarint(b)
|
2016-12-12 06:39:55 -08:00
|
|
|
if n < 1 {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "number of chunks")
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
b = b[n:]
|
2016-12-16 03:13:17 -08:00
|
|
|
chunks := make([]ChunkMeta, 0, k)
|
2016-12-12 06:39:55 -08:00
|
|
|
|
|
|
|
for i := 0; i < int(k); i++ {
|
2016-12-16 03:13:17 -08:00
|
|
|
firstTime, n := binary.Varint(b)
|
|
|
|
if n < 1 {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "first time")
|
2016-12-16 03:13:17 -08:00
|
|
|
}
|
|
|
|
b = b[n:]
|
|
|
|
|
|
|
|
lastTime, n := binary.Varint(b)
|
2016-12-12 06:39:55 -08:00
|
|
|
if n < 1 {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "last time")
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
|
|
|
b = b[n:]
|
|
|
|
|
|
|
|
o, n := binary.Uvarint(b)
|
|
|
|
if n < 1 {
|
2016-12-31 06:35:08 -08:00
|
|
|
return nil, nil, errors.Wrap(errInvalidSize, "chunk offset")
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
|
|
|
b = b[n:]
|
|
|
|
|
2016-12-16 03:13:17 -08:00
|
|
|
chunks = append(chunks, ChunkMeta{
|
2017-02-18 08:33:20 -08:00
|
|
|
Ref: o,
|
2016-12-16 03:13:17 -08:00
|
|
|
MinTime: firstTime,
|
|
|
|
MaxTime: lastTime,
|
2016-12-12 06:39:55 -08:00
|
|
|
})
|
|
|
|
}
|
2016-12-16 03:13:17 -08:00
|
|
|
|
2016-12-31 06:35:08 -08:00
|
|
|
return lbls, chunks, nil
|
2016-12-12 06:39:55 -08:00
|
|
|
}
|
|
|
|
|
2016-12-15 07:14:33 -08:00
|
|
|
func (r *indexReader) Postings(name, value string) (Postings, error) {
|
|
|
|
key := name + string(sep) + value
|
|
|
|
|
|
|
|
off, ok := r.postings[key]
|
|
|
|
if !ok {
|
2017-01-16 05:18:32 -08:00
|
|
|
return nil, ErrNotFound
|
2016-12-15 07:14:33 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
flag, b, err := r.section(off)
|
|
|
|
if err != nil {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrapf(err, "section at %d", off)
|
2016-12-15 07:14:33 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
if flag != flagStd {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrapf(errInvalidFlag, "section at %d", off)
|
2016-12-15 07:14:33 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO(fabxc): just read into memory as an intermediate solution.
|
|
|
|
// Add iterator over serialized data.
|
|
|
|
var l []uint32
|
|
|
|
|
|
|
|
for len(b) > 0 {
|
|
|
|
if len(b) < 4 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "plain postings entry")
|
2016-12-15 07:14:33 -08:00
|
|
|
}
|
|
|
|
l = append(l, binary.BigEndian.Uint32(b[:4]))
|
|
|
|
|
|
|
|
b = b[4:]
|
|
|
|
}
|
|
|
|
|
2016-12-19 02:44:11 -08:00
|
|
|
return &listPostings{list: l, idx: -1}, nil
|
2016-12-15 07:14:33 -08:00
|
|
|
}
|
|
|
|
|
2016-12-12 02:38:43 -08:00
|
|
|
type stringTuples struct {
|
|
|
|
l int // tuple length
|
|
|
|
s []string // flattened tuple entries
|
|
|
|
}
|
|
|
|
|
|
|
|
func newStringTuples(s []string, l int) (*stringTuples, error) {
|
|
|
|
if len(s)%l != 0 {
|
2016-12-19 13:29:49 -08:00
|
|
|
return nil, errors.Wrap(errInvalidSize, "string tuple list")
|
2016-12-12 02:38:43 -08:00
|
|
|
}
|
|
|
|
return &stringTuples{s: s, l: l}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t *stringTuples) Len() int { return len(t.s) / t.l }
|
|
|
|
func (t *stringTuples) At(i int) ([]string, error) { return t.s[i : i+t.l], nil }
|
|
|
|
|
|
|
|
func (t *stringTuples) Swap(i, j int) {
|
|
|
|
c := make([]string, t.l)
|
|
|
|
copy(c, t.s[i:i+t.l])
|
|
|
|
|
|
|
|
for k := 0; k < t.l; k++ {
|
|
|
|
t.s[i+k] = t.s[j+k]
|
|
|
|
t.s[j+k] = c[k]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t *stringTuples) Less(i, j int) bool {
|
|
|
|
for k := 0; k < t.l; k++ {
|
|
|
|
d := strings.Compare(t.s[i+k], t.s[j+k])
|
|
|
|
|
|
|
|
if d < 0 {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
if d > 0 {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
type serializedStringTuples struct {
|
|
|
|
l int
|
|
|
|
b []byte
|
2016-12-31 06:35:08 -08:00
|
|
|
lookup func(uint32) (string, error)
|
2016-12-12 02:38:43 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
func (t *serializedStringTuples) Len() int {
|
|
|
|
// TODO(fabxc): Cache this?
|
|
|
|
return len(t.b) / (4 * t.l)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t *serializedStringTuples) At(i int) ([]string, error) {
|
|
|
|
if len(t.b) < (i+t.l)*4 {
|
|
|
|
return nil, errInvalidSize
|
|
|
|
}
|
2016-12-20 04:10:37 -08:00
|
|
|
res := make([]string, 0, t.l)
|
2016-12-12 02:38:43 -08:00
|
|
|
|
|
|
|
for k := 0; k < t.l; k++ {
|
2016-12-20 04:10:37 -08:00
|
|
|
offset := binary.BigEndian.Uint32(t.b[(i+k)*4:])
|
2016-12-12 02:38:43 -08:00
|
|
|
|
2016-12-31 06:35:08 -08:00
|
|
|
s, err := t.lookup(offset)
|
2016-12-12 02:38:43 -08:00
|
|
|
if err != nil {
|
2016-12-20 04:10:37 -08:00
|
|
|
return nil, errors.Wrap(err, "symbol lookup")
|
2016-12-12 02:38:43 -08:00
|
|
|
}
|
2016-12-31 06:35:08 -08:00
|
|
|
res = append(res, s)
|
2016-12-12 02:38:43 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
return res, nil
|
2016-12-11 06:50:00 -08:00
|
|
|
}
|