chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
+445
@@ -0,0 +1,445 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var (
|
||||
HomeDir, _ = os.UserHomeDir()
|
||||
|
||||
// DefaultDatabase is the namespace that the bbolt store
|
||||
// will use if no namespace is provided.
|
||||
DefaultDatabase = "micro"
|
||||
// DefaultTable when none is specified.
|
||||
DefaultTable = "micro"
|
||||
// DefaultDir is the default directory for bbolt files.
|
||||
DefaultDir = filepath.Join(HomeDir, "micro", "store")
|
||||
|
||||
// bucket used for data storage.
|
||||
dataBucket = "data"
|
||||
)
|
||||
|
||||
func NewFileStore(opts ...Option) Store {
|
||||
s := &fileStore{
|
||||
handles: make(map[string]*fileHandle),
|
||||
}
|
||||
_ = s.init(opts...)
|
||||
return s
|
||||
}
|
||||
|
||||
type fileStore struct {
|
||||
options Options
|
||||
dir string
|
||||
|
||||
// the database handle
|
||||
sync.RWMutex
|
||||
handles map[string]*fileHandle
|
||||
}
|
||||
|
||||
type fileHandle struct {
|
||||
key string
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
// record stored by us.
|
||||
type record struct {
|
||||
Key string
|
||||
Value []byte
|
||||
Metadata map[string]interface{}
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func key(database, table string) string {
|
||||
return database + ":" + table
|
||||
}
|
||||
|
||||
func (m *fileStore) delete(fd *fileHandle, key string) error {
|
||||
return fd.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(dataBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.Delete([]byte(key))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *fileStore) init(opts ...Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.options)
|
||||
}
|
||||
|
||||
if m.options.Database == "" {
|
||||
m.options.Database = DefaultDatabase
|
||||
}
|
||||
|
||||
if m.options.Table == "" {
|
||||
// bbolt requires bucketname to not be empty
|
||||
m.options.Table = DefaultTable
|
||||
}
|
||||
|
||||
if m.options.Context != nil {
|
||||
if dir, ok := m.options.Context.Value(dirOptionKey{}).(string); ok {
|
||||
m.dir = dir
|
||||
}
|
||||
}
|
||||
|
||||
// create default directory
|
||||
if m.dir == "" {
|
||||
m.dir = DefaultDir
|
||||
}
|
||||
// create the directory
|
||||
return os.MkdirAll(m.dir, 0700)
|
||||
}
|
||||
|
||||
func (m *fileStore) getDB(database, table string) (*fileHandle, error) {
|
||||
if len(database) == 0 {
|
||||
database = m.options.Database
|
||||
}
|
||||
if len(table) == 0 {
|
||||
table = m.options.Table
|
||||
}
|
||||
|
||||
k := key(database, table)
|
||||
m.RLock()
|
||||
fd, ok := m.handles[k]
|
||||
m.RUnlock()
|
||||
|
||||
// return the file handle
|
||||
if ok {
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// double check locking
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
if fd, ok := m.handles[k]; ok {
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// create directory
|
||||
dir := filepath.Join(m.dir, database)
|
||||
// create the database handle
|
||||
fname := table + ".db"
|
||||
// make the dir
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
// database path
|
||||
dbPath := filepath.Join(dir, fname)
|
||||
|
||||
// create new db handle
|
||||
// Bolt DB only allows one process to open the file R/W so make sure we're doing this under a lock
|
||||
db, err := bolt.Open(dbPath, 0700, &bolt.Options{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fd = &fileHandle{
|
||||
key: k,
|
||||
db: db,
|
||||
}
|
||||
m.handles[k] = fd
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
func (m *fileStore) list(fd *fileHandle, limit, offset uint) []string {
|
||||
var allItems []string
|
||||
|
||||
_ = fd.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(dataBucket))
|
||||
// nothing to read
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @todo very inefficient
|
||||
if err := b.ForEach(func(k, v []byte) error {
|
||||
storedRecord := &record{}
|
||||
|
||||
if err := json.Unmarshal(v, storedRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !storedRecord.ExpiresAt.IsZero() {
|
||||
if storedRecord.ExpiresAt.Before(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
allItems = append(allItems, string(k))
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
allKeys := make([]string, len(allItems))
|
||||
|
||||
copy(allKeys, allItems)
|
||||
|
||||
if limit != 0 || offset != 0 {
|
||||
sort.Slice(allKeys, func(i, j int) bool { return allKeys[i] < allKeys[j] })
|
||||
min := func(i, j uint) uint {
|
||||
if i < j {
|
||||
return i
|
||||
}
|
||||
return j
|
||||
}
|
||||
return allKeys[offset:min(limit, uint(len(allKeys)))]
|
||||
}
|
||||
|
||||
return allKeys
|
||||
}
|
||||
|
||||
func (m *fileStore) get(fd *fileHandle, k string) (*Record, error) {
|
||||
var value []byte
|
||||
|
||||
_ = fd.db.View(func(tx *bolt.Tx) error {
|
||||
// @todo this is still very experimental...
|
||||
b := tx.Bucket([]byte(dataBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
value = b.Get([]byte(k))
|
||||
return nil
|
||||
})
|
||||
|
||||
if value == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
storedRecord := &record{}
|
||||
|
||||
if err := json.Unmarshal(value, storedRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newRecord := &Record{}
|
||||
newRecord.Key = storedRecord.Key
|
||||
newRecord.Value = storedRecord.Value
|
||||
newRecord.Metadata = make(map[string]interface{})
|
||||
|
||||
for k, v := range storedRecord.Metadata {
|
||||
newRecord.Metadata[k] = v
|
||||
}
|
||||
|
||||
if !storedRecord.ExpiresAt.IsZero() {
|
||||
if storedRecord.ExpiresAt.Before(time.Now()) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
newRecord.Expiry = time.Until(storedRecord.ExpiresAt)
|
||||
}
|
||||
|
||||
return newRecord, nil
|
||||
}
|
||||
|
||||
func (m *fileStore) set(fd *fileHandle, r *Record) error {
|
||||
// copy the incoming record and then
|
||||
// convert the expiry in to a hard timestamp
|
||||
item := &record{}
|
||||
item.Key = r.Key
|
||||
item.Value = r.Value
|
||||
item.Metadata = make(map[string]interface{})
|
||||
|
||||
if r.Expiry != 0 {
|
||||
item.ExpiresAt = time.Now().Add(r.Expiry)
|
||||
}
|
||||
|
||||
for k, v := range r.Metadata {
|
||||
item.Metadata[k] = v
|
||||
}
|
||||
|
||||
// marshal the data
|
||||
data, _ := json.Marshal(item)
|
||||
|
||||
return fd.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(dataBucket))
|
||||
if b == nil {
|
||||
var err error
|
||||
b, err = tx.CreateBucketIfNotExists([]byte(dataBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return b.Put([]byte(r.Key), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *fileStore) Close() error {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
for k, v := range m.handles {
|
||||
v.db.Close()
|
||||
delete(m.handles, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fileStore) Init(opts ...Option) error {
|
||||
return m.init(opts...)
|
||||
}
|
||||
|
||||
func (m *fileStore) Delete(key string, opts ...DeleteOption) error {
|
||||
var deleteOptions DeleteOptions
|
||||
for _, o := range opts {
|
||||
o(&deleteOptions)
|
||||
}
|
||||
|
||||
fd, err := m.getDB(deleteOptions.Database, deleteOptions.Table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.delete(fd, key)
|
||||
}
|
||||
|
||||
func (m *fileStore) Read(key string, opts ...ReadOption) ([]*Record, error) {
|
||||
var readOpts ReadOptions
|
||||
for _, o := range opts {
|
||||
o(&readOpts)
|
||||
}
|
||||
|
||||
fd, err := m.getDB(readOpts.Database, readOpts.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
|
||||
// Handle Prefix / suffix
|
||||
// TODO: do range scan here rather than listing all keys
|
||||
if readOpts.Prefix || readOpts.Suffix {
|
||||
// list the keys
|
||||
k := m.list(fd, readOpts.Limit, readOpts.Offset)
|
||||
|
||||
// check for prefix and suffix
|
||||
for _, v := range k {
|
||||
if readOpts.Prefix && !strings.HasPrefix(v, key) {
|
||||
continue
|
||||
}
|
||||
if readOpts.Suffix && !strings.HasSuffix(v, key) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, v)
|
||||
}
|
||||
} else {
|
||||
keys = []string{key}
|
||||
}
|
||||
|
||||
var results []*Record
|
||||
|
||||
for _, k := range keys {
|
||||
r, err := m.get(fd, k)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *fileStore) Write(r *Record, opts ...WriteOption) error {
|
||||
var writeOpts WriteOptions
|
||||
for _, o := range opts {
|
||||
o(&writeOpts)
|
||||
}
|
||||
|
||||
fd, err := m.getDB(writeOpts.Database, writeOpts.Table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(opts) > 0 {
|
||||
// Copy the record before applying options, or the incoming record will be mutated
|
||||
newRecord := Record{}
|
||||
newRecord.Key = r.Key
|
||||
newRecord.Value = r.Value
|
||||
newRecord.Metadata = make(map[string]interface{})
|
||||
newRecord.Expiry = r.Expiry
|
||||
|
||||
if !writeOpts.Expiry.IsZero() {
|
||||
newRecord.Expiry = time.Until(writeOpts.Expiry)
|
||||
}
|
||||
if writeOpts.TTL != 0 {
|
||||
newRecord.Expiry = writeOpts.TTL
|
||||
}
|
||||
|
||||
for k, v := range r.Metadata {
|
||||
newRecord.Metadata[k] = v
|
||||
}
|
||||
|
||||
return m.set(fd, &newRecord)
|
||||
}
|
||||
|
||||
return m.set(fd, r)
|
||||
}
|
||||
|
||||
func (m *fileStore) Options() Options {
|
||||
return m.options
|
||||
}
|
||||
|
||||
func (m *fileStore) List(opts ...ListOption) ([]string, error) {
|
||||
var listOptions ListOptions
|
||||
|
||||
for _, o := range opts {
|
||||
o(&listOptions)
|
||||
}
|
||||
|
||||
fd, err := m.getDB(listOptions.Database, listOptions.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO apply prefix/suffix in range query
|
||||
allKeys := m.list(fd, listOptions.Limit, listOptions.Offset)
|
||||
|
||||
if len(listOptions.Prefix) > 0 {
|
||||
var prefixKeys []string
|
||||
for _, k := range allKeys {
|
||||
if strings.HasPrefix(k, listOptions.Prefix) {
|
||||
prefixKeys = append(prefixKeys, k)
|
||||
}
|
||||
}
|
||||
allKeys = prefixKeys
|
||||
}
|
||||
|
||||
if len(listOptions.Suffix) > 0 {
|
||||
var suffixKeys []string
|
||||
for _, k := range allKeys {
|
||||
if strings.HasSuffix(k, listOptions.Suffix) {
|
||||
suffixKeys = append(suffixKeys, k)
|
||||
}
|
||||
}
|
||||
allKeys = suffixKeys
|
||||
}
|
||||
|
||||
return allKeys, nil
|
||||
}
|
||||
|
||||
func (m *fileStore) String() string {
|
||||
return "file"
|
||||
}
|
||||
|
||||
type dirOptionKey struct{}
|
||||
|
||||
// DirOption is a file store Option to set the directory for the file
|
||||
func DirOption(dir string) Option {
|
||||
return func(o *Options) {
|
||||
if o.Context == nil {
|
||||
o.Context = context.Background()
|
||||
}
|
||||
o.Context = context.WithValue(o.Context, dirOptionKey{}, dir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/kr/pretty"
|
||||
)
|
||||
|
||||
func newTestFileStore(t *testing.T, opts ...Option) Store {
|
||||
t.Helper()
|
||||
opts = append(opts, DirOption(t.TempDir()))
|
||||
s := NewStore(opts...)
|
||||
t.Cleanup(func() {
|
||||
if err := s.Close(); err != nil {
|
||||
t.Errorf("failed to close file store: %v", err)
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestFileStoreReInit(t *testing.T) {
|
||||
s := newTestFileStore(t, Table("aaa"))
|
||||
s.Init(Table("bbb"))
|
||||
if s.Options().Table != "bbb" {
|
||||
t.Error("Init didn't reinitialise the store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStoreBasic(t *testing.T) {
|
||||
s := newTestFileStore(t)
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func TestFileStoreTable(t *testing.T) {
|
||||
s := newTestFileStore(t, Table("testTable"))
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func TestFileStoreDatabase(t *testing.T) {
|
||||
s := newTestFileStore(t, Database("testdb"))
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func TestFileStoreDatabaseTable(t *testing.T) {
|
||||
s := newTestFileStore(t, Table("testTable"), Database("testdb"))
|
||||
fileTest(s, t)
|
||||
}
|
||||
|
||||
func fileTest(s Store, t *testing.T) {
|
||||
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
|
||||
t.Logf("Options %s %v\n", s.String(), s.Options())
|
||||
}
|
||||
// Read and Write an expiring Record
|
||||
if err := s.Write(&Record{
|
||||
Key: "Hello",
|
||||
Value: []byte("World"),
|
||||
Expiry: time.Millisecond * 150,
|
||||
}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if r, err := s.Read("Hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
if len(r) != 1 {
|
||||
t.Error("Read returned multiple records")
|
||||
}
|
||||
if r[0].Key != "Hello" {
|
||||
t.Errorf("Expected %s, got %s", "Hello", r[0].Key)
|
||||
}
|
||||
if string(r[0].Value) != "World" {
|
||||
t.Errorf("Expected %s, got %s", "World", r[0].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// wait for expiry
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
if _, err := s.Read("Hello"); err != ErrNotFound {
|
||||
t.Errorf("Expected %# v, got %# v", ErrNotFound, err)
|
||||
}
|
||||
|
||||
// Write 3 records with various expiry and get with Table
|
||||
records := []*Record{
|
||||
{
|
||||
Key: "foo",
|
||||
Value: []byte("foofoo"),
|
||||
},
|
||||
{
|
||||
Key: "foobar",
|
||||
Value: []byte("foobarfoobar"),
|
||||
Expiry: time.Second, // wide window: CI I/O under -race can exceed a 100ms expiry before the read below
|
||||
},
|
||||
}
|
||||
|
||||
for _, r := range records {
|
||||
if err := s.Write(r); err != nil {
|
||||
t.Errorf("Couldn't write k: %s, v: %# v (%s)", r.Key, pretty.Formatter(r.Value), err)
|
||||
}
|
||||
}
|
||||
|
||||
if results, err := s.Read("foo", ReadPrefix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else {
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 items, got %d", len(results))
|
||||
// t.Logf("Table test: %v\n", spew.Sdump(results))
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the expiry (must exceed the 1s Expiry above, with margin for slow CI)
|
||||
time.Sleep(time.Second * 2)
|
||||
|
||||
if results, err := s.Read("foo", ReadPrefix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else if len(results) != 1 {
|
||||
t.Errorf("Expected 1 item, got %d", len(results))
|
||||
// t.Logf("Table test: %v\n", spew.Sdump(results))
|
||||
}
|
||||
|
||||
if err := s.Delete("foo"); err != nil {
|
||||
t.Errorf("Delete failed (%v)", err)
|
||||
}
|
||||
|
||||
if results, err := s.Read("foo"); err != ErrNotFound {
|
||||
t.Errorf("Expected read failure read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else {
|
||||
if len(results) != 0 {
|
||||
t.Errorf("Expected 0 items, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
}
|
||||
|
||||
// Write records with suffix matches and an already-expired record. Avoid
|
||||
// wall-clock boundary sleeps here: under -race/-cover, sleeping exactly the
|
||||
// TTL made this assertion flaky on slower CI runners.
|
||||
records = []*Record{
|
||||
{
|
||||
Key: "foo",
|
||||
Value: []byte("foofoo"),
|
||||
},
|
||||
{
|
||||
Key: "barfoo",
|
||||
Value: []byte("barfoobarfoo"),
|
||||
Expiry: -time.Second,
|
||||
},
|
||||
{
|
||||
Key: "bazbarfoo",
|
||||
Value: []byte("bazbarfoobazbarfoo"),
|
||||
},
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := s.Write(r); err != nil {
|
||||
t.Errorf("Couldn't write k: %s, v: %# v (%s)", r.Key, pretty.Formatter(r.Value), err)
|
||||
}
|
||||
}
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else if len(results) != 2 {
|
||||
t.Errorf("Expected 2 unexpired suffix items, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
if err := s.Delete("bazbarfoo"); err != nil {
|
||||
t.Errorf("Delete failed (%v)", err)
|
||||
}
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else if len(results) != 1 {
|
||||
t.Errorf("Expected 1 unexpired suffix item, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
if err := s.Delete("foo"); err != nil {
|
||||
t.Errorf("Delete failed (%v)", err)
|
||||
}
|
||||
if results, err := s.Read("foo", ReadSuffix()); err != nil {
|
||||
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
|
||||
} else if len(results) != 0 {
|
||||
t.Errorf("Expected 0 items, got %d (%# v)", len(results), spew.Sdump(results))
|
||||
}
|
||||
|
||||
// Test Table, Suffix and WriteOptions
|
||||
if err := s.Write(&Record{
|
||||
Key: "foofoobarbar",
|
||||
Value: []byte("something"),
|
||||
}, WriteTTL(time.Second)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := s.Write(&Record{
|
||||
Key: "foofoo",
|
||||
Value: []byte("something"),
|
||||
}, WriteExpiry(time.Now().Add(time.Second))); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := s.Write(&Record{
|
||||
Key: "barbar",
|
||||
Value: []byte("something"),
|
||||
// TTL has higher precedence than expiry
|
||||
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Second)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if results, err := s.Read("foo", ReadPrefix(), ReadSuffix()); err != nil {
|
||||
t.Error(err)
|
||||
} else {
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 results, got %d: %# v", len(results), spew.Sdump(results))
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 2) // exceed the 1s TTL/expiry above so everything has expired
|
||||
|
||||
if results, err := s.List(); err != nil {
|
||||
t.Errorf("List failed: %s", err)
|
||||
} else {
|
||||
if len(results) != 0 {
|
||||
t.Errorf("Expiry options were not effective, results :%v", spew.Sdump(results))
|
||||
}
|
||||
}
|
||||
|
||||
// write the following records
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Write(&Record{
|
||||
Key: fmt.Sprintf("a%d", i),
|
||||
Value: []byte{},
|
||||
})
|
||||
}
|
||||
|
||||
// read back a few records
|
||||
if results, err := s.Read("a", ReadLimit(5), ReadPrefix()); err != nil {
|
||||
t.Error(err)
|
||||
} else {
|
||||
if len(results) != 5 {
|
||||
t.Fatal("Expected 5 results, got ", len(results))
|
||||
}
|
||||
if !strings.HasPrefix(results[0].Key, "a") {
|
||||
t.Fatalf("Expected a prefix, got %s", results[0].Key)
|
||||
}
|
||||
}
|
||||
|
||||
// read the rest back
|
||||
if results, err := s.Read("a", ReadLimit(30), ReadOffset(5), ReadPrefix()); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
if len(results) != 5 {
|
||||
t.Fatal("Expected 5 results, got ", len(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// NewMemoryStore returns a memory store.
|
||||
func NewMemoryStore(opts ...Option) Store {
|
||||
s := &memoryStore{
|
||||
options: Options{
|
||||
Database: "micro",
|
||||
Table: "micro",
|
||||
},
|
||||
store: cache.New(cache.NoExpiration, 5*time.Minute),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&s.options)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
options Options
|
||||
|
||||
store *cache.Cache
|
||||
}
|
||||
|
||||
type storeRecord struct {
|
||||
expiresAt time.Time
|
||||
metadata map[string]interface{}
|
||||
key string
|
||||
value []byte
|
||||
}
|
||||
|
||||
func (m *memoryStore) key(prefix, key string) string {
|
||||
return filepath.Join(prefix, key)
|
||||
}
|
||||
|
||||
func (m *memoryStore) prefix(database, table string) string {
|
||||
if len(database) == 0 {
|
||||
database = m.options.Database
|
||||
}
|
||||
if len(table) == 0 {
|
||||
table = m.options.Table
|
||||
}
|
||||
return filepath.Join(database, table)
|
||||
}
|
||||
|
||||
func (m *memoryStore) get(prefix, key string) (*Record, error) {
|
||||
key = m.key(prefix, key)
|
||||
|
||||
var storedRecord *storeRecord
|
||||
r, found := m.store.Get(key)
|
||||
if !found {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
storedRecord, ok := r.(*storeRecord)
|
||||
if !ok {
|
||||
return nil, errors.New("Retrieved a non *storeRecord from the cache")
|
||||
}
|
||||
|
||||
// Copy the record on the way out
|
||||
newRecord := &Record{}
|
||||
newRecord.Key = strings.TrimPrefix(storedRecord.key, prefix+"/")
|
||||
newRecord.Value = make([]byte, len(storedRecord.value))
|
||||
newRecord.Metadata = make(map[string]interface{})
|
||||
|
||||
// copy the value into the new record
|
||||
copy(newRecord.Value, storedRecord.value)
|
||||
|
||||
// check if we need to set the expiry
|
||||
if !storedRecord.expiresAt.IsZero() {
|
||||
newRecord.Expiry = time.Until(storedRecord.expiresAt)
|
||||
}
|
||||
|
||||
// copy in the metadata
|
||||
for k, v := range storedRecord.metadata {
|
||||
newRecord.Metadata[k] = v
|
||||
}
|
||||
|
||||
return newRecord, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) set(prefix string, r *Record) {
|
||||
key := m.key(prefix, r.Key)
|
||||
|
||||
// copy the incoming record and then
|
||||
// convert the expiry in to a hard timestamp
|
||||
i := &storeRecord{}
|
||||
i.key = r.Key
|
||||
i.value = make([]byte, len(r.Value))
|
||||
i.metadata = make(map[string]interface{})
|
||||
|
||||
// copy the the value
|
||||
copy(i.value, r.Value)
|
||||
|
||||
// set the expiry
|
||||
if r.Expiry != 0 {
|
||||
i.expiresAt = time.Now().Add(r.Expiry)
|
||||
}
|
||||
|
||||
// set the metadata
|
||||
for k, v := range r.Metadata {
|
||||
i.metadata[k] = v
|
||||
}
|
||||
|
||||
m.store.Set(key, i, r.Expiry)
|
||||
}
|
||||
|
||||
func (m *memoryStore) delete(prefix, key string) {
|
||||
key = m.key(prefix, key)
|
||||
m.store.Delete(key)
|
||||
}
|
||||
|
||||
func (m *memoryStore) list(prefix string, limit, offset uint) []string {
|
||||
allItems := m.store.Items()
|
||||
foundKeys := make([]string, 0, len(allItems))
|
||||
|
||||
for k := range allItems {
|
||||
if !strings.HasPrefix(k, prefix+"/") {
|
||||
continue
|
||||
}
|
||||
foundKeys = append(foundKeys, strings.TrimPrefix(k, prefix+"/"))
|
||||
}
|
||||
|
||||
if limit != 0 || offset != 0 {
|
||||
sort.Slice(foundKeys, func(i, j int) bool { return foundKeys[i] < foundKeys[j] })
|
||||
min := func(i, j uint) uint {
|
||||
if i < j {
|
||||
return i
|
||||
}
|
||||
return j
|
||||
}
|
||||
return foundKeys[offset:min(offset+limit, uint(len(foundKeys)))]
|
||||
}
|
||||
|
||||
return foundKeys
|
||||
}
|
||||
|
||||
func (m *memoryStore) Close() error {
|
||||
m.store.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Init(opts ...Option) error {
|
||||
for _, o := range opts {
|
||||
o(&m.options)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) String() string {
|
||||
return "memory"
|
||||
}
|
||||
|
||||
func (m *memoryStore) Read(key string, opts ...ReadOption) ([]*Record, error) {
|
||||
readOpts := ReadOptions{}
|
||||
for _, o := range opts {
|
||||
o(&readOpts)
|
||||
}
|
||||
|
||||
prefix := m.prefix(readOpts.Database, readOpts.Table)
|
||||
|
||||
var keys []string
|
||||
|
||||
// Handle Prefix / suffix
|
||||
if readOpts.Prefix || readOpts.Suffix {
|
||||
k := m.list(prefix, 0, 0)
|
||||
|
||||
// First, filter by prefix/suffix to get all matching keys
|
||||
var matchingKeys []string
|
||||
for _, kk := range k {
|
||||
if readOpts.Prefix && !strings.HasPrefix(kk, key) {
|
||||
continue
|
||||
}
|
||||
|
||||
if readOpts.Suffix && !strings.HasSuffix(kk, key) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchingKeys = append(matchingKeys, kk)
|
||||
}
|
||||
|
||||
// Then apply limit and offset to the filtered results
|
||||
limit := int(readOpts.Limit)
|
||||
offset := int(readOpts.Offset)
|
||||
|
||||
if offset > len(matchingKeys) {
|
||||
offset = len(matchingKeys)
|
||||
}
|
||||
|
||||
endIdx := offset + limit
|
||||
if endIdx > len(matchingKeys) || limit == 0 {
|
||||
endIdx = len(matchingKeys)
|
||||
}
|
||||
|
||||
keys = matchingKeys[offset:endIdx]
|
||||
} else {
|
||||
keys = []string{key}
|
||||
}
|
||||
|
||||
var results []*Record
|
||||
|
||||
for _, k := range keys {
|
||||
r, err := m.get(prefix, k)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Write(r *Record, opts ...WriteOption) error {
|
||||
writeOpts := WriteOptions{}
|
||||
for _, o := range opts {
|
||||
o(&writeOpts)
|
||||
}
|
||||
|
||||
prefix := m.prefix(writeOpts.Database, writeOpts.Table)
|
||||
|
||||
if len(opts) > 0 {
|
||||
// Copy the record before applying options, or the incoming record will be mutated
|
||||
newRecord := Record{}
|
||||
newRecord.Key = r.Key
|
||||
newRecord.Value = make([]byte, len(r.Value))
|
||||
newRecord.Metadata = make(map[string]interface{})
|
||||
copy(newRecord.Value, r.Value)
|
||||
newRecord.Expiry = r.Expiry
|
||||
|
||||
if !writeOpts.Expiry.IsZero() {
|
||||
newRecord.Expiry = time.Until(writeOpts.Expiry)
|
||||
}
|
||||
if writeOpts.TTL != 0 {
|
||||
newRecord.Expiry = writeOpts.TTL
|
||||
}
|
||||
|
||||
for k, v := range r.Metadata {
|
||||
newRecord.Metadata[k] = v
|
||||
}
|
||||
|
||||
m.set(prefix, &newRecord)
|
||||
return nil
|
||||
}
|
||||
|
||||
// set
|
||||
m.set(prefix, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Delete(key string, opts ...DeleteOption) error {
|
||||
deleteOptions := DeleteOptions{}
|
||||
for _, o := range opts {
|
||||
o(&deleteOptions)
|
||||
}
|
||||
|
||||
prefix := m.prefix(deleteOptions.Database, deleteOptions.Table)
|
||||
m.delete(prefix, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Options() Options {
|
||||
return m.options
|
||||
}
|
||||
|
||||
func (m *memoryStore) List(opts ...ListOption) ([]string, error) {
|
||||
listOptions := ListOptions{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&listOptions)
|
||||
}
|
||||
|
||||
prefix := m.prefix(listOptions.Database, listOptions.Table)
|
||||
keys := m.list(prefix, listOptions.Limit, listOptions.Offset)
|
||||
|
||||
if len(listOptions.Prefix) > 0 {
|
||||
var prefixKeys []string
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, listOptions.Prefix) {
|
||||
prefixKeys = append(prefixKeys, k)
|
||||
}
|
||||
}
|
||||
keys = prefixKeys
|
||||
}
|
||||
|
||||
if len(listOptions.Suffix) > 0 {
|
||||
var suffixKeys []string
|
||||
for _, k := range keys {
|
||||
if strings.HasSuffix(k, listOptions.Suffix) {
|
||||
suffixKeys = append(suffixKeys, k)
|
||||
}
|
||||
}
|
||||
keys = suffixKeys
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
log "go-micro.dev/v6/logger"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultDatabase is the database that the sql store will use if no database is provided.
|
||||
DefaultDatabase = "micro"
|
||||
// DefaultTable is the table that the sql store will use if no table is provided.
|
||||
DefaultTable = "micro"
|
||||
)
|
||||
|
||||
type sqlStore struct {
|
||||
db *sql.DB
|
||||
|
||||
database string
|
||||
table string
|
||||
|
||||
options store.Options
|
||||
|
||||
readPrepare, writePrepare, deletePrepare *sql.Stmt
|
||||
}
|
||||
|
||||
func (s *sqlStore) Init(opts ...store.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&s.options)
|
||||
}
|
||||
// reconfigure
|
||||
return s.configure()
|
||||
}
|
||||
|
||||
func (s *sqlStore) Options() store.Options {
|
||||
return s.options
|
||||
}
|
||||
|
||||
func (s *sqlStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// List all the known records.
|
||||
func (s *sqlStore) List(opts ...store.ListOption) ([]string, error) {
|
||||
rows, err := s.db.Query(fmt.Sprintf("SELECT `key`, value, expiry FROM %s.%s;", s.database, s.table))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []string
|
||||
var cachedTime time.Time
|
||||
|
||||
for rows.Next() {
|
||||
record := &store.Record{}
|
||||
if err := rows.Scan(&record.Key, &record.Value, &cachedTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cachedTime.Before(time.Now()) {
|
||||
// record has expired
|
||||
go func() { _ = s.Delete(record.Key) }()
|
||||
} else {
|
||||
records = append(records, record.Key)
|
||||
}
|
||||
}
|
||||
rowErr := rows.Close()
|
||||
if rowErr != nil {
|
||||
// transaction rollback or something
|
||||
return records, rowErr
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Read all records with keys.
|
||||
func (s *sqlStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
|
||||
var options store.ReadOptions
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// TODO: make use of options.Prefix using WHERE key LIKE = ?
|
||||
|
||||
var records []*store.Record
|
||||
row := s.readPrepare.QueryRow(key)
|
||||
record := &store.Record{}
|
||||
var cachedTime time.Time
|
||||
|
||||
if err := row.Scan(&record.Key, &record.Value, &cachedTime); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return records, store.ErrNotFound
|
||||
}
|
||||
return records, err
|
||||
}
|
||||
if cachedTime.Before(time.Now()) {
|
||||
// record has expired
|
||||
go func() { _ = s.Delete(key) }()
|
||||
return records, store.ErrNotFound
|
||||
}
|
||||
record.Expiry = time.Until(cachedTime)
|
||||
records = append(records, record)
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Write records.
|
||||
func (s *sqlStore) Write(r *store.Record, opts ...store.WriteOption) error {
|
||||
timeCached := time.Now().Add(r.Expiry)
|
||||
_, err := s.writePrepare.Exec(r.Key, r.Value, timeCached, r.Value, timeCached)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Couldn't insert record "+r.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete records with keys.
|
||||
func (s *sqlStore) Delete(key string, opts ...store.DeleteOption) error {
|
||||
result, err := s.deletePrepare.Exec(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) initDB() error {
|
||||
// Create the namespace's database
|
||||
_, err := s.db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s ;", s.database))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(fmt.Sprintf("USE %s ;", s.database))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Couldn't use database")
|
||||
}
|
||||
|
||||
// Create a table for the namespace's prefix
|
||||
createSQL := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (`key` varchar(255) primary key, value blob null, expiry timestamp not null);", s.table)
|
||||
_, err = s.db.Exec(createSQL)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Couldn't create table")
|
||||
}
|
||||
|
||||
// prepare statements
|
||||
var prepareErr error
|
||||
|
||||
s.readPrepare, prepareErr = s.db.Prepare(fmt.Sprintf("SELECT `key`, value, expiry FROM %s.%s WHERE `key` = ?;", s.database, s.table))
|
||||
if prepareErr != nil {
|
||||
return errors.Wrap(prepareErr, "failed to prepare read statement")
|
||||
}
|
||||
|
||||
s.writePrepare, prepareErr = s.db.Prepare(fmt.Sprintf("INSERT INTO %s.%s (`key`, value, expiry) VALUES(?, ?, ?) ON DUPLICATE KEY UPDATE `value`= ?, `expiry` = ?", s.database, s.table))
|
||||
if prepareErr != nil {
|
||||
return errors.Wrap(prepareErr, "failed to prepare write statement")
|
||||
}
|
||||
|
||||
s.deletePrepare, prepareErr = s.db.Prepare(fmt.Sprintf("DELETE FROM %s.%s WHERE `key` = ?;", s.database, s.table))
|
||||
if prepareErr != nil {
|
||||
return errors.Wrap(prepareErr, "failed to prepare delete statement")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) configure() error {
|
||||
nodes := s.options.Nodes
|
||||
if len(nodes) == 0 {
|
||||
nodes = []string{"localhost:3306"}
|
||||
}
|
||||
|
||||
database := s.options.Database
|
||||
if len(database) == 0 {
|
||||
database = DefaultDatabase
|
||||
}
|
||||
|
||||
table := s.options.Table
|
||||
if len(table) == 0 {
|
||||
table = DefaultTable
|
||||
}
|
||||
|
||||
for _, r := range database {
|
||||
if !unicode.IsLetter(r) {
|
||||
return errors.New("store.namespace must only contain letters")
|
||||
}
|
||||
}
|
||||
|
||||
source := nodes[0]
|
||||
// create source from first node
|
||||
db, err := sql.Open("mysql", source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
s.db.Close()
|
||||
}
|
||||
|
||||
// save the values
|
||||
s.db = db
|
||||
s.database = database
|
||||
s.table = table
|
||||
|
||||
// initialize the database
|
||||
return s.initDB()
|
||||
}
|
||||
|
||||
func (s *sqlStore) String() string {
|
||||
return "mysql"
|
||||
}
|
||||
|
||||
// New returns a new micro Store backed by sql.
|
||||
func NewMysqlStore(opts ...store.Option) store.Store {
|
||||
var options store.Options
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// new store
|
||||
s := new(sqlStore)
|
||||
// set the options
|
||||
s.options = options
|
||||
|
||||
// configure the store
|
||||
if err := s.configure(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// return store
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
var (
|
||||
sqlStoreT store.Store
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if tr := os.Getenv("TRAVIS"); len(tr) > 0 {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
sqlStoreT = NewMysqlStore(
|
||||
store.Database("testMicro"),
|
||||
store.Nodes("root:123@(127.0.0.1:3306)/test?charset=utf8&parseTime=true&loc=Asia%2FShanghai"),
|
||||
)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
err := sqlStoreT.Write(
|
||||
&store.Record{
|
||||
Key: "test",
|
||||
Value: []byte("foo2"),
|
||||
Expiry: time.Second * 200,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
err := sqlStoreT.Delete("test")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRead(t *testing.T) {
|
||||
records, err := sqlStoreT.Read("test")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
t.Log(string(records[0].Value))
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
records, err := sqlStoreT.List()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
} else {
|
||||
beauty, _ := json.Marshal(records)
|
||||
t.Log(string(beauty))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# NATS JetStream Key Value Store Plugin
|
||||
|
||||
This plugin uses the NATS JetStream [KeyValue Store](https://docs.nats.io/nats-concepts/jetstream/key-value-store) to implement the Go-Micro store interface.
|
||||
|
||||
You can use this plugin like any other store plugin.
|
||||
To start a local NATS JetStream server run `nats-server -js`.
|
||||
|
||||
To manually create a new storage object call:
|
||||
|
||||
```go
|
||||
natsjskv.NewStore(opts ...store.Option)
|
||||
```
|
||||
|
||||
The Go-Micro store interface uses databases and tables to store keys. These translate
|
||||
to buckets (key value stores) and key prefixes. If no database (bucket name) is provided, "default" will be used.
|
||||
|
||||
You can call `Write` with any arbitrary database name, and if a bucket with that name does not exist yet,
|
||||
it will be automatically created.
|
||||
|
||||
If a table name is provided, it will use it to prefix the key as `<table>_<key>`.
|
||||
|
||||
To delete a bucket, and all the key/value pairs in it, pass the `DeleteBucket` option to the `Delete`
|
||||
method, then they key name will be interpreted as a bucket name, and the bucket will be deleted.
|
||||
|
||||
Next to the default store options, a few NATS specific options are available:
|
||||
|
||||
|
||||
```go
|
||||
// NatsOptions accepts nats.Options
|
||||
NatsOptions(opts nats.Options)
|
||||
|
||||
// JetStreamOptions accepts multiple nats.JSOpt
|
||||
JetStreamOptions(opts ...nats.JSOpt)
|
||||
|
||||
// KeyValueOptions accepts multiple nats.KeyValueConfig
|
||||
// This will create buckets with the provided configs at initialization.
|
||||
//
|
||||
// type KeyValueConfig struct {
|
||||
// Bucket string
|
||||
// Description string
|
||||
// MaxValueSize int32
|
||||
// History uint8
|
||||
// TTL time.Duration
|
||||
// MaxBytes int64
|
||||
// Storage StorageType
|
||||
// Replicas int
|
||||
// Placement *Placement
|
||||
// RePublish *RePublish
|
||||
// Mirror *StreamSource
|
||||
// Sources []*StreamSource
|
||||
}
|
||||
KeyValueOptions(cfg ...*nats.KeyValueConfig)
|
||||
|
||||
// DefaultTTL sets the default TTL to use for new buckets
|
||||
// By default no TTL is set.
|
||||
//
|
||||
// TTL ON INDIVIDUAL WRITE CALLS IS NOT SUPPORTED, only bucket wide TTL.
|
||||
// Either set a default TTL with this option or provide bucket specific options
|
||||
// with ObjectStoreOptions
|
||||
DefaultTTL(ttl time.Duration)
|
||||
|
||||
// DefaultMemory sets the default storage type to memory only.
|
||||
//
|
||||
// The default is file storage, persisting storage between service restarts.
|
||||
// Be aware that the default storage location of NATS the /tmp dir is, and thus
|
||||
// won't persist reboots.
|
||||
DefaultMemory()
|
||||
|
||||
// DefaultDescription sets the default description to use when creating new
|
||||
// buckets. The default is "Store managed by go-micro"
|
||||
DefaultDescription(text string)
|
||||
|
||||
// DeleteBucket will use the key passed to Delete as a bucket (database) name,
|
||||
// and delete the bucket.
|
||||
// This option should not be combined with the store.DeleteFrom option, as
|
||||
// that will overwrite the delete action.
|
||||
DeleteBucket()
|
||||
```
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package natsjskv
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// setStoreOption returns a function to setup a context with given value.
|
||||
func setStoreOption(k, v interface{}) store.Option {
|
||||
return func(o *store.Options) {
|
||||
if o.Context == nil {
|
||||
o.Context = context.Background()
|
||||
}
|
||||
|
||||
o.Context = context.WithValue(o.Context, k, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package natsjskv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
nserver "github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/test-go/testify/require"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func testSetup(ctx context.Context, t *testing.T, opts ...store.Option) store.Store {
|
||||
t.Helper()
|
||||
|
||||
var err error
|
||||
var s store.Store
|
||||
for i := 0; i < 5; i++ {
|
||||
nCtx, cancel := context.WithCancel(ctx)
|
||||
addr := startNatsServer(nCtx, t)
|
||||
|
||||
opts = append(opts, store.Nodes(addr), EncodeKeys())
|
||||
s = NewStore(opts...)
|
||||
|
||||
err = s.Init()
|
||||
if err != nil {
|
||||
t.Log(errors.Wrap(err, "Error: Server initialization failed, restarting server"))
|
||||
cancel()
|
||||
if err = s.Close(); err != nil {
|
||||
t.Logf("Failed to close store: %v", err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
cancel()
|
||||
if err = s.Close(); err != nil {
|
||||
t.Logf("Failed to close store: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return s
|
||||
}
|
||||
t.Error(errors.Wrap(err, "Store initialization failed"))
|
||||
return s
|
||||
}
|
||||
|
||||
func startNatsServer(ctx context.Context, t *testing.T) string {
|
||||
t.Helper()
|
||||
natsAddr := getFreeLocalhostAddress()
|
||||
natsPort, err := strconv.Atoi(strings.Split(natsAddr, ":")[1])
|
||||
if err != nil {
|
||||
t.Logf("Failed to parse port from address: %v", err)
|
||||
}
|
||||
|
||||
clusterName := "gomicro-store-test-cluster"
|
||||
|
||||
// start the NATS with JetStream server
|
||||
go natsServer(ctx,
|
||||
t,
|
||||
&nserver.Options{
|
||||
Host: strings.Split(natsAddr, ":")[0],
|
||||
Port: natsPort,
|
||||
Cluster: nserver.ClusterOpts{
|
||||
Name: clusterName,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return natsAddr
|
||||
}
|
||||
|
||||
func getFreeLocalhostAddress() string {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
addr := l.Addr().String()
|
||||
if err := l.Close(); err != nil {
|
||||
return addr
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func natsServer(ctx context.Context, t *testing.T, opts *nserver.Options) {
|
||||
t.Helper()
|
||||
|
||||
opts.TLSTimeout = 180
|
||||
server, err := nserver.NewServer(
|
||||
opts,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer server.Shutdown()
|
||||
|
||||
server.SetLoggerV2(
|
||||
NewLogWrapper(),
|
||||
false, false, false,
|
||||
)
|
||||
|
||||
tmpdir := t.TempDir()
|
||||
natsdir := filepath.Join(tmpdir, "nats-js")
|
||||
jsConf := &nserver.JetStreamConfig{
|
||||
StoreDir: natsdir,
|
||||
}
|
||||
|
||||
// first start NATS
|
||||
go server.Start()
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// second start JetStream
|
||||
err = server.EnableJetStream(jsConf)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// This fixes some issues where tests fail because directory cleanup fails
|
||||
t.Cleanup(func() {
|
||||
contents, err := filepath.Glob(natsdir + "/*")
|
||||
if err != nil {
|
||||
t.Logf("Failed to glob directory: %v", err)
|
||||
}
|
||||
for _, item := range contents {
|
||||
if err := os.RemoveAll(item); err != nil {
|
||||
t.Logf("Failed to remove file: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(natsdir); err != nil {
|
||||
t.Logf("Failed to remove directory: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func NewLogWrapper() *LogWrapper {
|
||||
return &LogWrapper{}
|
||||
}
|
||||
|
||||
type LogWrapper struct {
|
||||
}
|
||||
|
||||
// Noticef logs a notice statement.
|
||||
func (l *LogWrapper) Noticef(_ string, _ ...interface{}) {
|
||||
}
|
||||
|
||||
// Warnf logs a warning statement.
|
||||
func (l *LogWrapper) Warnf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Fatalf logs a fatal statement.
|
||||
func (l *LogWrapper) Fatalf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Errorf logs an error statement.
|
||||
func (l *LogWrapper) Errorf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Debugf logs a debug statement.
|
||||
func (l *LogWrapper) Debugf(_ string, _ ...interface{}) {
|
||||
}
|
||||
|
||||
// Tracef logs a trace statement.
|
||||
func (l *LogWrapper) Tracef(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package natsjskv
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NatsKey is a convenience function to create a key for the nats kv store.
|
||||
func (n *natsStore) NatsKey(table, microkey string) string {
|
||||
return n.NewKey(table, microkey, "").NatsKey()
|
||||
}
|
||||
|
||||
// MicroKey is a convenience function to create a key for the micro interface.
|
||||
func (n *natsStore) MicroKey(table, natskey string) string {
|
||||
return n.NewKey(table, "", natskey).MicroKey()
|
||||
}
|
||||
|
||||
// MicroKeyFilter is a convenience function to create a key for the micro interface.
|
||||
// It returns false if the key does not match the table, prefix or suffix.
|
||||
func (n *natsStore) MicroKeyFilter(table, natskey string, prefix, suffix string) (string, bool) {
|
||||
k := n.NewKey(table, "", natskey)
|
||||
return k.MicroKey(), k.Check(table, prefix, suffix)
|
||||
}
|
||||
|
||||
// Key represents a key in the store.
|
||||
// They are used to convert nats keys (base32 encoded) to micro keys (plain text - no table prefix) and vice versa.
|
||||
type Key struct {
|
||||
// Plain is the plain key as requested by the go-micro interface.
|
||||
Plain string
|
||||
// Full is the full key including the table prefix.
|
||||
Full string
|
||||
// Encoded is the base64 encoded key as used by the nats kv store.
|
||||
Encoded string
|
||||
}
|
||||
|
||||
// NewKey creates a new key. Either plain or encoded must be set.
|
||||
func (n *natsStore) NewKey(table string, plain, encoded string) *Key {
|
||||
k := &Key{
|
||||
Plain: plain,
|
||||
Encoded: encoded,
|
||||
}
|
||||
|
||||
switch {
|
||||
case k.Plain != "":
|
||||
k.Full = getKey(k.Plain, table)
|
||||
k.Encoded = encode(k.Full, n.encoding)
|
||||
case k.Encoded != "":
|
||||
k.Full = decode(k.Encoded, n.encoding)
|
||||
k.Plain = trimKey(k.Full, table)
|
||||
}
|
||||
|
||||
return k
|
||||
}
|
||||
|
||||
// NatsKey returns a key the nats kv store can work with.
|
||||
func (k *Key) NatsKey() string {
|
||||
return k.Encoded
|
||||
}
|
||||
|
||||
// MicroKey returns a key the micro interface can work with.
|
||||
func (k *Key) MicroKey() string {
|
||||
return k.Plain
|
||||
}
|
||||
|
||||
// Check returns false if the key does not match the table, prefix or suffix.
|
||||
func (k *Key) Check(table, prefix, suffix string) bool {
|
||||
if table != "" && k.Full != getKey(k.Plain, table) {
|
||||
return false
|
||||
}
|
||||
|
||||
if prefix != "" && !strings.HasPrefix(k.Plain, prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
if suffix != "" && !strings.HasSuffix(k.Plain, suffix) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func encode(s string, alg string) string {
|
||||
switch alg {
|
||||
case "base32":
|
||||
return base32.StdEncoding.EncodeToString([]byte(s))
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func decode(s string, alg string) string {
|
||||
switch alg {
|
||||
case "base32":
|
||||
b, err := base32.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
|
||||
return string(b)
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func getKey(key, table string) string {
|
||||
if table != "" {
|
||||
return table + "_" + key
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func trimKey(key, table string) string {
|
||||
if table != "" {
|
||||
return strings.TrimPrefix(key, table+"_")
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// Package natsjskv is a go-micro store plugin for NATS JetStream Key-Value store.
|
||||
package natsjskv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cornelk/hashmap"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/pkg/errors"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrBucketNotFound is returned when the requested bucket does not exist.
|
||||
ErrBucketNotFound = errors.New("Bucket (database) not found")
|
||||
)
|
||||
|
||||
// KeyValueEnvelope is the data structure stored in the key value store.
|
||||
type KeyValueEnvelope struct {
|
||||
Key string `json:"key"`
|
||||
Data []byte `json:"data"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type natsStore struct {
|
||||
sync.Once
|
||||
sync.RWMutex
|
||||
|
||||
encoding string
|
||||
ttl time.Duration
|
||||
storageType nats.StorageType
|
||||
description string
|
||||
|
||||
opts store.Options
|
||||
nopts nats.Options
|
||||
jsopts []nats.JSOpt
|
||||
kvConfigs []*nats.KeyValueConfig
|
||||
|
||||
conn *nats.Conn
|
||||
js nats.JetStreamContext
|
||||
buckets *hashmap.Map[string, nats.KeyValue]
|
||||
}
|
||||
|
||||
// NewStore will create a new NATS JetStream Object Store.
|
||||
func NewStore(opts ...store.Option) store.Store {
|
||||
options := store.Options{
|
||||
Nodes: []string{},
|
||||
Database: "default",
|
||||
Table: "",
|
||||
Context: context.Background(),
|
||||
}
|
||||
|
||||
n := &natsStore{
|
||||
description: "KeyValue storage administered by go-micro store plugin",
|
||||
opts: options,
|
||||
jsopts: []nats.JSOpt{},
|
||||
kvConfigs: []*nats.KeyValueConfig{},
|
||||
buckets: hashmap.New[string, nats.KeyValue](),
|
||||
storageType: nats.FileStorage,
|
||||
}
|
||||
|
||||
n.setOption(opts...)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// Init initializes the store. It must perform any required setup on the
|
||||
// backing storage implementation and check that it is ready for use,
|
||||
// returning any errors.
|
||||
func (n *natsStore) Init(opts ...store.Option) error {
|
||||
n.setOption(opts...)
|
||||
|
||||
// Connect to NATS servers
|
||||
conn, err := n.nopts.Connect()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to connect to NATS Server")
|
||||
}
|
||||
|
||||
// Create JetStream context
|
||||
js, err := conn.JetStream(n.jsopts...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create JetStream context")
|
||||
}
|
||||
|
||||
n.conn = conn
|
||||
n.js = js
|
||||
|
||||
// Create default config if no configs present
|
||||
if len(n.kvConfigs) == 0 {
|
||||
if _, err := n.mustGetBucketByName(n.opts.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create kv store buckets
|
||||
for _, cfg := range n.kvConfigs {
|
||||
if _, err := n.mustGetBucket(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *natsStore) setOption(opts ...store.Option) {
|
||||
for _, o := range opts {
|
||||
o(&n.opts)
|
||||
}
|
||||
|
||||
n.Do(func() {
|
||||
n.nopts = nats.GetDefaultOptions()
|
||||
})
|
||||
|
||||
// Extract options from context
|
||||
if nopts, ok := n.opts.Context.Value(natsOptionsKey{}).(nats.Options); ok {
|
||||
n.nopts = nopts
|
||||
}
|
||||
|
||||
if jsopts, ok := n.opts.Context.Value(jsOptionsKey{}).([]nats.JSOpt); ok {
|
||||
n.jsopts = append(n.jsopts, jsopts...)
|
||||
}
|
||||
|
||||
if cfg, ok := n.opts.Context.Value(kvOptionsKey{}).([]*nats.KeyValueConfig); ok {
|
||||
n.kvConfigs = append(n.kvConfigs, cfg...)
|
||||
}
|
||||
|
||||
if ttl, ok := n.opts.Context.Value(ttlOptionsKey{}).(time.Duration); ok {
|
||||
n.ttl = ttl
|
||||
}
|
||||
|
||||
if sType, ok := n.opts.Context.Value(memoryOptionsKey{}).(nats.StorageType); ok {
|
||||
n.storageType = sType
|
||||
}
|
||||
|
||||
if text, ok := n.opts.Context.Value(descriptionOptionsKey{}).(string); ok {
|
||||
n.description = text
|
||||
}
|
||||
|
||||
if encoding, ok := n.opts.Context.Value(keyEncodeOptionsKey{}).(string); ok {
|
||||
n.encoding = encoding
|
||||
}
|
||||
|
||||
// Assign store option server addresses to nats options
|
||||
if len(n.opts.Nodes) > 0 {
|
||||
n.nopts.Url = ""
|
||||
n.nopts.Servers = n.opts.Nodes
|
||||
}
|
||||
|
||||
if len(n.nopts.Servers) == 0 && n.nopts.Url == "" {
|
||||
n.nopts.Url = nats.DefaultURL
|
||||
}
|
||||
}
|
||||
|
||||
// Options allows you to view the current options.
|
||||
func (n *natsStore) Options() store.Options {
|
||||
return n.opts
|
||||
}
|
||||
|
||||
// Read takes a single key name and optional ReadOptions. It returns matching []*Record or an error.
|
||||
func (n *natsStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
|
||||
if err := n.initConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opt := store.ReadOptions{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
if opt.Database == "" {
|
||||
opt.Database = n.opts.Database
|
||||
}
|
||||
|
||||
if opt.Table == "" {
|
||||
opt.Table = n.opts.Table
|
||||
}
|
||||
|
||||
bucket, ok := n.buckets.Get(opt.Database)
|
||||
if !ok {
|
||||
return nil, ErrBucketNotFound
|
||||
}
|
||||
|
||||
keys, err := n.natsKeys(bucket, opt.Table, key, opt.Prefix, opt.Suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]*store.Record, 0, len(keys))
|
||||
|
||||
for _, key := range keys {
|
||||
rec, ok, err := n.getRecord(bucket, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ok {
|
||||
records = append(records, rec)
|
||||
}
|
||||
}
|
||||
|
||||
return enforceLimits(records, opt.Limit, opt.Offset), nil
|
||||
}
|
||||
|
||||
// Write writes a record to the store, and returns an error if the record was not written.
|
||||
func (n *natsStore) Write(rec *store.Record, opts ...store.WriteOption) error {
|
||||
if err := n.initConn(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opt := store.WriteOptions{}
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
if opt.Database == "" {
|
||||
opt.Database = n.opts.Database
|
||||
}
|
||||
|
||||
if opt.Table == "" {
|
||||
opt.Table = n.opts.Table
|
||||
}
|
||||
|
||||
store, err := n.mustGetBucketByName(opt.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, err := json.Marshal(KeyValueEnvelope{
|
||||
Key: rec.Key,
|
||||
Data: rec.Value,
|
||||
Metadata: rec.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to marshal object")
|
||||
}
|
||||
|
||||
if _, err := store.Put(n.NatsKey(opt.Table, rec.Key), b); err != nil {
|
||||
return errors.Wrapf(err, "Failed to store data in bucket '%s'", n.NatsKey(opt.Table, rec.Key))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the record with the corresponding key from the store.
|
||||
func (n *natsStore) Delete(key string, opts ...store.DeleteOption) error {
|
||||
if err := n.initConn(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opt := store.DeleteOptions{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
if opt.Database == "" {
|
||||
opt.Database = n.opts.Database
|
||||
}
|
||||
|
||||
if opt.Table == "" {
|
||||
opt.Table = n.opts.Table
|
||||
}
|
||||
|
||||
if opt.Table == "DELETE_BUCKET" {
|
||||
n.buckets.Del(key)
|
||||
|
||||
if err := n.js.DeleteKeyValue(key); err != nil {
|
||||
return errors.Wrap(err, "Failed to delete bucket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
store, ok := n.buckets.Get(opt.Database)
|
||||
if !ok {
|
||||
return ErrBucketNotFound
|
||||
}
|
||||
|
||||
if err := store.Delete(n.NatsKey(opt.Table, key)); err != nil {
|
||||
return errors.Wrap(err, "Failed to delete data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns any keys that match, or an empty list with no error if none matched.
|
||||
func (n *natsStore) List(opts ...store.ListOption) ([]string, error) {
|
||||
if err := n.initConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opt := store.ListOptions{}
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
if opt.Database == "" {
|
||||
opt.Database = n.opts.Database
|
||||
}
|
||||
|
||||
if opt.Table == "" {
|
||||
opt.Table = n.opts.Table
|
||||
}
|
||||
|
||||
store, ok := n.buckets.Get(opt.Database)
|
||||
if !ok {
|
||||
return nil, ErrBucketNotFound
|
||||
}
|
||||
|
||||
keys, err := n.microKeys(store, opt.Table, opt.Prefix, opt.Suffix)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to list keys in bucket")
|
||||
}
|
||||
|
||||
return enforceLimits(keys, opt.Limit, opt.Offset), nil
|
||||
}
|
||||
|
||||
// Close the store.
|
||||
func (n *natsStore) Close() error {
|
||||
n.conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the name of the implementation.
|
||||
func (n *natsStore) String() string {
|
||||
return "NATS JetStream KeyValueStore"
|
||||
}
|
||||
|
||||
// thread safe way to initialize the connection.
|
||||
func (n *natsStore) initConn() error {
|
||||
if n.hasConn() {
|
||||
return nil
|
||||
}
|
||||
|
||||
n.Lock()
|
||||
defer n.Unlock()
|
||||
|
||||
// check if conn was initialized meanwhile
|
||||
if n.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return n.Init()
|
||||
}
|
||||
|
||||
// thread safe way to check if n is initialized.
|
||||
func (n *natsStore) hasConn() bool {
|
||||
n.RLock()
|
||||
defer n.RUnlock()
|
||||
|
||||
return n.conn != nil
|
||||
}
|
||||
|
||||
// mustGetDefaultBucket returns the bucket with the given name creating it with default configuration if needed.
|
||||
func (n *natsStore) mustGetBucketByName(name string) (nats.KeyValue, error) {
|
||||
return n.mustGetBucket(&nats.KeyValueConfig{
|
||||
Bucket: name,
|
||||
Description: n.description,
|
||||
TTL: n.ttl,
|
||||
Storage: n.storageType,
|
||||
})
|
||||
}
|
||||
|
||||
// mustGetBucket creates a new bucket if it does not exist yet.
|
||||
func (n *natsStore) mustGetBucket(kv *nats.KeyValueConfig) (nats.KeyValue, error) {
|
||||
if store, ok := n.buckets.Get(kv.Bucket); ok {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
store, err := n.js.KeyValue(kv.Bucket)
|
||||
if err != nil {
|
||||
if !errors.Is(err, nats.ErrBucketNotFound) {
|
||||
return nil, errors.Wrapf(err, "Failed to get bucket (%s)", kv.Bucket)
|
||||
}
|
||||
|
||||
store, err = n.js.CreateKeyValue(kv)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "Failed to create bucket (%s)", kv.Bucket)
|
||||
}
|
||||
}
|
||||
|
||||
n.buckets.Set(kv.Bucket, store)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// getRecord returns the record with the given key from the nats kv store.
|
||||
func (n *natsStore) getRecord(bucket nats.KeyValue, key string) (*store.Record, bool, error) {
|
||||
obj, err := bucket.Get(key)
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil, false, store.ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, false, errors.Wrap(err, "Failed to get object from bucket")
|
||||
}
|
||||
|
||||
var kv KeyValueEnvelope
|
||||
if err := json.Unmarshal(obj.Value(), &kv); err != nil {
|
||||
return nil, false, errors.Wrap(err, "Failed to unmarshal object")
|
||||
}
|
||||
|
||||
if obj.Operation() != nats.KeyValuePut {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return &store.Record{
|
||||
Key: kv.Key,
|
||||
Value: kv.Data,
|
||||
Metadata: kv.Metadata,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (n *natsStore) natsKeys(bucket nats.KeyValue, table, key string, prefix, suffix bool) ([]string, error) {
|
||||
if !suffix && !prefix {
|
||||
return []string{n.NatsKey(table, key)}, nil
|
||||
}
|
||||
|
||||
toS := func(s string, b bool) string {
|
||||
if b {
|
||||
return s
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
keys, _, err := n.getKeys(bucket, table, toS(key, prefix), toS(key, suffix))
|
||||
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (n *natsStore) microKeys(bucket nats.KeyValue, table, prefix, suffix string) ([]string, error) {
|
||||
_, keys, err := n.getKeys(bucket, table, prefix, suffix)
|
||||
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (n *natsStore) getKeys(bucket nats.KeyValue, table string, prefix, suffix string) ([]string, []string, error) {
|
||||
names, err := bucket.Keys(nats.IgnoreDeletes())
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return []string{}, []string{}, nil
|
||||
} else if err != nil {
|
||||
return []string{}, []string{}, errors.Wrap(err, "Failed to list objects")
|
||||
}
|
||||
|
||||
natsKeys := make([]string, 0, len(names))
|
||||
microKeys := make([]string, 0, len(names))
|
||||
|
||||
for _, k := range names {
|
||||
mkey, ok := n.MicroKeyFilter(table, k, prefix, suffix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
natsKeys = append(natsKeys, k)
|
||||
microKeys = append(microKeys, mkey)
|
||||
}
|
||||
|
||||
return natsKeys, microKeys, nil
|
||||
}
|
||||
|
||||
// enforces offset and limit without causing a panic.
|
||||
func enforceLimits[V any](recs []V, limit, offset uint) []V {
|
||||
l := uint(len(recs))
|
||||
|
||||
from := offset
|
||||
if from > l {
|
||||
from = l
|
||||
}
|
||||
|
||||
to := l
|
||||
if limit > 0 && offset+limit < l {
|
||||
to = offset + limit
|
||||
}
|
||||
|
||||
return recs[from:to]
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package natsjskv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/pkg/errors"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestNats(t *testing.T) {
|
||||
// Setup without calling Init on purpose
|
||||
var err error
|
||||
for i := 0; i < 5; i++ {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
addr := startNatsServer(ctx, t)
|
||||
s := NewStore(store.Nodes(addr), EncodeKeys())
|
||||
|
||||
// Test String method
|
||||
t.Log("Testing:", s.String())
|
||||
|
||||
err = basicTest(t, s)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Test reading non-existing key
|
||||
r, err := s.Read("this-is-a-random-key")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Expected %# v, got %# v", store.ErrNotFound, err)
|
||||
}
|
||||
if len(r) > 0 {
|
||||
t.Fatal("Lenth should be 0")
|
||||
}
|
||||
err = s.Close()
|
||||
if err != nil {
|
||||
t.Logf("Failed to close store: %v", err)
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := testSetup(ctx, t,
|
||||
DefaultMemory(),
|
||||
|
||||
// Having a non-default description will trigger nats.ErrStreamNameAlreadyInUse
|
||||
// since the buckets have been created in previous tests with a different description.
|
||||
//
|
||||
// NOTE: this is only the case with a manually set up server, not with current
|
||||
// test setup, where new servers are started for each test.
|
||||
DefaultDescription("My fancy description"),
|
||||
|
||||
// Option has no effect in this context, just to test setting the option
|
||||
JetStreamOptions(nats.PublishAsyncMaxPending(256)),
|
||||
|
||||
// Sets a custom NATS client name, just to test the NatsOptions() func
|
||||
NatsOptions(nats.Options{Name: "Go NATS Store Plugin Tests Client"}),
|
||||
|
||||
KeyValueOptions(&nats.KeyValueConfig{
|
||||
Bucket: "TestBucketName",
|
||||
Description: "This bucket is not used",
|
||||
TTL: 5 * time.Minute,
|
||||
MaxBytes: 1024,
|
||||
Storage: nats.MemoryStorage,
|
||||
Replicas: 1,
|
||||
}),
|
||||
|
||||
// Encode keys to avoid character limitations
|
||||
EncodeKeys(),
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
if err := basicTest(t, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTL(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ttl := 500 * time.Millisecond
|
||||
s := testSetup(ctx, t,
|
||||
DefaultTTL(ttl),
|
||||
|
||||
// Since these buckets will be new they will have the new description
|
||||
DefaultDescription("My fancy description"),
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
// Use a uuid to make sure a new bucket is created when using local server
|
||||
id := uuid.New().String()
|
||||
for _, r := range table {
|
||||
if err := s.Write(r.Record, store.WriteTo(r.Database+id, r.Table)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(ttl * 2)
|
||||
|
||||
for _, r := range table {
|
||||
res, err := s.Read(r.Record.Key, store.ReadFrom(r.Database+id, r.Table))
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Expected %# v, got %# v", store.ErrNotFound, err)
|
||||
}
|
||||
if len(res) > 0 {
|
||||
t.Fatal("Fetched record while it should have expired")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaData(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := testSetup(ctx, t)
|
||||
defer cancel()
|
||||
|
||||
record := store.Record{
|
||||
Key: "KeyOne",
|
||||
Value: []byte("Some value"),
|
||||
Metadata: map[string]interface{}{
|
||||
"meta-one": "val",
|
||||
"meta-two": 5,
|
||||
},
|
||||
Expiry: 0,
|
||||
}
|
||||
bucket := "meta-data-test"
|
||||
if err := s.Write(&record, store.WriteTo(bucket, "")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := s.Read(record.Key, store.ReadFrom(bucket, ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r) == 0 {
|
||||
t.Fatal("No results found")
|
||||
}
|
||||
|
||||
m := r[0].Metadata
|
||||
if m["meta-one"].(string) != record.Metadata["meta-one"].(string) ||
|
||||
m["meta-two"].(float64) != float64(record.Metadata["meta-two"].(int)) {
|
||||
t.Fatalf("Metadata does not match: (%+v) != (%+v)", m, record.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := testSetup(ctx, t)
|
||||
defer cancel()
|
||||
|
||||
for _, r := range table {
|
||||
if err := s.Write(r.Record, store.WriteTo(r.Database, r.Table)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.Delete(r.Record.Key, store.DeleteFrom(r.Database, r.Table)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
|
||||
res, err := s.Read(r.Record.Key, store.ReadFrom(r.Database, r.Table))
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("Expected %# v, got %# v", store.ErrNotFound, err)
|
||||
}
|
||||
if len(res) > 0 {
|
||||
t.Fatalf("Failed to delete %s:%s from %s %s (len: %d)", r.Record.Key, r.Record.Value, r.Database, r.Table, len(res))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := testSetup(ctx, t)
|
||||
defer cancel()
|
||||
|
||||
for _, r := range table {
|
||||
if err := s.Write(r.Record, store.WriteTo(r.Database, r.Table)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
l := []struct {
|
||||
Database string
|
||||
Table string
|
||||
Length int
|
||||
Prefix string
|
||||
Suffix string
|
||||
Offset int
|
||||
Limit int
|
||||
}{
|
||||
{Length: 7},
|
||||
{Database: "prefix-test", Length: 7},
|
||||
{Database: "prefix-test", Offset: 2, Length: 5},
|
||||
{Database: "prefix-test", Offset: 2, Limit: 3, Length: 3},
|
||||
{Database: "prefix-test", Table: "names", Length: 3},
|
||||
{Database: "prefix-test", Table: "cities", Length: 4},
|
||||
{Database: "prefix-test", Table: "cities", Suffix: "City", Length: 3},
|
||||
{Database: "prefix-test", Table: "cities", Suffix: "City", Limit: 2, Length: 2},
|
||||
{Database: "prefix-test", Table: "cities", Suffix: "City", Offset: 1, Length: 2},
|
||||
{Prefix: "test", Length: 1},
|
||||
{Table: "some_table", Prefix: "test", Suffix: "test", Length: 2},
|
||||
}
|
||||
|
||||
for i, entry := range l {
|
||||
// Test listing keys
|
||||
keys, err := s.List(
|
||||
store.ListFrom(entry.Database, entry.Table),
|
||||
store.ListPrefix(entry.Prefix),
|
||||
store.ListSuffix(entry.Suffix),
|
||||
store.ListOffset(uint(entry.Offset)),
|
||||
store.ListLimit(uint(entry.Limit)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != entry.Length {
|
||||
t.Fatalf("Length of returned keys is invalid for test %d - %+v (%d)", i+1, entry, len(keys))
|
||||
}
|
||||
|
||||
// Test reading keys
|
||||
if entry.Prefix != "" || entry.Suffix != "" {
|
||||
var key string
|
||||
options := []store.ReadOption{
|
||||
store.ReadFrom(entry.Database, entry.Table),
|
||||
store.ReadLimit(uint(entry.Limit)),
|
||||
store.ReadOffset(uint(entry.Offset)),
|
||||
}
|
||||
if entry.Prefix != "" {
|
||||
key = entry.Prefix
|
||||
options = append(options, store.ReadPrefix())
|
||||
}
|
||||
if entry.Suffix != "" {
|
||||
key = entry.Suffix
|
||||
options = append(options, store.ReadSuffix())
|
||||
}
|
||||
r, err := s.Read(key, options...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r) != entry.Length {
|
||||
t.Fatalf("Length of read keys is invalid for test %d - %+v (%d)", i+1, entry, len(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := testSetup(ctx, t)
|
||||
defer cancel()
|
||||
|
||||
for _, r := range table {
|
||||
if err := s.Write(r.Record, store.WriteTo(r.Database, r.Table)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
bucket := "prefix-test"
|
||||
if err := s.Delete(bucket, DeleteBucket()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keys, err := s.List(store.ListFrom(bucket, ""))
|
||||
if err != nil && !errors.Is(err, ErrBucketNotFound) {
|
||||
t.Fatalf("Failed to delete bucket: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
t.Fatal("Length of key list should be 0 after bucket deletion")
|
||||
}
|
||||
|
||||
r, err := s.Read("", store.ReadPrefix(), store.ReadFrom(bucket, ""))
|
||||
if err != nil && !errors.Is(err, ErrBucketNotFound) {
|
||||
t.Fatalf("Failed to delete bucket: %v", err)
|
||||
}
|
||||
if len(r) > 0 {
|
||||
t.Fatal("Length of record list should be 0 after bucket deletion", len(r))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceLimits(t *testing.T) {
|
||||
s := []string{"a", "b", "c", "d"}
|
||||
var testCasts = []struct {
|
||||
Alias string
|
||||
Offset uint
|
||||
Limit uint
|
||||
Expected []string
|
||||
}{
|
||||
{"plain", 0, 0, []string{"a", "b", "c", "d"}},
|
||||
{"offset&limit-1", 1, 3, []string{"b", "c", "d"}},
|
||||
{"offset&limit-2", 1, 1, []string{"b"}},
|
||||
{"offset=length", 4, 0, []string{}},
|
||||
{"offset>length", 222, 0, []string{}},
|
||||
{"limit>length", 0, 36, []string{"a", "b", "c", "d"}},
|
||||
}
|
||||
for _, tc := range testCasts {
|
||||
actual := enforceLimits(s, tc.Limit, tc.Offset)
|
||||
if !reflect.DeepEqual(actual, tc.Expected) {
|
||||
t.Fatalf("%s: Expected %v, got %v", tc.Alias, tc.Expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func basicTest(t *testing.T, s store.Store) error {
|
||||
t.Helper()
|
||||
for _, test := range table {
|
||||
if err := s.Write(test.Record, store.WriteTo(test.Database, test.Table)); err != nil {
|
||||
return errors.Wrap(err, "Failed to write record in basic test")
|
||||
}
|
||||
r, err := s.Read(test.Record.Key, store.ReadFrom(test.Database, test.Table))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to read record in basic test")
|
||||
}
|
||||
if len(r) == 0 {
|
||||
t.Fatalf("No results found for %s (%s) %s", test.Record.Key, test.Database, test.Table)
|
||||
}
|
||||
|
||||
key := test.Record.Key
|
||||
val1 := string(test.Record.Value)
|
||||
|
||||
key2 := r[0].Key
|
||||
val2 := string(r[0].Value)
|
||||
if val1 != val2 {
|
||||
t.Fatalf("Value not equal for (%s: %s) != (%s: %s)", key, val1, key2, val2)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package natsjskv
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// store.Option.
|
||||
type natsOptionsKey struct{}
|
||||
type jsOptionsKey struct{}
|
||||
type kvOptionsKey struct{}
|
||||
type ttlOptionsKey struct{}
|
||||
type memoryOptionsKey struct{}
|
||||
type descriptionOptionsKey struct{}
|
||||
type keyEncodeOptionsKey struct{}
|
||||
|
||||
// NatsOptions accepts nats.Options.
|
||||
func NatsOptions(opts nats.Options) store.Option {
|
||||
return setStoreOption(natsOptionsKey{}, opts)
|
||||
}
|
||||
|
||||
// JetStreamOptions accepts multiple nats.JSOpt.
|
||||
func JetStreamOptions(opts ...nats.JSOpt) store.Option {
|
||||
return setStoreOption(jsOptionsKey{}, opts)
|
||||
}
|
||||
|
||||
// KeyValueOptions accepts multiple nats.KeyValueConfig
|
||||
// This will create buckets with the provided configs at initialization.
|
||||
func KeyValueOptions(cfg ...*nats.KeyValueConfig) store.Option {
|
||||
return setStoreOption(kvOptionsKey{}, cfg)
|
||||
}
|
||||
|
||||
// DefaultTTL sets the default TTL to use for new buckets
|
||||
//
|
||||
// By default no TTL is set.
|
||||
//
|
||||
// TTL ON INDIVIDUAL WRITE CALLS IS NOT SUPPORTED, only bucket wide TTL.
|
||||
// Either set a default TTL with this option or provide bucket specific options
|
||||
//
|
||||
// with ObjectStoreOptions
|
||||
func DefaultTTL(ttl time.Duration) store.Option {
|
||||
return setStoreOption(ttlOptionsKey{}, ttl)
|
||||
}
|
||||
|
||||
// DefaultMemory sets the default storage type to memory only.
|
||||
//
|
||||
// The default is file storage, persisting storage between service restarts.
|
||||
//
|
||||
// Be aware that the default storage location of NATS the /tmp dir is, and thus
|
||||
//
|
||||
// won't persist reboots.
|
||||
func DefaultMemory() store.Option {
|
||||
return setStoreOption(memoryOptionsKey{}, nats.MemoryStorage)
|
||||
}
|
||||
|
||||
// DefaultDescription sets the default description to use when creating new
|
||||
//
|
||||
// buckets. The default is "Store managed by go-micro"
|
||||
func DefaultDescription(text string) store.Option {
|
||||
return setStoreOption(descriptionOptionsKey{}, text)
|
||||
}
|
||||
|
||||
// EncodeKeys will "base32" encode the keys.
|
||||
// This is to work around limited characters usable as keys for the natsjs kv store.
|
||||
// See details here: https://docs.nats.io/nats-concepts/subjects#characters-allowed-for-subject-names
|
||||
func EncodeKeys() store.Option {
|
||||
return setStoreOption(keyEncodeOptionsKey{}, "base32")
|
||||
}
|
||||
|
||||
// DeleteBucket will use the key passed to Delete as a bucket (database) name,
|
||||
//
|
||||
// and delete the bucket.
|
||||
//
|
||||
// This option should not be combined with the store.DeleteFrom option, as
|
||||
//
|
||||
// that will overwrite the delete action.
|
||||
func DeleteBucket() store.DeleteOption {
|
||||
return func(d *store.DeleteOptions) {
|
||||
d.Table = "DELETE_BUCKET"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package natsjskv
|
||||
|
||||
import "go-micro.dev/v6/store"
|
||||
|
||||
type test struct {
|
||||
Record *store.Record
|
||||
Database string
|
||||
Table string
|
||||
}
|
||||
|
||||
var (
|
||||
table = []test{
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "One",
|
||||
Value: []byte("First value"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Two",
|
||||
Value: []byte("Second value"),
|
||||
},
|
||||
Table: "prefix_test",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Third",
|
||||
Value: []byte("Third value"),
|
||||
},
|
||||
Database: "new-bucket",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Four",
|
||||
Value: []byte("Fourth value"),
|
||||
},
|
||||
Database: "new-bucket",
|
||||
Table: "prefix_test",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "empty-value",
|
||||
Value: []byte{},
|
||||
},
|
||||
Database: "new-bucket",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Alex",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "names",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Jones",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "names",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Adrianna",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "names",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "MexicoCity",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "cities",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "HoustonCity",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "cities",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "ZurichCity",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "cities",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "Helsinki",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Database: "prefix-test",
|
||||
Table: "cities",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "testKeytest",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Table: "some_table",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "testSecondtest",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Table: "some_table",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "lalala",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
Table: "some_table",
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "testAnothertest",
|
||||
Value: []byte("Some value"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Record: &store.Record{
|
||||
Key: "FobiddenCharactersAreAllowed:|@..+",
|
||||
Value: []byte("data no matter"),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package store
|
||||
|
||||
type noopStore struct{}
|
||||
|
||||
func (n *noopStore) Init(opts ...Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noopStore) Options() Options {
|
||||
return Options{}
|
||||
}
|
||||
|
||||
func (n *noopStore) String() string {
|
||||
return "noop"
|
||||
}
|
||||
|
||||
func (n *noopStore) Read(key string, opts ...ReadOption) ([]*Record, error) {
|
||||
return []*Record{}, nil
|
||||
}
|
||||
|
||||
func (n *noopStore) Write(r *Record, opts ...WriteOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noopStore) Delete(key string, opts ...DeleteOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noopStore) List(opts ...ListOption) ([]string, error) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func (n *noopStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewNoopStore(opts ...Option) Store {
|
||||
return new(noopStore)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
// Options contains configuration for the Store.
|
||||
type Options struct {
|
||||
// Context should contain all implementation specific options, using context.WithValue.
|
||||
Context context.Context
|
||||
// Client to use for RPC
|
||||
Client client.Client
|
||||
// Logger is the underline logger
|
||||
Logger logger.Logger
|
||||
// Database allows multiple isolated stores to be kept in one backend, if supported.
|
||||
Database string
|
||||
// Table is analogous to a table in database backends or a key prefix in KV backends
|
||||
Table string
|
||||
// Nodes contains the addresses or other connection information of the backing storage.
|
||||
// For example, an etcd implementation would contain the nodes of the cluster.
|
||||
// A SQL implementation could contain one or more connection strings.
|
||||
Nodes []string
|
||||
}
|
||||
|
||||
// Option sets values in Options.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Nodes contains the addresses or other connection information of the backing storage.
|
||||
// For example, an etcd implementation would contain the nodes of the cluster.
|
||||
// A SQL implementation could contain one or more connection strings.
|
||||
func Nodes(a ...string) Option {
|
||||
return func(o *Options) {
|
||||
o.Nodes = a
|
||||
}
|
||||
}
|
||||
|
||||
// Database allows multiple isolated stores to be kept in one backend, if supported.
|
||||
func Database(db string) Option {
|
||||
return func(o *Options) {
|
||||
o.Database = db
|
||||
}
|
||||
}
|
||||
|
||||
func Table(t string) Option {
|
||||
return func(o *Options) {
|
||||
o.Table = t
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the stores context, for any extra configuration.
|
||||
func WithContext(c context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = c
|
||||
}
|
||||
}
|
||||
|
||||
// WithClient sets the stores client to use for RPC.
|
||||
func WithClient(c client.Client) Option {
|
||||
return func(o *Options) {
|
||||
o.Client = c
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets the underline logger.
|
||||
func WithLogger(l logger.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = l
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOptions configures an individual Read operation.
|
||||
type ReadOptions struct {
|
||||
Database, Table string
|
||||
// Prefix returns all records that are prefixed with key
|
||||
Prefix bool
|
||||
// Suffix returns all records that have the suffix key
|
||||
Suffix bool
|
||||
// Limit limits the number of returned records
|
||||
Limit uint
|
||||
// Offset when combined with Limit supports pagination
|
||||
Offset uint
|
||||
}
|
||||
|
||||
// ReadOption sets values in ReadOptions.
|
||||
type ReadOption func(r *ReadOptions)
|
||||
|
||||
// ReadFrom the database and table.
|
||||
func ReadFrom(database, table string) ReadOption {
|
||||
return func(r *ReadOptions) {
|
||||
r.Database = database
|
||||
r.Table = table
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPrefix returns all records that are prefixed with key.
|
||||
func ReadPrefix() ReadOption {
|
||||
return func(r *ReadOptions) {
|
||||
r.Prefix = true
|
||||
}
|
||||
}
|
||||
|
||||
// ReadSuffix returns all records that have the suffix key.
|
||||
func ReadSuffix() ReadOption {
|
||||
return func(r *ReadOptions) {
|
||||
r.Suffix = true
|
||||
}
|
||||
}
|
||||
|
||||
// ReadLimit limits the number of responses to l.
|
||||
func ReadLimit(l uint) ReadOption {
|
||||
return func(r *ReadOptions) {
|
||||
r.Limit = l
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOffset starts returning responses from o. Use in conjunction with Limit for pagination.
|
||||
func ReadOffset(o uint) ReadOption {
|
||||
return func(r *ReadOptions) {
|
||||
r.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
// WriteOptions configures an individual Write operation
|
||||
// If Expiry and TTL are set TTL takes precedence.
|
||||
type WriteOptions struct {
|
||||
// Expiry is the time the record expires
|
||||
Expiry time.Time
|
||||
Database, Table string
|
||||
// TTL is the time until the record expires
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// WriteOption sets values in WriteOptions.
|
||||
type WriteOption func(w *WriteOptions)
|
||||
|
||||
// WriteTo the database and table.
|
||||
func WriteTo(database, table string) WriteOption {
|
||||
return func(w *WriteOptions) {
|
||||
w.Database = database
|
||||
w.Table = table
|
||||
}
|
||||
}
|
||||
|
||||
// WriteExpiry is the time the record expires.
|
||||
func WriteExpiry(t time.Time) WriteOption {
|
||||
return func(w *WriteOptions) {
|
||||
w.Expiry = t
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTTL is the time the record expires.
|
||||
func WriteTTL(d time.Duration) WriteOption {
|
||||
return func(w *WriteOptions) {
|
||||
w.TTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteOptions configures an individual Delete operation.
|
||||
type DeleteOptions struct {
|
||||
Database, Table string
|
||||
}
|
||||
|
||||
// DeleteOption sets values in DeleteOptions.
|
||||
type DeleteOption func(d *DeleteOptions)
|
||||
|
||||
// DeleteFrom the database and table.
|
||||
func DeleteFrom(database, table string) DeleteOption {
|
||||
return func(d *DeleteOptions) {
|
||||
d.Database = database
|
||||
d.Table = table
|
||||
}
|
||||
}
|
||||
|
||||
// ListOptions configures an individual List operation.
|
||||
type ListOptions struct {
|
||||
// List from the following
|
||||
Database, Table string
|
||||
// Prefix returns all keys that are prefixed with key
|
||||
Prefix string
|
||||
// Suffix returns all keys that end with key
|
||||
Suffix string
|
||||
// Limit limits the number of returned keys
|
||||
Limit uint
|
||||
// Offset when combined with Limit supports pagination
|
||||
Offset uint
|
||||
}
|
||||
|
||||
// ListOption sets values in ListOptions.
|
||||
type ListOption func(l *ListOptions)
|
||||
|
||||
// ListFrom the database and table.
|
||||
func ListFrom(database, table string) ListOption {
|
||||
return func(l *ListOptions) {
|
||||
l.Database = database
|
||||
l.Table = table
|
||||
}
|
||||
}
|
||||
|
||||
// ListPrefix returns all keys that are prefixed with key.
|
||||
func ListPrefix(p string) ListOption {
|
||||
return func(l *ListOptions) {
|
||||
l.Prefix = p
|
||||
}
|
||||
}
|
||||
|
||||
// ListSuffix returns all keys that end with key.
|
||||
func ListSuffix(s string) ListOption {
|
||||
return func(l *ListOptions) {
|
||||
l.Suffix = s
|
||||
}
|
||||
}
|
||||
|
||||
// ListLimit limits the number of returned keys to l.
|
||||
func ListLimit(l uint) ListOption {
|
||||
return func(lo *ListOptions) {
|
||||
lo.Limit = l
|
||||
}
|
||||
}
|
||||
|
||||
// ListOffset starts returning responses from o. Use in conjunction with Limit for pagination.
|
||||
func ListOffset(o uint) ListOption {
|
||||
return func(l *ListOptions) {
|
||||
l.Offset = o
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Postgres plugin
|
||||
|
||||
This module implements a Postgres implementation of the micro store interface.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### Concepts
|
||||
We maintain a single connection to the Postgres server. Due to the way connections are handled this means that all micro "databases" and "tables" are stored under a single Postgres database as specified in the connection string (https://www.postgresql.org/docs/8.1/ddl-schemas.html). The mapping of micro to Postgres concepts is:
|
||||
- micro database => Postgres schema
|
||||
- micro table => Postgres table
|
||||
|
||||
### Expiry
|
||||
Expiry is managed by an expiry column in the table. A record's expiry is specified in the column and when a record is read the expiry field is first checked, only returning the record if its still valid otherwise it's deleted. A maintenance loop also periodically runs to delete any rows that have expired.
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2020 Asim Aslam
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// https://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.
|
||||
//
|
||||
// Original source: github.com/micro/go-plugins/v3/store/cockroach/metadata.go
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// https://github.com/upper/db/blob/master/postgresql/custom_types.go#L43
|
||||
type Metadata map[string]interface{}
|
||||
|
||||
// Scan satisfies the sql.Scanner interface.
|
||||
func (m *Metadata) Scan(src interface{}) error {
|
||||
source, ok := src.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion .([]byte) failed")
|
||||
}
|
||||
|
||||
var i interface{}
|
||||
err := json.Unmarshal(source, &i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*m, ok = i.(map[string]interface{})
|
||||
if !ok {
|
||||
return errors.New("type assertion .(map[string]interface{}) failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value satisfies the driver.Valuer interface.
|
||||
func (m Metadata) Value() (driver.Value, error) {
|
||||
j, err := json.Marshal(m)
|
||||
return j, err
|
||||
}
|
||||
|
||||
func toMetadata(m *Metadata) map[string]interface{} {
|
||||
md := make(map[string]interface{})
|
||||
for k, v := range *m {
|
||||
md[k] = v
|
||||
}
|
||||
return md
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Postgres pgx plugin
|
||||
|
||||
This module implements a Postgres implementation of the micro store interface.
|
||||
It uses modern https://github.com/jackc/pgx driver to access Postgres.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### Concepts
|
||||
Every database has they own connection pool. Due to the way connections are handled this means that all micro "databases" and "tables" can be stored under a single or several Postgres database as specified in the connection string (https://www.postgresql.org/docs/8.1/ddl-schemas.html). The mapping of micro to Postgres concepts is:
|
||||
- micro database => Postgres schema
|
||||
- micro table => Postgres table
|
||||
|
||||
### Expiry
|
||||
Expiry is managed by an expiry column in the table. A record's expiry is specified in the column and when a record is read the expiry field is first checked, only returning the record if it's still valid otherwise it's deleted. A maintenance loop also periodically runs to delete any rows that have expired.
|
||||
@@ -0,0 +1,8 @@
|
||||
package pgx
|
||||
|
||||
import "github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
type DB struct {
|
||||
conn *pgxpool.Pool
|
||||
tables map[string]Queries
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package pgx
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Metadata map[string]interface{}
|
||||
|
||||
// Scan satisfies the sql.Scanner interface.
|
||||
func (m *Metadata) Scan(src interface{}) error {
|
||||
source, ok := src.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion .([]byte) failed")
|
||||
}
|
||||
|
||||
var i interface{}
|
||||
err := json.Unmarshal(source, &i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*m, ok = i.(map[string]interface{})
|
||||
if !ok {
|
||||
return errors.New("type assertion .(map[string]interface{}) failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value satisfies the driver.Valuer interface.
|
||||
func (m *Metadata) Value() (driver.Value, error) {
|
||||
j, err := json.Marshal(m)
|
||||
return j, err
|
||||
}
|
||||
|
||||
func toMetadata(m *Metadata) map[string]interface{} {
|
||||
md := make(map[string]interface{})
|
||||
for k, v := range *m {
|
||||
md[k] = v
|
||||
}
|
||||
return md
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// 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
|
||||
//
|
||||
// https://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 pgx implements the postgres store with pgx driver
|
||||
package pgx
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"go-micro.dev/v6/logger"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
const defaultDatabase = "micro"
|
||||
const defaultTable = "micro"
|
||||
|
||||
type sqlStore struct {
|
||||
options store.Options
|
||||
re *regexp.Regexp
|
||||
sync.Mutex
|
||||
// known databases
|
||||
databases map[string]DB
|
||||
}
|
||||
|
||||
func (s *sqlStore) getDB(database, table string) (string, string) {
|
||||
if len(database) == 0 {
|
||||
if len(s.options.Database) > 0 {
|
||||
database = s.options.Database
|
||||
} else {
|
||||
database = defaultDatabase
|
||||
}
|
||||
}
|
||||
|
||||
if len(table) == 0 {
|
||||
if len(s.options.Table) > 0 {
|
||||
table = s.options.Table
|
||||
} else {
|
||||
table = defaultTable
|
||||
}
|
||||
}
|
||||
|
||||
// store.namespace must only contain letters, numbers and underscores
|
||||
database = s.re.ReplaceAllString(database, "_")
|
||||
table = s.re.ReplaceAllString(table, "_")
|
||||
|
||||
return database, table
|
||||
}
|
||||
|
||||
func (s *sqlStore) db(database, table string) (*pgxpool.Pool, Queries, error) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
database, table = s.getDB(database, table)
|
||||
|
||||
if _, ok := s.databases[database]; !ok {
|
||||
err := s.initDB(database)
|
||||
if err != nil {
|
||||
return nil, Queries{}, err
|
||||
}
|
||||
}
|
||||
dbObj := s.databases[database]
|
||||
if _, ok := dbObj.tables[table]; !ok {
|
||||
err := s.initTable(database, table)
|
||||
if err != nil {
|
||||
return nil, Queries{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return dbObj.conn, dbObj.tables[table], nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) initTable(database, table string) error {
|
||||
db := s.databases[database].conn
|
||||
|
||||
_, err := db.Exec(s.options.Context, fmt.Sprintf(createTable, database, table))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot create table")
|
||||
}
|
||||
|
||||
_, err = db.Exec(s.options.Context, fmt.Sprintf(createMDIndex, table, database, table))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot create metadata index")
|
||||
}
|
||||
|
||||
_, err = db.Exec(s.options.Context, fmt.Sprintf(createExpiryIndex, table, database, table))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot create expiry index")
|
||||
}
|
||||
|
||||
s.databases[database].tables[table] = NewQueries(database, table)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) initDB(database string) error {
|
||||
if len(s.options.Nodes) == 0 {
|
||||
s.options.Nodes = []string{"postgresql://root@localhost:26257?sslmode=disable"}
|
||||
}
|
||||
|
||||
source := s.options.Nodes[0]
|
||||
// check if it is a standard connection string eg: host=%s port=%d user=%s password=%s dbname=%s sslmode=disable
|
||||
// if err is nil which means it would be a URL like postgre://xxxx?yy=zz
|
||||
_, err := url.Parse(source)
|
||||
if err != nil {
|
||||
if !strings.Contains(source, " ") {
|
||||
source = fmt.Sprintf("host=%s", source)
|
||||
}
|
||||
}
|
||||
|
||||
config, err := pgxpool.ParseConfig(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := pgxpool.NewWithConfig(s.options.Context, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = db.Ping(s.options.Context); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(s.options.Context, fmt.Sprintf(createSchema, database))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(database) == 0 {
|
||||
if len(s.options.Database) > 0 {
|
||||
database = s.options.Database
|
||||
} else {
|
||||
database = defaultDatabase
|
||||
}
|
||||
}
|
||||
|
||||
// save the values
|
||||
s.databases[database] = DB{
|
||||
conn: db,
|
||||
tables: make(map[string]Queries),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) Close() error {
|
||||
for _, obj := range s.databases {
|
||||
obj.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) Init(opts ...store.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&s.options)
|
||||
}
|
||||
_, _, err := s.db(s.options.Database, s.options.Table)
|
||||
return err
|
||||
}
|
||||
|
||||
// List all the known records
|
||||
func (s *sqlStore) List(opts ...store.ListOption) ([]string, error) {
|
||||
options := store.ListOptions{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
db, queries, err := s.db(options.Database, options.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pattern := "%"
|
||||
if options.Prefix != "" {
|
||||
pattern = options.Prefix + pattern
|
||||
}
|
||||
if options.Suffix != "" {
|
||||
pattern = pattern + options.Suffix
|
||||
}
|
||||
|
||||
var rows pgx.Rows
|
||||
if options.Limit > 0 {
|
||||
rows, err = db.Query(s.options.Context, queries.ListAscLimit, pattern, options.Limit, options.Offset)
|
||||
|
||||
} else {
|
||||
|
||||
rows, err = db.Query(s.options.Context, queries.ListAsc, pattern)
|
||||
|
||||
}
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]string, 0, 10)
|
||||
for rows.Next() {
|
||||
var key string
|
||||
err = rows.Scan(&key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// rowToRecord converts from pgx.Row to a store.Record
|
||||
func (s *sqlStore) rowToRecord(row pgx.Row) (*store.Record, error) {
|
||||
var expiry *time.Time
|
||||
record := &store.Record{}
|
||||
metadata := make(Metadata)
|
||||
|
||||
if err := row.Scan(&record.Key, &record.Value, &metadata, &expiry); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return record, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set the metadata
|
||||
record.Metadata = toMetadata(&metadata)
|
||||
if expiry != nil {
|
||||
record.Expiry = time.Until(*expiry)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// rowsToRecords converts from pgx.Rows to []*store.Record
|
||||
func (s *sqlStore) rowsToRecords(rows pgx.Rows) ([]*store.Record, error) {
|
||||
var records []*store.Record
|
||||
|
||||
for rows.Next() {
|
||||
var expiry *time.Time
|
||||
record := &store.Record{}
|
||||
metadata := make(Metadata)
|
||||
|
||||
if err := rows.Scan(&record.Key, &record.Value, &metadata, &expiry); err != nil {
|
||||
return records, err
|
||||
}
|
||||
|
||||
// set the metadata
|
||||
record.Metadata = toMetadata(&metadata)
|
||||
if expiry != nil {
|
||||
record.Expiry = time.Until(*expiry)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Read a single key
|
||||
func (s *sqlStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
|
||||
options := store.ReadOptions{}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
db, queries, err := s.db(options.Database, options.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// read one record
|
||||
if !options.Prefix && !options.Suffix {
|
||||
row := db.QueryRow(s.options.Context, queries.ReadOne, key)
|
||||
record, err := s.rowToRecord(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*store.Record{record}, nil
|
||||
}
|
||||
|
||||
// read by pattern
|
||||
pattern := "%"
|
||||
if options.Prefix {
|
||||
pattern = key + pattern
|
||||
}
|
||||
if options.Suffix {
|
||||
pattern = pattern + key
|
||||
}
|
||||
|
||||
var rows pgx.Rows
|
||||
if options.Limit > 0 {
|
||||
|
||||
rows, err = db.Query(s.options.Context, queries.ListAscLimit, pattern, options.Limit, options.Offset)
|
||||
|
||||
} else {
|
||||
|
||||
rows, err = db.Query(s.options.Context, queries.ListAsc, pattern)
|
||||
|
||||
}
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.rowsToRecords(rows)
|
||||
}
|
||||
|
||||
// Write records
|
||||
func (s *sqlStore) Write(r *store.Record, opts ...store.WriteOption) error {
|
||||
var options store.WriteOptions
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
db, queries, err := s.db(options.Database, options.Table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadata := make(Metadata)
|
||||
for k, v := range r.Metadata {
|
||||
metadata[k] = v
|
||||
}
|
||||
|
||||
if r.Expiry != 0 {
|
||||
_, err = db.Exec(s.options.Context, queries.Write, r.Key, r.Value, metadata, time.Now().Add(r.Expiry))
|
||||
} else {
|
||||
_, err = db.Exec(s.options.Context, queries.Write, r.Key, r.Value, metadata, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot upsert record "+r.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete records with keys
|
||||
func (s *sqlStore) Delete(key string, opts ...store.DeleteOption) error {
|
||||
var options store.DeleteOptions
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
db, queries, err := s.db(options.Database, options.Table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(s.options.Context, queries.Delete, key)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqlStore) Options() store.Options {
|
||||
return s.options
|
||||
}
|
||||
|
||||
func (s *sqlStore) String() string {
|
||||
return "pgx"
|
||||
}
|
||||
|
||||
// NewStore returns a new micro Store backed by sql
|
||||
func NewStore(opts ...store.Option) store.Store {
|
||||
options := store.Options{
|
||||
Database: defaultDatabase,
|
||||
Table: defaultTable,
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// new store
|
||||
s := new(sqlStore)
|
||||
s.options = options
|
||||
s.databases = make(map[string]DB)
|
||||
s.re = regexp.MustCompile("[^a-zA-Z0-9]+")
|
||||
|
||||
go s.expiryLoop()
|
||||
// return store
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *sqlStore) expiryLoop() {
|
||||
for {
|
||||
err := s.expireRows()
|
||||
if err != nil {
|
||||
logger.Errorf("error cleaning up %s", err)
|
||||
}
|
||||
time.Sleep(1 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sqlStore) expireRows() error {
|
||||
for database, dbObj := range s.databases {
|
||||
db := dbObj.conn
|
||||
for table, queries := range dbObj.tables {
|
||||
res, err := db.Exec(s.options.Context, queries.DeleteExpired)
|
||||
if err != nil {
|
||||
logger.Errorf("Error cleaning up %s", err)
|
||||
return err
|
||||
}
|
||||
logger.Infof("Cleaning up %s %s: %d rows deleted", database, table, res.RowsAffected())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package pgx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type testObj struct {
|
||||
One string
|
||||
Two int64
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
t.Run("ReadWrite", func(t *testing.T) {
|
||||
s := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"))
|
||||
b, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s.Write(&store.Record{
|
||||
Key: "foobar/baz",
|
||||
Value: b,
|
||||
Metadata: map[string]interface{}{
|
||||
"meta1": "val1",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs, err := s.Read("foobar/baz")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs, 1)
|
||||
assert.Equal(t, "foobar/baz", recs[0].Key)
|
||||
assert.Len(t, recs[0].Metadata, 1)
|
||||
assert.Equal(t, "val1", recs[0].Metadata["meta1"])
|
||||
|
||||
var tobj testObj
|
||||
assert.NoError(t, json.Unmarshal(recs[0].Value, &tobj))
|
||||
assert.Equal(t, "1", tobj.One)
|
||||
assert.Equal(t, int64(2), tobj.Two)
|
||||
})
|
||||
t.Run("Prefix", func(t *testing.T) {
|
||||
s := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"))
|
||||
b, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s.Write(&store.Record{
|
||||
Key: "foo/bar",
|
||||
Value: b,
|
||||
Metadata: map[string]interface{}{
|
||||
"meta1": "val1",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
err = s.Write(&store.Record{
|
||||
Key: "foo/baz",
|
||||
Value: b,
|
||||
Metadata: map[string]interface{}{
|
||||
"meta1": "val1",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs, err := s.Read("foo/", store.ReadPrefix())
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs, 2)
|
||||
assert.Equal(t, "foo/bar", recs[0].Key)
|
||||
assert.Equal(t, "foo/baz", recs[1].Key)
|
||||
})
|
||||
|
||||
t.Run("MultipleTables", func(t *testing.T) {
|
||||
s1 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Table("t1"))
|
||||
s2 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Table("t2"))
|
||||
b1, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s1.Write(&store.Record{
|
||||
Key: "foo/bar",
|
||||
Value: b1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
b2, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err = s2.Write(&store.Record{
|
||||
Key: "foo/baz",
|
||||
Value: b2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs1, err := s1.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs1, 1)
|
||||
assert.Equal(t, "foo/bar", recs1[0])
|
||||
|
||||
recs2, err := s2.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs2, 1)
|
||||
assert.Equal(t, "foo/baz", recs2[0])
|
||||
})
|
||||
|
||||
t.Run("MultipleDBs", func(t *testing.T) {
|
||||
s1 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Database("d1"))
|
||||
s2 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Database("d2"))
|
||||
b1, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s1.Write(&store.Record{
|
||||
Key: "foo/bar",
|
||||
Value: b1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
b2, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err = s2.Write(&store.Record{
|
||||
Key: "foo/baz",
|
||||
Value: b2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs1, err := s1.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs1, 1)
|
||||
assert.Equal(t, "foo/bar", recs1[0])
|
||||
|
||||
recs2, err := s2.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs2, 1)
|
||||
assert.Equal(t, "foo/baz", recs2[0])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pgx
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Queries struct {
|
||||
// read
|
||||
ListAsc string
|
||||
ListAscLimit string
|
||||
ListDesc string
|
||||
ListDescLimit string
|
||||
ReadOne string
|
||||
ReadManyAsc string
|
||||
ReadManyAscLimit string
|
||||
ReadManyDesc string
|
||||
ReadManyDescLimit string
|
||||
|
||||
// change
|
||||
Write string
|
||||
Delete string
|
||||
DeleteExpired string
|
||||
}
|
||||
|
||||
func NewQueries(database, table string) Queries {
|
||||
return Queries{
|
||||
ListAsc: fmt.Sprintf(list, database, table) + asc,
|
||||
ListAscLimit: fmt.Sprintf(list, database, table) + asc + limit,
|
||||
ListDesc: fmt.Sprintf(list, database, table) + desc,
|
||||
ListDescLimit: fmt.Sprintf(list, database, table) + desc + limit,
|
||||
ReadOne: fmt.Sprintf(readOne, database, table),
|
||||
ReadManyAsc: fmt.Sprintf(readMany, database, table) + asc,
|
||||
ReadManyAscLimit: fmt.Sprintf(readMany, database, table) + asc + limit,
|
||||
ReadManyDesc: fmt.Sprintf(readMany, database, table) + desc,
|
||||
ReadManyDescLimit: fmt.Sprintf(readMany, database, table) + desc + limit,
|
||||
Write: fmt.Sprintf(write, database, table),
|
||||
Delete: fmt.Sprintf(deleteRecord, database, table),
|
||||
DeleteExpired: fmt.Sprintf(deleteExpired, database, table),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package pgx
|
||||
|
||||
// init
|
||||
|
||||
const createSchema = "CREATE SCHEMA IF NOT EXISTS %s"
|
||||
const createTable = `CREATE TABLE IF NOT EXISTS %s.%s
|
||||
(
|
||||
key text primary key,
|
||||
value bytea,
|
||||
metadata JSONB,
|
||||
expiry timestamp with time zone
|
||||
)`
|
||||
const createMDIndex = `create index if not exists idx_md_%s ON %s.%s USING GIN (metadata)`
|
||||
const createExpiryIndex = `create index if not exists idx_expiry_%s on %s.%s (expiry) where (expiry IS NOT NULL)`
|
||||
|
||||
// base queries
|
||||
const (
|
||||
list = "SELECT key FROM %s.%s WHERE key LIKE $1 and (expiry < now() or expiry isnull)"
|
||||
readOne = "SELECT key, value, metadata, expiry FROM %s.%s WHERE key = $1 and (expiry < now() or expiry isnull)"
|
||||
readMany = "SELECT key, value, metadata, expiry FROM %s.%s WHERE key LIKE $1 and (expiry < now() or expiry isnull)"
|
||||
write = `INSERT INTO %s.%s(key, value, metadata, expiry)
|
||||
VALUES ($1, $2::bytea, $3, $4)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE
|
||||
SET value = EXCLUDED.value, metadata = EXCLUDED.metadata, expiry = EXCLUDED.expiry`
|
||||
deleteRecord = "DELETE FROM %s.%s WHERE key = $1"
|
||||
deleteExpired = "DELETE FROM %s.%s WHERE expiry < now()"
|
||||
)
|
||||
|
||||
// suffixes
|
||||
const (
|
||||
limit = " LIMIT $2 OFFSET $3"
|
||||
asc = " ORDER BY key ASC"
|
||||
desc = " ORDER BY key DESC"
|
||||
)
|
||||
@@ -0,0 +1,657 @@
|
||||
// Copyright 2020 Asim Aslam
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// https://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.
|
||||
//
|
||||
// Original source: github.com/micro/go-plugins/v3/store/cockroach/cockroach.go
|
||||
|
||||
// Package postgres implements the postgres store
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/pkg/errors"
|
||||
"go-micro.dev/v6/logger"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// DefaultDatabase is the namespace that the sql store
|
||||
// will use if no namespace is provided.
|
||||
var (
|
||||
DefaultDatabase = "micro"
|
||||
DefaultTable = "micro"
|
||||
ErrNoConnection = errors.New("Database connection not initialized")
|
||||
)
|
||||
|
||||
var (
|
||||
re = regexp.MustCompile("[^a-zA-Z0-9]+")
|
||||
|
||||
// the sql statements we prepare and use
|
||||
statements = map[string]string{
|
||||
"list": "SELECT key, value, metadata, expiry FROM %s.%s WHERE key LIKE $1 ORDER BY key ASC LIMIT $2 OFFSET $3;",
|
||||
"read": "SELECT key, value, metadata, expiry FROM %s.%s WHERE key = $1;",
|
||||
"readMany": "SELECT key, value, metadata, expiry FROM %s.%s WHERE key LIKE $1 ORDER BY key ASC;",
|
||||
"readOffset": "SELECT key, value, metadata, expiry FROM %s.%s WHERE key LIKE $1 ORDER BY key ASC LIMIT $2 OFFSET $3;",
|
||||
"write": "INSERT INTO %s.%s(key, value, metadata, expiry) VALUES ($1, $2::bytea, $3, $4) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, metadata = EXCLUDED.metadata, expiry = EXCLUDED.expiry;",
|
||||
"delete": "DELETE FROM %s.%s WHERE key = $1;",
|
||||
"deleteExpired": "DELETE FROM %s.%s WHERE expiry < now();",
|
||||
"showTables": "SELECT schemaname, tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema';",
|
||||
}
|
||||
)
|
||||
|
||||
type sqlStore struct {
|
||||
options store.Options
|
||||
dbConn *sql.DB
|
||||
|
||||
sync.RWMutex
|
||||
// known databases
|
||||
databases map[string]bool
|
||||
}
|
||||
|
||||
func (s *sqlStore) getDB(database, table string) (string, string) {
|
||||
if len(database) == 0 {
|
||||
if len(s.options.Database) > 0 {
|
||||
database = s.options.Database
|
||||
} else {
|
||||
database = DefaultDatabase
|
||||
}
|
||||
}
|
||||
|
||||
if len(table) == 0 {
|
||||
if len(s.options.Table) > 0 {
|
||||
table = s.options.Table
|
||||
} else {
|
||||
table = DefaultTable
|
||||
}
|
||||
}
|
||||
|
||||
// store.namespace must only contain letters, numbers and underscores
|
||||
database = re.ReplaceAllString(database, "_")
|
||||
table = re.ReplaceAllString(table, "_")
|
||||
|
||||
return database, table
|
||||
}
|
||||
|
||||
// createDB ensures that the DB and table have been created. It's used for lazy initialisation
|
||||
// and will record which tables have been created to reduce calls to the DB
|
||||
func (s *sqlStore) createDB(database, table string) error {
|
||||
database, table = s.getDB(database, table)
|
||||
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
if _, ok := s.databases[database+":"+table]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.initDB(database, table); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.databases[database+":"+table] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// db returns a valid connection to the DB
|
||||
func (s *sqlStore) db() (*sql.DB, error) {
|
||||
if s.dbConn == nil {
|
||||
return nil, ErrNoConnection
|
||||
}
|
||||
|
||||
if err := s.dbConn.Ping(); err != nil {
|
||||
if !isBadConnError(err) {
|
||||
return nil, err
|
||||
}
|
||||
logger.Errorf("Error with DB connection, will reconfigure: %s", err)
|
||||
if err := s.configure(); err != nil {
|
||||
logger.Errorf("Error while reconfiguring client: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return s.dbConn, nil
|
||||
}
|
||||
|
||||
// isBadConnError returns true if the error is related to having a bad connection such that you need to reconnect
|
||||
func isBadConnError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if err == driver.ErrBadConn {
|
||||
return true
|
||||
}
|
||||
|
||||
// heavy handed crude check for "connection reset by peer"
|
||||
if strings.Contains(err.Error(), syscall.ECONNRESET.Error()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// otherwise iterate through the error types
|
||||
switch t := err.(type) {
|
||||
case syscall.Errno:
|
||||
return t == syscall.ECONNRESET || t == syscall.ECONNABORTED || t == syscall.ECONNREFUSED
|
||||
case *net.OpError:
|
||||
return !t.Temporary()
|
||||
case net.Error:
|
||||
return !t.Temporary()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *sqlStore) initDB(database, table string) error {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Create the namespace's database
|
||||
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s;", database))
|
||||
if err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
return err
|
||||
}
|
||||
|
||||
var version string
|
||||
if err = db.QueryRow("select version()").Scan(&version); err == nil {
|
||||
if strings.Contains(version, "PostgreSQL") {
|
||||
_, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s;", database))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a table for the namespace's prefix
|
||||
_, err = db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.%s
|
||||
(
|
||||
key text NOT NULL,
|
||||
value bytea,
|
||||
metadata JSONB,
|
||||
expiry timestamp with time zone,
|
||||
CONSTRAINT %s_pkey PRIMARY KEY (key)
|
||||
);`, database, table, table))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Couldn't create table")
|
||||
}
|
||||
|
||||
// Create Index
|
||||
_, err = db.Exec(fmt.Sprintf(`CREATE INDEX IF NOT EXISTS "%s" ON %s.%s USING btree ("key");`, "key_index_"+table, database, table))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create Metadata Index
|
||||
_, err = db.Exec(fmt.Sprintf(`CREATE INDEX IF NOT EXISTS "%s" ON %s.%s USING GIN ("metadata");`, "metadata_index_"+table, database, table))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) configure() error {
|
||||
if len(s.options.Nodes) == 0 {
|
||||
s.options.Nodes = []string{"postgresql://root@localhost:26257?sslmode=disable"}
|
||||
}
|
||||
|
||||
source := s.options.Nodes[0]
|
||||
// check if it is a standard connection string eg: host=%s port=%d user=%s password=%s dbname=%s sslmode=disable
|
||||
// if err is nil which means it would be a URL like postgre://xxxx?yy=zz
|
||||
_, err := url.Parse(source)
|
||||
if err != nil {
|
||||
if !strings.Contains(source, " ") {
|
||||
source = fmt.Sprintf("host=%s", source)
|
||||
}
|
||||
}
|
||||
|
||||
// create source from first node
|
||||
db, err := sql.Open("postgres", source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.dbConn != nil {
|
||||
s.dbConn.Close()
|
||||
}
|
||||
|
||||
// save the values
|
||||
s.dbConn = db
|
||||
|
||||
// get DB
|
||||
database, table := s.getDB(s.options.Database, s.options.Table)
|
||||
|
||||
// initialize the database
|
||||
return s.initDB(database, table)
|
||||
}
|
||||
|
||||
func (s *sqlStore) prepare(database, table, query string) (*sql.Stmt, error) {
|
||||
st, ok := statements[query]
|
||||
if !ok {
|
||||
return nil, errors.New("unsupported statement")
|
||||
}
|
||||
|
||||
// get DB
|
||||
database, table = s.getDB(database, table)
|
||||
|
||||
q := fmt.Sprintf(st, database, table)
|
||||
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt, err := db.Prepare(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) Close() error {
|
||||
if s.dbConn != nil {
|
||||
return s.dbConn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) Init(opts ...store.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&s.options)
|
||||
}
|
||||
// reconfigure
|
||||
return s.configure()
|
||||
}
|
||||
|
||||
// List all the known records
|
||||
func (s *sqlStore) List(opts ...store.ListOption) ([]string, error) {
|
||||
options := store.ListOptions{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// create the db if not exists
|
||||
if err := s.createDB(options.Database, options.Table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := sql.NullInt32{}
|
||||
offset := 0
|
||||
pattern := "%"
|
||||
if options.Prefix != "" || options.Suffix != "" {
|
||||
if options.Prefix != "" {
|
||||
pattern = options.Prefix + pattern
|
||||
}
|
||||
if options.Suffix != "" {
|
||||
pattern = pattern + options.Suffix
|
||||
}
|
||||
}
|
||||
if options.Offset > 0 {
|
||||
offset = int(options.Offset)
|
||||
}
|
||||
if options.Limit > 0 {
|
||||
limit = sql.NullInt32{Int32: int32(options.Limit), Valid: true}
|
||||
}
|
||||
|
||||
st, err := s.prepare(options.Database, options.Table, "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
rows, err := st.Query(pattern, limit, offset)
|
||||
if err != nil {
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
records, err := s.rowsToRecords(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, k := range records {
|
||||
keys = append(keys, k.Key)
|
||||
}
|
||||
rowErr := rows.Close()
|
||||
if rowErr != nil {
|
||||
// transaction rollback or something
|
||||
return keys, rowErr
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return keys, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// rowToRecord converts from sql.Row to a store.Record. If the record has expired it will issue a delete in a separate goroutine
|
||||
func (s *sqlStore) rowToRecord(row *sql.Row) (*store.Record, error) {
|
||||
var timehelper pq.NullTime
|
||||
record := &store.Record{}
|
||||
metadata := make(Metadata)
|
||||
|
||||
if err := row.Scan(&record.Key, &record.Value, &metadata, &timehelper); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return record, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set the metadata
|
||||
record.Metadata = toMetadata(&metadata)
|
||||
if timehelper.Valid {
|
||||
if timehelper.Time.Before(time.Now()) {
|
||||
// record has expired
|
||||
go func() { _ = s.Delete(record.Key) }()
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
record.Expiry = time.Until(timehelper.Time)
|
||||
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// rowsToRecords converts from sql.Rows to []*store.Record. If a record has expired it will issue a delete in a separate goroutine
|
||||
func (s *sqlStore) rowsToRecords(rows *sql.Rows) ([]*store.Record, error) {
|
||||
var records []*store.Record
|
||||
var timehelper pq.NullTime
|
||||
|
||||
for rows.Next() {
|
||||
record := &store.Record{}
|
||||
metadata := make(Metadata)
|
||||
|
||||
if err := rows.Scan(&record.Key, &record.Value, &metadata, &timehelper); err != nil {
|
||||
return records, err
|
||||
}
|
||||
|
||||
// set the metadata
|
||||
record.Metadata = toMetadata(&metadata)
|
||||
|
||||
if timehelper.Valid {
|
||||
if timehelper.Time.Before(time.Now()) {
|
||||
// record has expired
|
||||
go func() { _ = s.Delete(record.Key) }()
|
||||
} else {
|
||||
record.Expiry = time.Until(timehelper.Time)
|
||||
records = append(records, record)
|
||||
}
|
||||
} else {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Read a single key
|
||||
func (s *sqlStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
|
||||
options := store.ReadOptions{}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// create the db if not exists
|
||||
if err := s.createDB(options.Database, options.Table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if options.Prefix || options.Suffix {
|
||||
return s.read(key, options)
|
||||
}
|
||||
|
||||
st, err := s.prepare(options.Database, options.Table, "read")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
row := st.QueryRow(key)
|
||||
record, err := s.rowToRecord(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []*store.Record
|
||||
return append(records, record), nil
|
||||
}
|
||||
|
||||
// Read Many records
|
||||
func (s *sqlStore) read(key string, options store.ReadOptions) ([]*store.Record, error) {
|
||||
pattern := "%"
|
||||
if options.Prefix {
|
||||
pattern = key + pattern
|
||||
}
|
||||
if options.Suffix {
|
||||
pattern = pattern + key
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var st *sql.Stmt
|
||||
var err error
|
||||
|
||||
if options.Limit != 0 {
|
||||
st, err = s.prepare(options.Database, options.Table, "readOffset")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
rows, err = st.Query(pattern, options.Limit, options.Offset)
|
||||
} else {
|
||||
st, err = s.prepare(options.Database, options.Table, "readMany")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
rows, err = st.Query(pattern)
|
||||
}
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return []*store.Record{}, nil
|
||||
}
|
||||
return []*store.Record{}, errors.Wrap(err, "sqlStore.read failed")
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
records, err := s.rowsToRecords(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rowErr := rows.Close()
|
||||
if rowErr != nil {
|
||||
// transaction rollback or something
|
||||
return records, rowErr
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return records, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Write records
|
||||
func (s *sqlStore) Write(r *store.Record, opts ...store.WriteOption) error {
|
||||
var options store.WriteOptions
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// create the db if not exists
|
||||
if err := s.createDB(options.Database, options.Table); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := s.prepare(options.Database, options.Table, "write")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
metadata := make(Metadata)
|
||||
for k, v := range r.Metadata {
|
||||
metadata[k] = v
|
||||
}
|
||||
|
||||
var expiry time.Time
|
||||
if r.Expiry != 0 {
|
||||
expiry = time.Now().Add(r.Expiry)
|
||||
}
|
||||
|
||||
if expiry.IsZero() {
|
||||
_, err = st.Exec(r.Key, r.Value, metadata, nil)
|
||||
} else {
|
||||
_, err = st.Exec(r.Key, r.Value, metadata, expiry)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Couldn't insert record "+r.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete records with keys
|
||||
func (s *sqlStore) Delete(key string, opts ...store.DeleteOption) error {
|
||||
var options store.DeleteOptions
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// create the db if not exists
|
||||
if err := s.createDB(options.Database, options.Table); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, err := s.prepare(options.Database, options.Table, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
result, err := st.Exec(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqlStore) Options() store.Options {
|
||||
return s.options
|
||||
}
|
||||
|
||||
func (s *sqlStore) String() string {
|
||||
return "cockroach"
|
||||
}
|
||||
|
||||
// NewStore returns a new micro Store backed by sql
|
||||
func NewStore(opts ...store.Option) store.Store {
|
||||
options := store.Options{
|
||||
Database: DefaultDatabase,
|
||||
Table: DefaultTable,
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// new store
|
||||
s := new(sqlStore)
|
||||
// set the options
|
||||
s.options = options
|
||||
// mark known databases
|
||||
s.databases = make(map[string]bool)
|
||||
// best-effort configure the store
|
||||
if err := s.configure(); err != nil {
|
||||
if logger.V(logger.ErrorLevel, logger.DefaultLogger) {
|
||||
logger.Error("Error configuring store ", err)
|
||||
}
|
||||
}
|
||||
go s.expiryLoop()
|
||||
// return store
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *sqlStore) expiryLoop() {
|
||||
for {
|
||||
_ = s.expireRows()
|
||||
time.Sleep(1 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sqlStore) expireRows() error {
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
logger.Errorf("Error getting DB connection %s", err)
|
||||
return err
|
||||
}
|
||||
stmt, err := db.Prepare(statements["showTables"])
|
||||
if err != nil {
|
||||
logger.Errorf("Error prepping show tables query %s", err)
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
rows, err := stmt.Query()
|
||||
if err != nil {
|
||||
logger.Errorf("Error running show tables query %s", err)
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var schemaName, tableName string
|
||||
if err := rows.Scan(&schemaName, &tableName); err != nil {
|
||||
logger.Errorf("Error parsing result %s", err)
|
||||
return err
|
||||
}
|
||||
db, err = s.db()
|
||||
if err != nil {
|
||||
logger.Errorf("Error prepping delete expired query %s", err)
|
||||
return err
|
||||
}
|
||||
delStmt, err := db.Prepare(fmt.Sprintf(statements["deleteExpired"], schemaName, tableName))
|
||||
if err != nil {
|
||||
logger.Errorf("Error prepping delete expired query %s", err)
|
||||
return err
|
||||
}
|
||||
defer delStmt.Close()
|
||||
res, err := delStmt.Exec()
|
||||
if err != nil {
|
||||
logger.Errorf("Error cleaning up %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
r, _ := res.RowsAffected()
|
||||
logger.Infof("Cleaning up %s %s: %d rows deleted", schemaName, tableName, r)
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type testObj struct {
|
||||
One string
|
||||
Two int64
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
t.Run("ReadWrite", func(t *testing.T) {
|
||||
s := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"))
|
||||
base := s.(*sqlStore)
|
||||
base.dbConn.Exec("DROP SCHENA IF EXISTS micro")
|
||||
b, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s.Write(&store.Record{
|
||||
Key: "foobar/baz",
|
||||
Value: b,
|
||||
Metadata: map[string]interface{}{
|
||||
"meta1": "val1",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs, err := s.Read("foobar/baz")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs, 1)
|
||||
assert.Equal(t, "foobar/baz", recs[0].Key)
|
||||
assert.Len(t, recs[0].Metadata, 1)
|
||||
assert.Equal(t, "val1", recs[0].Metadata["meta1"])
|
||||
|
||||
var tobj testObj
|
||||
assert.NoError(t, json.Unmarshal(recs[0].Value, &tobj))
|
||||
assert.Equal(t, "1", tobj.One)
|
||||
assert.Equal(t, int64(2), tobj.Two)
|
||||
})
|
||||
t.Run("Prefix", func(t *testing.T) {
|
||||
s := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"))
|
||||
base := s.(*sqlStore)
|
||||
base.dbConn.Exec("DROP SCHENA IF EXISTS micro")
|
||||
b, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s.Write(&store.Record{
|
||||
Key: "foo/bar",
|
||||
Value: b,
|
||||
Metadata: map[string]interface{}{
|
||||
"meta1": "val1",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
err = s.Write(&store.Record{
|
||||
Key: "foo/baz",
|
||||
Value: b,
|
||||
Metadata: map[string]interface{}{
|
||||
"meta1": "val1",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs, err := s.Read("foo/", store.ReadPrefix())
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs, 2)
|
||||
assert.Equal(t, "foo/bar", recs[0].Key)
|
||||
assert.Equal(t, "foo/baz", recs[1].Key)
|
||||
})
|
||||
|
||||
t.Run("MultipleTables", func(t *testing.T) {
|
||||
s1 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Table("t1"))
|
||||
s2 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Table("t2"))
|
||||
base := s1.(*sqlStore)
|
||||
base.dbConn.Exec("DROP SCHENA IF EXISTS t1")
|
||||
base.dbConn.Exec("DROP SCHENA IF EXISTS t2")
|
||||
b1, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s1.Write(&store.Record{
|
||||
Key: "foo/bar",
|
||||
Value: b1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
b2, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err = s2.Write(&store.Record{
|
||||
Key: "foo/baz",
|
||||
Value: b2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs1, err := s1.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs1, 1)
|
||||
assert.Equal(t, "foo/bar", recs1[0])
|
||||
|
||||
recs2, err := s2.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs2, 1)
|
||||
assert.Equal(t, "foo/baz", recs2[0])
|
||||
})
|
||||
|
||||
t.Run("MultipleDBs", func(t *testing.T) {
|
||||
s1 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Database("d1"))
|
||||
s2 := NewStore(store.Nodes("postgresql://postgres@localhost:5432/?sslmode=disable"), store.Database("d2"))
|
||||
base := s1.(*sqlStore)
|
||||
base.dbConn.Exec("DROP DATABASE EXISTS d1")
|
||||
base.dbConn.Exec("DROP DATABASE EXISTS d2")
|
||||
b1, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err := s1.Write(&store.Record{
|
||||
Key: "foo/bar",
|
||||
Value: b1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
b2, _ := json.Marshal(testObj{
|
||||
One: "1",
|
||||
Two: 2,
|
||||
})
|
||||
err = s2.Write(&store.Record{
|
||||
Key: "foo/baz",
|
||||
Value: b2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
recs1, err := s1.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs1, 1)
|
||||
assert.Equal(t, "foo/bar", recs1[0])
|
||||
|
||||
recs2, err := s2.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recs2, 1)
|
||||
assert.Equal(t, "foo/baz", recs2[0])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package store
|
||||
|
||||
// Scope returns a Store that confines every operation to the given
|
||||
// database and table of s, without mutating s. It is the safe way to give
|
||||
// each component — a service, an agent, a flow — its own table over a
|
||||
// shared backend: unlike Init(Table(...)), which changes process-global
|
||||
// options and so races between co-located components, a scoped handle
|
||||
// injects the database/table on each call.
|
||||
//
|
||||
// An empty database or table falls through to the underlying store's
|
||||
// default for that field.
|
||||
//
|
||||
// st := store.Scope(store.DefaultStore, "agent", "task-mgr")
|
||||
// st.Write(&store.Record{Key: "history", Value: data}) // -> agent/task-mgr
|
||||
func Scope(s Store, database, table string) Store {
|
||||
return &scopedStore{Store: s, database: database, table: table}
|
||||
}
|
||||
|
||||
// scopedStore applies a fixed database/table to every data operation,
|
||||
// delegating everything else to the embedded Store.
|
||||
type scopedStore struct {
|
||||
Store
|
||||
database string
|
||||
table string
|
||||
}
|
||||
|
||||
func (s *scopedStore) Read(key string, opts ...ReadOption) ([]*Record, error) {
|
||||
return s.Store.Read(key, append([]ReadOption{ReadFrom(s.database, s.table)}, opts...)...)
|
||||
}
|
||||
|
||||
func (s *scopedStore) Write(r *Record, opts ...WriteOption) error {
|
||||
return s.Store.Write(r, append([]WriteOption{WriteTo(s.database, s.table)}, opts...)...)
|
||||
}
|
||||
|
||||
func (s *scopedStore) Delete(key string, opts ...DeleteOption) error {
|
||||
return s.Store.Delete(key, append([]DeleteOption{DeleteFrom(s.database, s.table)}, opts...)...)
|
||||
}
|
||||
|
||||
func (s *scopedStore) List(opts ...ListOption) ([]string, error) {
|
||||
return s.Store.List(append([]ListOption{ListFrom(s.database, s.table)}, opts...)...)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScopeIsolatesTables(t *testing.T) {
|
||||
base := NewMemoryStore()
|
||||
|
||||
a := Scope(base, "agent", "one")
|
||||
b := Scope(base, "agent", "two")
|
||||
|
||||
if err := a.Write(&Record{Key: "history", Value: []byte("A")}); err != nil {
|
||||
t.Fatalf("write a: %v", err)
|
||||
}
|
||||
if err := b.Write(&Record{Key: "history", Value: []byte("B")}); err != nil {
|
||||
t.Fatalf("write b: %v", err)
|
||||
}
|
||||
|
||||
// Same key, different scopes — the values must not collide.
|
||||
recs, err := a.Read("history")
|
||||
if err != nil || len(recs) != 1 || string(recs[0].Value) != "A" {
|
||||
t.Fatalf("scope a read = %v %v", recs, err)
|
||||
}
|
||||
recs, err = b.Read("history")
|
||||
if err != nil || len(recs) != 1 || string(recs[0].Value) != "B" {
|
||||
t.Fatalf("scope b read = %v %v", recs, err)
|
||||
}
|
||||
|
||||
// List is confined to the scope.
|
||||
keys, err := a.List()
|
||||
if err != nil || len(keys) != 1 {
|
||||
t.Fatalf("scope a list = %v %v, want 1 key", keys, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package store is an interface for distributed data storage.
|
||||
// The design document is located at https://github.com/micro/development/blob/master/design/store.md
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a key doesn't exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// DefaultStore is the file store (persists to ~/micro/store/).
|
||||
DefaultStore Store = NewStore()
|
||||
)
|
||||
|
||||
// Store is a data storage interface.
|
||||
type Store interface {
|
||||
// Init initializes the store. It must perform any required setup on the backing storage implementation and check that it is ready for use, returning any errors.
|
||||
Init(...Option) error
|
||||
// Options allows you to view the current options.
|
||||
Options() Options
|
||||
// Read takes a single key name and optional ReadOptions. It returns matching []*Record or an error.
|
||||
Read(key string, opts ...ReadOption) ([]*Record, error)
|
||||
// Write() writes a record to the store, and returns an error if the record was not written.
|
||||
Write(r *Record, opts ...WriteOption) error
|
||||
// Delete removes the record with the corresponding key from the store.
|
||||
Delete(key string, opts ...DeleteOption) error
|
||||
// List returns any keys that match, or an empty list with no error if none matched.
|
||||
List(opts ...ListOption) ([]string, error)
|
||||
// Close the store
|
||||
Close() error
|
||||
// String returns the name of the implementation.
|
||||
String() string
|
||||
}
|
||||
|
||||
// Record is an item stored or retrieved from a Store.
|
||||
type Record struct {
|
||||
// Any associated metadata for indexing
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
// The key to store the record
|
||||
Key string `json:"key"`
|
||||
// The value within the record
|
||||
Value []byte `json:"value"`
|
||||
// Time to expire a record: TODO: change to timestamp
|
||||
Expiry time.Duration `json:"expiry,omitempty"`
|
||||
}
|
||||
|
||||
func NewStore(opts ...Option) Store {
|
||||
return NewFileStore(opts...)
|
||||
}
|
||||
|
||||
func NewRecord(key string, val interface{}) *Record {
|
||||
b, _ := json.Marshal(val)
|
||||
return &Record{
|
||||
Key: key,
|
||||
Value: b,
|
||||
}
|
||||
}
|
||||
|
||||
// Encode will marshal any type into the byte Value field
|
||||
func (r *Record) Encode(v interface{}) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Value = b
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode is a convenience helper for decoding records
|
||||
func (r *Record) Decode(v interface{}) error {
|
||||
return json.Unmarshal(r.Value, v)
|
||||
}
|
||||
|
||||
// Read records
|
||||
func Read(key string, opts ...ReadOption) ([]*Record, error) {
|
||||
// execute the query
|
||||
return DefaultStore.Read(key, opts...)
|
||||
}
|
||||
|
||||
// Write a record to the store
|
||||
func Write(r *Record) error {
|
||||
return DefaultStore.Write(r)
|
||||
}
|
||||
|
||||
// Delete removes the record with the corresponding key from the store.
|
||||
func Delete(key string) error {
|
||||
return DefaultStore.Delete(key)
|
||||
}
|
||||
|
||||
// List returns any keys that match, or an empty list with no error if none matched.
|
||||
func List(opts ...ListOption) ([]string, error) {
|
||||
return DefaultStore.List(opts...)
|
||||
}
|
||||
Reference in New Issue
Block a user