// Store implements store.Interface backed by bbolt.
//
// In essence, bbolt is a hierarchical key/value store with a twist: every value
// needs to belong to a bucket. Each value in the store is given its own bucket
// with two keys:
//
// 1. data - The raw data, usually in JSON
// 2. expiry - The expiry time formatted as a time.RFC3339Nano timestamp string
type Store struct {
bdb *bbolt.DB
}
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
var result []byte
if err := s.bdb.View(func(tx *bbolt.Tx) error {
itemBucket := tx.Bucket([]byte(key))
if itemBucket == nil {
return fmt.Errorf("%w: %q", store.ErrNotFound, key)
}
expiryStr := itemBucket.Get([]byte("expiry"))
if expiryStr == nil {
return fmt.Errorf("[unexpected] %w: %q (expiry is nil)", store.ErrNotFound, key)
}
expiry, err := time.Parse(time.RFC3339Nano, string(expiryStr))
if err != nil {
return fmt.Errorf("[unexpected] %w: %w", store.ErrCantDecode, err)
}
if time.Now().After(expiry) {
go s.Delete(context.Background(), key)
return fmt.Errorf("%w: %q", store.ErrNotFound, key)
}
dataStr := itemBucket.Get([]byte("data"))
if dataStr == nil {
return fmt.Errorf("[unexpected] %w: %q (data is nil)", store.ErrNotFound, key)
}
result = make([]byte, len(dataStr))
copy(result, dataStr)
return nil
); err != nil {
return nil, err
}
return result, nil
}
func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
expires := time.Now().Add(expiry)
return s.bdb.Update(func(tx *bbolt.Tx) error {
valueBkt, err := tx.CreateBucketIfNotExists([]byte(key))
if err != nil {
return fmt.Errorf("%w: %w: %q (create bucket)", store.ErrCantEncode, err, key)
}
if err := valueBkt.Put([]byte("expiry"), []byte(expires.Format(time.RFC3339Nano))); err != nil {
return fmt.Errorf("%w: %q (expiry)", store.ErrCantEncode, key)
}
if err := valueBkt.Put([]byte("data"), value); err != nil {
return fmt.Errorf("%w: %q (data)", store.ErrCantEncode, key)
}
return nil
)
}