13/08/2020 - GO, REDIS
In this example we are going to use Redis transactions by utilising Client.Watch feature. It is same logic as the Atomicity principle of database transactions - "either all occur, or nothing occurs". In our case, if one command fails all the others fail too.
We have a bank system where an account holder might have multiple accounts. When we create an account holder, we will try to create accounts as well. However, if something goes wrong along the line, we will not create anything at all. We are going to create 3 different hashes. One for the root element Holder
. The other two for the Account
elements.
{
"ID": "144c8dcb89dc293f55c68cc74adda88b",
"FirstName": "Al",
"MiddleName": "",
"LastName": "Pacino",
"Accounts": [
{
"Type": "Savings",
"Number": "20202020",
"SortCode": "20-20-20",
"Active": false
},
{
"Type": "Current",
"Number": "10101010",
"SortCode": "10-10-10",
"Active": true
}
]
}
├── docker-compose.yaml
└── internal
├── domain
│ ├── account
│ │ ├── account.go
│ │ ├── cache.go
│ │ └── holder.go
│ └── repository
│ └── account.go
└── storage
├── account.go
└── account_test.go
version: "3"
services:
redis:
image: redis:6.0.6-alpine
command: redis-server --requirepass pass
ports:
- 6379:6379
package account
import "fmt"
func CacheHashRootKey(holderID string) string {
return "app:holder:" + holderID
}
func CacheHashHolderField() string {
return "holder"
}
func CacheHashAccountField(accountType Type) string {
return fmt.Sprintf("account:%s", accountType)
}
package account
import "encoding/json"
type Holder struct {
ID string
FirstName string
MiddleName string
LastName string
Accounts []*Account
}
func (h *Holder) MarshalBinary() ([]byte, error) {
return json.Marshal(h)
}
func (h *Holder) UnmarshalBinary(data []byte) error {
if err := json.Unmarshal(data, &h); err != nil {
return err
}
return nil
}
package account
import "encoding/json"
type Type string
const (
TypeCurrent Type = "Current"
TypeSavings Type = "Savings"
)
type Account struct {
Type Type
Number string
SortCode string
Active bool
}
func (a *Account) MarshalBinary() ([]byte, error) {
return json.Marshal(a)
}
func (a *Account) UnmarshalBinary(data []byte) error {
if err := json.Unmarshal(data, &a); err != nil {
return err
}
return nil
}
package repository
import (
"context"
"github.com/inanzzz/cache/internal/domain/account"
)
type Account interface {
// Create creates a new account holder with/without actual accounts.
Create(ctx context.Context, holder account.Holder) error
}
You can purposely remove MarshalBinary
and UnmarshalBinary
methods in the structs to make transactions fail in order to reproduce rollbacks.
package storage
import (
"context"
"fmt"
"github.com/inanzzz/cache/internal/domain/account"
"github.com/go-redis/redis/v8"
)
type Account struct {
rds *redis.Client
}
func NewAccount(rds *redis.Client) Account {
return Account{rds: rds}
}
func (a Account) Create(ctx context.Context, holder account.Holder) error {
accounts := holder.Accounts
// We do not want the inner structures within the parent because we will store them separately.
holder.Accounts = nil
err := a.rds.Watch(ctx, func(tx *redis.Tx) error {
// You can run more commands here. You will use `tx` though.
// e.g. tx.HGET()
// Note: `tx` is not part of transactional `pipe` below so any "SET" operation will be independent.
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
// 1- Set holder
if _, err := pipe.HSetNX(
ctx,
account.CacheHashRootKey(holder.ID),
account.CacheHashHolderField(),
&holder,
).Result(); err != nil {
return fmt.Errorf("create: holder: %w", err)
}
//tx.Expire()
//
// 2- Set accounts within holder
for _, acc := range accounts {
if _, err := pipe.HSetNX(
ctx,
account.CacheHashRootKey(holder.ID),
account.CacheHashAccountField(acc.Type),
acc,
).Result(); err != nil {
return fmt.Errorf("create: account: %w", err)
}
//pipe.Expire()
}
//
return nil
})
return err
})
if err != nil {
return fmt.Errorf("create: transaction: %w", err)
}
return nil
}
package storage
import (
"context"
"testing"
"github.com/inanzzz/cache/internal/domain/account"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
)
func TestAccount_Create(t *testing.T) {
rds := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "pass",
})
storage := NewAccount(rds)
holder := account.Holder{
ID: "144c8dcb89dc293f55c68cc74adda88b",
FirstName: "Al",
MiddleName: "",
LastName: "Pacino",
Accounts: []*account.Account{
{
Type: account.TypeCurrent,
Number: "10101010",
SortCode: "10-10-10",
Active: true,
},
{
Type: account.TypeSavings,
Number: "20202020",
SortCode: "20-20-20",
Active: false,
},
},
}
assert.NoError(t, storage.Create(context.Background(), holder))
}
localhost:6379> KEYS *
1) "app:holder:144c8dcb89dc293f55c68cc74adda88b"
localhost:6379> HGETALL app:holder:144c8dcb89dc293f55c68cc74adda88b
1) "account:Savings"
2) "{\"Type\":\"Savings\",\"Number\":\"20202020\",\"SortCode\":\"20-20-20\",\"Active\":false}"
3) "holder"
4) "{\"ID\":\"144c8dcb89dc293f55c68cc74adda88b\",\"FirstName\":\"Al\",\"MiddleName\":\"\",\"LastName\":\"Pacino\",\"Accounts\":null}"
5) "account:Current"
6) "{\"Type\":\"Current\",\"Number\":\"10101010\",\"SortCode\":\"10-10-10\",\"Active\":true}"