├── .gitignore ├── README.md ├── cmd ├── consumer │ ├── handler │ │ ├── factory.go │ │ ├── handler.go │ │ ├── item_add.go │ │ ├── log.go │ │ ├── order.go │ │ └── topup.go │ ├── main.go │ └── state │ │ └── state.go └── producer │ └── main.go ├── dump.rdb ├── go.mod ├── go.sum ├── pkg ├── event │ ├── base.go │ ├── event.go │ ├── factory.go │ ├── item_add.go │ ├── order.go │ └── topup.go ├── snapshot │ ├── snapshot.go │ └── snapshot_test.go ├── user │ └── user.go └── warehouse │ └── warehouse.go └── util.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Sourcing with Go and Redis 2 | NOTE: This code is not tested, just an experiment 3 | 4 | I thought you already heard about Event Sourcing in the past recent year. 5 | But let's go through the definition again. 6 | 7 | > Capture all changes to an application state as a sequence of events. 8 | > Event Sourcing ensures that all changes to application state are stored as a sequence of events. - [Martin Fowler](https://martinfowler.com/eaaDev/EventSourcing.html) 9 | 10 | If you know bitcoin/blockchain you will know it's quite similar with Event Sourcing. 11 | 12 | > Your current balance (Application State) is calculated from a series of events in history (in the chain) 13 | ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/ztik9xqelulsh4lx3kl9.png) 14 | 15 | so you don't have a table like this in database 16 | 17 | |user_id|balance| 18 | |----|----| 19 | | 10 | 100$| 20 | | 7 | 200$| 21 | 22 | now you have 23 | 24 | |events| 25 | |------| 26 | |user x top-up event| 27 | |user buy 5 items event| 28 | |user y top-up event| 29 | 30 | I've read many articles/blog posts about Event Sourcing so I try to make once. 31 | 32 | ## What we will build? 33 | Let's say you have an e-commerce website and users can buy items from your website. 34 | Source: https://github.com/felixvo/lmax 35 | 36 | Entities: 37 | - `User` will have `balance`. 38 | - `Item` will have `price` and number of `remain` items in the warehouse. 39 | 40 | Events: 41 | - `Topup`: increase user balance 42 | - `AddItem`: add more item to warehouse 43 | - `Order`: buy items 44 | 45 | ## Directory Structure 46 | 47 | ``` 48 | ├── cmd 49 | │   ├── consumer # process events 50 | │   │   ├── handler # handle new event base on event Type 51 | │   │   └── state 52 | │   └── producer # publish events 53 | └── pkg 54 | ├── event # event definition 55 | ├── snapshot # snapshot state of the app 56 | ├── user # user domain 57 | └── warehouse # item domain 58 | ``` 59 | 60 | ## Architecture 61 | ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/ui1bv7ili5wag324ucil.png) 62 | 63 | - Event storage: [Redis Stream](https://redis.io/topics/streams-intro) 64 | >Entry IDs 65 | >The entry ID returned by the XADD command, and identifying univocally >each entry inside a given stream, is composed of two parts: 66 | >`-` 67 | > I use this `Entry ID` to keep track of processed event 68 | 69 | - The consumer will consume events and build the application state 70 | - `snapshot` package will take the application state and save to redis every 30s. Application state will restore from this if our app crash 71 | 72 | ## Run 73 | 74 | ### Producer 75 | 76 | First, start the producer to insert some events to `redis stream` 77 | ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/v0scin2iq1cdcnu9nkaw.png) 78 | 79 | ### Consumer 80 | 81 | Now start the consumer to consume events 82 | 83 | ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/eueho865f5couulhbnal.png) 84 | Because the consumer consumes the events but not backup the state yet. 85 | If you wait for more than 30s, you will see this message from console 86 | 87 | ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/0qkcmgzgwlhctk51q3bj.png) 88 | 89 | Now if you stop the app and start it again, the application state will restore from the latest snapshot, not reprocess the event again 90 | 91 | ![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/vxxwbn2hfho3qj1hgi3b.png) 92 | 93 | Thank you for reading! 94 | I hope the source code is clean enough for you to understand :scream: 95 | 96 | ## Thoughts 97 | 98 | 99 | -------------------------------------------------------------------------------- /cmd/consumer/handler/factory.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/felixvo/lmax/cmd/consumer/state" 5 | "github.com/felixvo/lmax/pkg/event" 6 | ) 7 | 8 | func HandlerFactory(st *state.State) func(t event.Type) Handler { 9 | 10 | return func(t event.Type) Handler { 11 | switch t { 12 | case event.OrderType: 13 | return NewOrderHandler(st) 14 | case event.TopUpType: 15 | return NewTopupHandler(st) 16 | case event.AddItemType: 17 | return NewAddItemHandler(st) 18 | } 19 | return NewLogHandler(st) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cmd/consumer/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/felixvo/lmax/pkg/event" 5 | ) 6 | 7 | type Handler interface { 8 | Handle(e event.Event) error 9 | } 10 | -------------------------------------------------------------------------------- /cmd/consumer/handler/item_add.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "github.com/felixvo/lmax/cmd/consumer/state" 6 | "github.com/felixvo/lmax/pkg/event" 7 | "github.com/felixvo/lmax/pkg/warehouse" 8 | "math/rand" 9 | ) 10 | 11 | type itemAddHandler struct { 12 | state *state.State 13 | } 14 | 15 | func NewAddItemHandler(state *state.State) Handler { 16 | return &itemAddHandler{ 17 | state: state, 18 | } 19 | } 20 | 21 | func (h *itemAddHandler) Handle(e event.Event) error { 22 | addItem, ok := e.(*event.AddItem) 23 | defer func() { 24 | h.state.LatestEventID = addItem.GetID() 25 | }() 26 | if !ok { 27 | return fmt.Errorf("incorrect event type") 28 | } 29 | 30 | i, exist := h.state.Items[addItem.ItemID] 31 | if !exist { 32 | i = &warehouse.Item{ 33 | ID: addItem.ItemID, 34 | Price: uint(rand.Intn(100)), 35 | Remain: uint(rand.Intn(200)), 36 | } 37 | h.state.Items[addItem.ItemID] = i 38 | } 39 | i.Remain += addItem.Count 40 | 41 | fmt.Printf("completed add item %+v \n", addItem) 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /cmd/consumer/handler/log.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "github.com/felixvo/lmax/cmd/consumer/state" 6 | "github.com/felixvo/lmax/pkg/event" 7 | ) 8 | 9 | type logHandler struct { 10 | state *state.State 11 | } 12 | 13 | func NewLogHandler( 14 | state *state.State, 15 | ) Handler { 16 | return &logHandler{state: state} 17 | } 18 | 19 | func (h *logHandler) Handle(e event.Event) error { 20 | defer func() { 21 | h.state.LatestEventID = e.GetID() 22 | }() 23 | fmt.Printf("new event:%+v\n", e) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /cmd/consumer/handler/order.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "github.com/felixvo/lmax/cmd/consumer/state" 6 | "github.com/felixvo/lmax/pkg/event" 7 | "github.com/felixvo/lmax/pkg/warehouse" 8 | ) 9 | 10 | type orderHandler struct { 11 | state *state.State 12 | } 13 | 14 | func NewOrderHandler(state *state.State) Handler { 15 | return &orderHandler{ 16 | state: state, 17 | } 18 | } 19 | 20 | func (h *orderHandler) Handle(e event.Event) error { 21 | defer func() { 22 | h.state.LatestEventID = e.GetID() 23 | }() 24 | orderEvent, ok := e.(*event.OrderEvent) 25 | if !ok { 26 | return fmt.Errorf("invalid event type") 27 | } 28 | if len(orderEvent.ItemIDs) <= 0 { 29 | return fmt.Errorf("invalid items") 30 | } 31 | u := h.state.GetUserByID(orderEvent.UserID) 32 | items := h.state.GetItems(orderEvent.ItemIDs) 33 | 34 | total, err := total(items, orderEvent.ItemQuantities) 35 | if err != nil { 36 | return err 37 | } 38 | if total > u.Balance { 39 | return fmt.Errorf("not enough cash") 40 | } 41 | // handle 42 | u.Balance = u.Balance - total 43 | updateItemQuantities(items, orderEvent.ItemQuantities) 44 | fmt.Printf("completed order %+v \n", orderEvent) 45 | return nil 46 | } 47 | func total(items []*warehouse.Item, quantities []uint) (uint, error) { 48 | var total uint 49 | for i, item := range items { 50 | total += item.Price 51 | if item.Remain < quantities[i] { 52 | return 0, fmt.Errorf("%s is out of stock", item.ID) 53 | } 54 | } 55 | return total, nil 56 | } 57 | func updateItemQuantities(items []*warehouse.Item, quantities []uint) { 58 | for i, item := range items { 59 | item.Remain = item.Remain - quantities[i] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cmd/consumer/handler/topup.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "github.com/felixvo/lmax/cmd/consumer/state" 6 | "github.com/felixvo/lmax/pkg/event" 7 | "github.com/felixvo/lmax/pkg/user" 8 | ) 9 | 10 | type topupHandler struct { 11 | state *state.State 12 | } 13 | 14 | func NewTopupHandler(state *state.State) Handler { 15 | return &topupHandler{ 16 | state: state, 17 | } 18 | } 19 | 20 | func (h *topupHandler) Handle(e event.Event) error { 21 | topup, ok := e.(*event.TopUp) 22 | defer func() { 23 | h.state.LatestEventID = topup.GetID() 24 | }() 25 | if !ok { 26 | return fmt.Errorf("incorrect event type") 27 | } 28 | 29 | u, exist := h.state.Users[topup.UserID] 30 | if !exist { // should have an event to create user before use 31 | u = &user.User{ 32 | UseID: topup.UserID, 33 | Balance: 0, 34 | } 35 | h.state.Users[topup.UserID] = u 36 | } 37 | 38 | u.Balance += topup.Amount 39 | 40 | fmt.Printf("completed topup %+v \n", topup) 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/consumer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/felixvo/lmax/cmd/consumer/handler" 10 | "github.com/felixvo/lmax/cmd/consumer/state" 11 | "github.com/felixvo/lmax/pkg/event" 12 | "github.com/felixvo/lmax/pkg/snapshot" 13 | "github.com/felixvo/lmax/pkg/user" 14 | "github.com/felixvo/lmax/pkg/warehouse" 15 | "github.com/go-redis/redis/v7" 16 | ) 17 | 18 | const ( 19 | OrderStream = "orders" 20 | ) 21 | 22 | func main() { 23 | client, err := newRedisClient() 24 | if err != nil { 25 | panic(err) 26 | } 27 | snapshotSrv := snapshot.NewRedisSnapshot(client) 28 | st := initialState(snapshotSrv) 29 | // 30 | go exeSnapshot(st, snapshotSrv) 31 | 32 | // start fetch events 33 | events := eventFetcher(client, st) 34 | 35 | // start consume events 36 | consumeEvents(events, handler.HandlerFactory(st)) 37 | 38 | quit := make(chan bool) 39 | <-quit 40 | } 41 | func exeSnapshot(st *state.State, snapshotSrv snapshot.Snapshot) { 42 | ticker := time.Tick(time.Second * 30) 43 | for { 44 | select { 45 | case <-ticker: 46 | err := snapshotSrv.Snapshot(st) 47 | if err != nil { 48 | fmt.Println("snapshot failed:", err) 49 | break 50 | } 51 | fmt.Println("snapshot success:", st.LatestEventID, " at ", time.Now()) 52 | } 53 | 54 | } 55 | } 56 | 57 | // start fetch new event starting from st.LatestEventID 58 | func eventFetcher(client *redis.Client, st *state.State) chan event.Event { 59 | c := make(chan event.Event, 100) 60 | start := "-" 61 | if len(st.LatestEventID) > 0 { 62 | splitted := strings.Split(st.LatestEventID, "-") 63 | counter, _ := strconv.Atoi(splitted[1]) 64 | start = fmt.Sprintf("%s-%v", splitted[0], counter+1) 65 | } 66 | go func() { 67 | for { 68 | func() { 69 | defer func() { // increase start by once after processed all the new messages 70 | splitted := strings.Split(start, "-") 71 | counter, _ := strconv.Atoi(splitted[1]) 72 | start = fmt.Sprintf("%s-%v", splitted[0], counter+1) 73 | }() 74 | rr, err := client.XRange(OrderStream, start, "+").Result() 75 | if err != nil { 76 | panic(err) 77 | } 78 | 79 | for _, r := range rr { 80 | start = r.ID 81 | t := r.Values["type"].(string) 82 | e, err := event.New(event.Type(t)) 83 | if err != nil { 84 | panic(err) 85 | } 86 | err = e.UnmarshalBinary([]byte(r.Values["data"].(string))) 87 | if err != nil { 88 | client.XDel("orders", r.ID) 89 | st.LatestEventID = r.ID 90 | fmt.Printf("fail to unmarshal event:%v\n", r.ID) 91 | return 92 | } 93 | e.SetID(r.ID) 94 | c <- e 95 | } 96 | }() 97 | } 98 | }() 99 | return c 100 | } 101 | 102 | func consumeEvents(events chan event.Event, handlerFactory func(t event.Type) handler.Handler) { 103 | for { 104 | select { 105 | case e := <-events: 106 | h := handlerFactory(e.GetType()) 107 | err := h.Handle(e) 108 | if err != nil { 109 | fmt.Printf("handle event error eventType:%v err:%v\n", e.GetType(), err) 110 | } 111 | } 112 | } 113 | } 114 | 115 | func initialState(snapshotSrv snapshot.Snapshot) *state.State { 116 | st := state.State{ 117 | Users: map[int64]*user.User{}, 118 | Items: map[string]*warehouse.Item{}, 119 | } 120 | err := snapshotSrv.Restore(&st) 121 | if err != nil { // inital state for demo purpose 122 | fmt.Println(err) 123 | return &st 124 | } 125 | fmt.Println("state restored ") 126 | for _, u := range st.Users { 127 | fmt.Printf("userID:%v balance:%v \n", u.UseID, u.Balance) 128 | } 129 | for _, item := range st.Items { 130 | fmt.Printf("itemID:%v remain:%v price:%v \n", item.ID, item.Remain, item.Price) 131 | } 132 | return &st 133 | } 134 | 135 | func newRedisClient() (*redis.Client, error) { 136 | client := redis.NewClient(&redis.Options{ 137 | //Addr: "redis-14450.c1.asia-northeast1-1.gce.cloud.redislabs.com:14450", 138 | //Password: "37uaACndCvuQ1heADnHkishnAhMmosWq", // no password set 139 | Addr: "localhost:6379", 140 | Password: "", 141 | DB: 0, // use default DB 142 | }) 143 | 144 | _, err := client.Ping().Result() 145 | return client, err 146 | 147 | } 148 | -------------------------------------------------------------------------------- /cmd/consumer/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "github.com/felixvo/lmax/pkg/user" 5 | "github.com/felixvo/lmax/pkg/warehouse" 6 | ) 7 | 8 | type State struct { 9 | LatestEventID string 10 | Users map[int64]*user.User 11 | Items map[string]*warehouse.Item 12 | } 13 | 14 | func (s *State) SetLatestEventID(id string) { 15 | s.LatestEventID = id 16 | } 17 | func (s *State) GetLatestEventID() string { 18 | return s.LatestEventID 19 | } 20 | 21 | // for inital state, state should calculated from events 22 | func (s *State) SetUsers(users map[int64]*user.User) { 23 | s.Users = users 24 | } 25 | 26 | // for inital state, state should calculated from events 27 | func (s *State) SetItems(items map[string]*warehouse.Item) { 28 | s.Items = items 29 | } 30 | 31 | func (s *State) GetUserByID(id int64) *user.User { 32 | u, exist := s.Users[id] 33 | if !exist { 34 | return &user.User{} 35 | } 36 | return u 37 | } 38 | 39 | func (s *State) GetItem(id string) *warehouse.Item { 40 | return s.Items[id] 41 | } 42 | 43 | func (s *State) GetItems(ids []string) []*warehouse.Item { 44 | rs := make([]*warehouse.Item, len(ids)) 45 | for i, id := range ids { 46 | if item := s.GetItem(id); item != nil { 47 | rs[i] = s.GetItem(id) 48 | } else { // item not exist => should show error, this just for demo 49 | rs[i] = &warehouse.Item{ 50 | ID: id, 51 | Remain: 0, 52 | } 53 | } 54 | } 55 | return rs 56 | } 57 | -------------------------------------------------------------------------------- /cmd/producer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | 7 | "github.com/felixvo/lmax/pkg/event" 8 | "github.com/go-redis/redis/v7" 9 | ) 10 | 11 | const ( 12 | MaxUserIDRange = 10000 13 | ) 14 | 15 | func main() { 16 | client, err := newRedisClient() 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | Topup(client) 22 | AddItem(client) 23 | MakeOrders(client) 24 | } 25 | func Topup(client *redis.Client) { 26 | for i := 0; i < 10; i++ { 27 | userID := int64(rand.Intn(MaxUserIDRange)) 28 | strCMD := client.XAdd(&redis.XAddArgs{ 29 | Stream: "orders", 30 | Values: map[string]interface{}{ 31 | "type": string(event.TopUpType), 32 | "data": &event.TopUp{ 33 | Base: &event.Base{ 34 | Type: event.TopUpType, 35 | }, 36 | UserID: userID, 37 | Amount: 500, 38 | }, 39 | }, 40 | }) 41 | newID, err := strCMD.Result() 42 | if err != nil { 43 | fmt.Printf("topup error:%v\n", err) 44 | } else { 45 | fmt.Printf("topup success for user:%v offset:%v\n", userID, newID) 46 | } 47 | } 48 | } 49 | func AddItem(client *redis.Client) { 50 | for i := 0; i < 10; i++ { 51 | itemID := []string{"cpu", "ram", "hdd", "ssd"}[rand.Intn(4)] 52 | count := uint(rand.Intn(50)) 53 | strCMD := client.XAdd(&redis.XAddArgs{ 54 | Stream: "orders", 55 | Values: map[string]interface{}{ 56 | "type": string(event.AddItemType), 57 | "data": &event.AddItem{ 58 | Base: &event.Base{ 59 | Type: event.AddItemType, 60 | }, 61 | ItemID: itemID, 62 | Count: count, 63 | }, 64 | }, 65 | }) 66 | newID, err := strCMD.Result() 67 | if err != nil { 68 | fmt.Printf("add item error:%v\n", err) 69 | } else { 70 | fmt.Printf("add item success itemID:%v count:%v offset:%v\n", itemID, count, newID) 71 | } 72 | } 73 | } 74 | 75 | func MakeOrders(client *redis.Client) { 76 | for i := 0; i < 10; i++ { 77 | itemID := []string{"cpu", "ram", "hdd", "ssd"}[rand.Intn(4)] 78 | count := uint(rand.Intn(50)) 79 | userID := int64(rand.Intn(MaxUserIDRange)) 80 | strCMD := client.XAdd(&redis.XAddArgs{ 81 | Stream: "orders", 82 | Values: map[string]interface{}{ 83 | "type": string(event.OrderType), 84 | "data": &event.OrderEvent{ 85 | Base: &event.Base{ 86 | Type: event.OrderType, 87 | }, 88 | UserID: userID, 89 | ItemIDs: []string{itemID}, 90 | ItemQuantities: []uint{count}, 91 | }, 92 | }, 93 | }) 94 | newID, err := strCMD.Result() 95 | if err != nil { 96 | fmt.Printf("add item error:%v\n", err) 97 | } else { 98 | fmt.Printf("make order success userID:%v itemID:%v count:%v offset:%v\n", userID, itemID, count, newID) 99 | } 100 | //time.Sleep(time.Second * 2) 101 | } 102 | } 103 | func newRedisClient() (*redis.Client, error) { 104 | client := redis.NewClient(&redis.Options{ 105 | //Addr: "redis-14450.c1.asia-northeast1-1.gce.cloud.redislabs.com:14450", 106 | //Password: "37uaACndCvuQ1heADnHkishnAhMmosWq", // no password set 107 | Addr: "localhost:6379", 108 | Password: "", 109 | DB: 0, // use default DB 110 | }) 111 | 112 | _, err := client.Ping().Result() 113 | return client, err 114 | 115 | } 116 | -------------------------------------------------------------------------------- /dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixvo/event-sourcing-test-app/11b6e3cfa362731f7113eaf1ae0955c3ca0ff361/dump.rdb -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/felixvo/lmax 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/go-redis/redis/v7 v7.0.0-beta.4 7 | github.com/stretchr/testify v1.4.0 8 | github.com/vmihailenco/msgpack/v4 v4.2.1 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 4 | github.com/go-redis/redis v6.15.6+incompatible h1:H9evprGPLI8+ci7fxQx6WNZHJSb7be8FqJQRhdQZ5Sg= 5 | github.com/go-redis/redis/v7 v7.0.0-beta.4 h1:p6z7Pde69EGRWvlC++y8aFcaWegyrKHzOBGo0zUACTQ= 6 | github.com/go-redis/redis/v7 v7.0.0-beta.4/go.mod h1:xhhSbUMTsleRPur+Vgx9sUHtyN33bdjxY+9/0n9Ig8s= 7 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 8 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 9 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 10 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 11 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 12 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 13 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 15 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 16 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 17 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/smartystreets-prototypes/go-disruptor v0.0.0-20191016010027-c1aa45f7f564 h1:Ur3f1wBqtP7ZKC2CKAymmxviztdCnHej5Cu8QctOxbo= 21 | github.com/smartystreets-prototypes/go-disruptor v0.0.0-20191016010027-c1aa45f7f564/go.mod h1:slFCjqF2v0VgmCeB+J4uEy0d7HAgLkgEjVrG0DPO67M= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 24 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 25 | github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= 26 | github.com/vmihailenco/msgpack/v4 v4.2.1 h1:9GCtYfnRH3FhtqfQTc1wYXmIB2UJd/K890yNae8kpOw= 27 | github.com/vmihailenco/msgpack/v4 v4.2.1/go.mod h1:Mu3B7ZwLd5nNOLVOKt9DecVl7IVg0xkDiEjk6CwMrww= 28 | github.com/vmihailenco/tagparser v0.1.0 h1:u6yzKTY6gW/KxL/K2NTEQUOSXZipyGiIRarGjJKmQzU= 29 | github.com/vmihailenco/tagparser v0.1.0/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= 30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 31 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 32 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 33 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 34 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 35 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 36 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= 37 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 38 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 45 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 46 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 47 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 48 | google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= 49 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 51 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 53 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 54 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 55 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 56 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 57 | -------------------------------------------------------------------------------- /pkg/event/base.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "fmt" 4 | 5 | type Base struct { 6 | ID string 7 | Type Type 8 | } 9 | 10 | func (o *Base) GetID() string { 11 | return o.ID 12 | } 13 | 14 | func (o *Base) SetID(id string) { 15 | o.ID = id 16 | } 17 | 18 | func (o *Base) GetType() Type { 19 | return o.Type 20 | } 21 | func (o *Base) String() string { 22 | 23 | return fmt.Sprintf("id:%s type:%s", o.ID, o.Type) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "encoding" 4 | 5 | type Type string 6 | 7 | const ( 8 | OrderType Type = "OrderType" 9 | TopUpType Type = "TopUp" 10 | AddItemType Type = "AddItem" 11 | ) 12 | 13 | // Event -- 14 | type Event interface { 15 | GetID() string 16 | GetType() Type 17 | SetID(id string) 18 | encoding.BinaryMarshaler 19 | encoding.BinaryUnmarshaler 20 | } 21 | -------------------------------------------------------------------------------- /pkg/event/factory.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "fmt" 4 | 5 | func New(t Type) (Event, error) { 6 | b := &Base{ 7 | Type: t, 8 | } 9 | switch t { 10 | case OrderType: 11 | return &OrderEvent{ 12 | Base: b, 13 | }, nil 14 | case TopUpType: 15 | return &TopUp{ 16 | Base: b, 17 | }, nil 18 | case AddItemType: 19 | return &AddItem{ 20 | Base: b, 21 | }, nil 22 | } 23 | 24 | return nil, fmt.Errorf("type %v not supported", t) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/event/item_add.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "github.com/vmihailenco/msgpack/v4" 4 | 5 | type AddItem struct { 6 | *Base 7 | ItemID string 8 | Count uint 9 | } 10 | 11 | func (o *AddItem) MarshalBinary() (data []byte, err error) { 12 | return msgpack.Marshal(o) 13 | } 14 | 15 | func (o *AddItem) UnmarshalBinary(data []byte) error { 16 | return msgpack.Unmarshal(data, o) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/event/order.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "github.com/vmihailenco/msgpack/v4" 4 | 5 | type OrderEvent struct { 6 | *Base 7 | UserID int64 8 | ItemIDs []string 9 | ItemQuantities []uint 10 | } 11 | 12 | func (o *OrderEvent) MarshalBinary() (data []byte, err error) { 13 | return msgpack.Marshal(o) 14 | } 15 | 16 | func (o *OrderEvent) UnmarshalBinary(data []byte) error { 17 | return msgpack.Unmarshal(data, o) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/event/topup.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "github.com/vmihailenco/msgpack/v4" 4 | 5 | type TopUp struct { 6 | *Base 7 | UserID int64 8 | Amount uint 9 | } 10 | 11 | func (o *TopUp) MarshalBinary() (data []byte, err error) { 12 | return msgpack.Marshal(o) 13 | } 14 | 15 | func (o *TopUp) UnmarshalBinary(data []byte) error { 16 | return msgpack.Unmarshal(data, o) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/snapshot/snapshot.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "github.com/go-redis/redis/v7" 7 | "time" 8 | ) 9 | 10 | type Snapshot interface { 11 | Snapshot(t interface{}) error 12 | Restore(t interface{}) error 13 | } 14 | 15 | const ( 16 | snapshotKey = "snapshot" 17 | ) 18 | 19 | func NewRedisSnapshot( 20 | client *redis.Client, 21 | ) Snapshot { 22 | return &redisSnapshot{client: client} 23 | } 24 | 25 | type redisSnapshot struct { 26 | client *redis.Client 27 | } 28 | 29 | func (r *redisSnapshot) Snapshot(t interface{}) error { 30 | buf := bytes.Buffer{} 31 | ecd := gob.NewEncoder(&buf) 32 | err := ecd.Encode(t) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | _, err = r.client.Set(snapshotKey, buf.Bytes(), time.Hour*1).Result() 38 | return err 39 | } 40 | 41 | func (r *redisSnapshot) Restore(t interface{}) error { 42 | snap, err := r.client.Get(snapshotKey).Result() 43 | if err != nil { 44 | return err 45 | } 46 | dcd := gob.NewDecoder(bytes.NewBuffer([]byte(snap))) 47 | return dcd.Decode(t) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/snapshot/snapshot_test.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "fmt" 5 | "github.com/felixvo/lmax/cmd/consumer/state" 6 | "github.com/felixvo/lmax/pkg/user" 7 | "github.com/felixvo/lmax/pkg/warehouse" 8 | "github.com/go-redis/redis/v7" 9 | "github.com/stretchr/testify/assert" 10 | "testing" 11 | ) 12 | 13 | func TestRedisSnapshot(t *testing.T) { 14 | 15 | client := redis.NewClient(&redis.Options{ 16 | Addr: "localhost:6379", 17 | Password: "", // no password set 18 | DB: 0, // use default DB 19 | }) 20 | snapshotSrv := NewRedisSnapshot(client) 21 | st := &state.State{} 22 | st.SetUsers(map[int64]*user.User{ 23 | 1: { 24 | UseID: 1, 25 | Balance: 1, 26 | }, 27 | 2: { 28 | UseID: 2, 29 | Balance: 2, 30 | }, 31 | 3: { 32 | UseID: 3, 33 | Balance: 3, 34 | }, 35 | }) 36 | st.SetItems(map[string]*warehouse.Item{ 37 | "cpu": { 38 | ID: "cpu", 39 | Price: 100, 40 | Remain: 100, 41 | }, 42 | "ram": { 43 | ID: "ram", 44 | Price: 50, 45 | Remain: 150, 46 | }, 47 | }) 48 | err := Snapshot(*st) 49 | assert.NoError(t, err) 50 | restore := state.State{} 51 | err = Restore(&restore) 52 | assert.NoError(t, err) 53 | fmt.Println(restore.LatestEventID) 54 | for _, u := range restore.Users { 55 | fmt.Println(u) 56 | } 57 | for _, u := range restore.Items { 58 | fmt.Println(u) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | type User struct { 4 | UseID int64 5 | Balance uint 6 | } 7 | -------------------------------------------------------------------------------- /pkg/warehouse/warehouse.go: -------------------------------------------------------------------------------- 1 | package warehouse 2 | 3 | type Item struct { 4 | ID string 5 | Price uint 6 | Remain uint 7 | } 8 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | func PrintMemUsage() { 9 | var m runtime.MemStats 10 | runtime.ReadMemStats(&m) 11 | // For info on each, see: https://golang.org/pkg/runtime/#MemStats 12 | fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc)) 13 | fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc)) 14 | fmt.Printf("\tSys = %v MiB", bToMb(m.Sys)) 15 | fmt.Printf("\tNumGC = %v\n", m.NumGC) 16 | } 17 | 18 | func bToMb(b uint64) uint64 { 19 | return b / 1024 / 1024 20 | } 21 | --------------------------------------------------------------------------------