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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+445
View File
@@ -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)
}
}
+250
View File
@@ -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
View File
@@ -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
}
+251
View File
@@ -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
}
+69
View File
@@ -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))
}
}
+79
View File
@@ -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()
```
+18
View File
@@ -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)
}
}
+184
View File
@@ -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...)
}
+119
View File
@@ -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
}
+479
View File
@@ -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]
}
+337
View File
@@ -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
}
+83
View File
@@ -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"
}
}
+138
View File
@@ -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"),
},
},
}
)
+39
View File
@@ -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)
}
+229
View File
@@ -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
}
}
+13
View File
@@ -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.
+61
View File
@@ -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
}
+14
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
package pgx
import "github.com/jackc/pgx/v5/pgxpool"
type DB struct {
conn *pgxpool.Pool
tables map[string]Queries
}
+44
View File
@@ -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
}
+425
View File
@@ -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
}
+139
View File
@@ -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])
})
}
+38
View File
@@ -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),
}
}
+35
View File
@@ -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"
)
+657
View File
@@ -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
}
+148
View File
@@ -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])
})
}
+41
View File
@@ -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...)...)
}
+33
View File
@@ -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)
}
}
+97
View File
@@ -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...)
}