From 5a7e3762e2fccea902287742a35e75f9df3d019f Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Sun, 15 May 2016 17:43:38 -0700 Subject: [PATCH 01/10] Fix doc comment typo --- collection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collection.go b/collection.go index f84cf79..651e2a8 100644 --- a/collection.go +++ b/collection.go @@ -92,7 +92,7 @@ func (p *Pool) NewCollection(model Model) (*Collection, error) { return p.NewCollectionWithOptions(model, DefaultCollectionOptions) } -// NewCollection registers and returns a new collection of the given model type +// NewCollectionWithOptions registers and returns a new collection of the given model type // and with the provided options. func (p *Pool) NewCollectionWithOptions(model Model, options CollectionOptions) (*Collection, error) { typ := reflect.TypeOf(model) From 6e8cfe8e572e8aac6e95b630322f61865b2262e3 Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Sun, 15 May 2016 17:56:05 -0700 Subject: [PATCH 02/10] Implement and test Exists method --- collection.go | 30 +++++++++++++++++++++++++++--- collection_test.go | 31 ++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/collection.go b/collection.go index 651e2a8..2d548ce 100644 --- a/collection.go +++ b/collection.go @@ -92,8 +92,8 @@ func (p *Pool) NewCollection(model Model) (*Collection, error) { return p.NewCollectionWithOptions(model, DefaultCollectionOptions) } -// NewCollectionWithOptions registers and returns a new collection of the given model type -// and with the provided options. +// NewCollectionWithOptions registers and returns a new collection of the given +// model type and with the provided options. func (p *Pool) NewCollectionWithOptions(model Model, options CollectionOptions) (*Collection, error) { typ := reflect.TypeOf(model) // If options.Name is empty use the name of the concrete model type (without @@ -575,6 +575,30 @@ func (t *Transaction) FindAll(c *Collection, models interface{}) { t.Command("SORT", sortArgs, newScanModelsHandler(c.spec, fieldNames, models)) } +// Exists returns true if the collection has a model with the given id. It +// returns an error if there was a problem connecting to the database. +func (c *Collection) Exists(id string) (bool, error) { + t := c.pool.NewTransaction() + exists := false + t.Exists(c, id, &exists) + if err := t.Exec(); err != nil { + return false, err + } + return exists, nil +} + +// Exists sets the value of exists to true if a model exists in the given +// collection with the given id, and sets it to false otherwise. The first error +// encountered (if any) will be added to the transaction and returned when +// the transaction is executed. +func (t *Transaction) Exists(c *Collection, id string, exists *bool) { + if c == nil { + t.setError(newNilCollectionError("Exists")) + return + } + t.Command("EXISTS", redis.Args{c.ModelKey(id)}, NewScanBoolHandler(exists)) +} + // Count returns the number of models of the given type that exist in the database. // It returns an error if there was a problem connecting to the database. func (c *Collection) Count() (int, error) { @@ -582,7 +606,7 @@ func (c *Collection) Count() (int, error) { count := 0 t.Count(c, &count) if err := t.Exec(); err != nil { - return count, err + return 0, err } return count, nil } diff --git a/collection_test.go b/collection_test.go index 3334a8b..473e37b 100644 --- a/collection_test.go +++ b/collection_test.go @@ -318,6 +318,36 @@ func TestFindAll(t *testing.T) { } } +func TestExists(t *testing.T) { + testingSetUp() + defer testingTearDown() + + // Expect exists to be false if we haven't saved any models + exists, err := testModels.Exists("invalidId") + if err != nil { + t.Errorf("Unexpected error in testModels.Exists: %s", err.Error()) + } + if exists { + t.Errorf("Expected exists to be false, but got: %v", exists) + } + + // Create and save a test model + models, err := createAndSaveTestModels(1) + if err != nil { + t.Errorf("Unexpected error saving test models: %s", err.Error()) + } + model := models[0] + + // Expect exists to be true + exists, err = testModels.Exists(model.Id) + if err != nil { + t.Errorf("Unexpected error in testModels.Exists: %s", err.Error()) + } + if !exists { + t.Errorf("Expected exists to be true, but got: %v", exists) + } +} + func TestCount(t *testing.T) { testingSetUp() defer testingTearDown() @@ -346,7 +376,6 @@ func TestCount(t *testing.T) { if got != expected { t.Errorf("Expected Count to be %d but got %d", expected, got) } - } func TestDelete(t *testing.T) { From 1599c728b66ce2a26938f0c6b9216cc1ac7b56dd Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 19:31:40 -0700 Subject: [PATCH 03/10] Implement and test Transaction.Watch and Transaction.WatchKey --- errors.go | 8 ++++++++ transaction.go | 40 +++++++++++++++++++++++++++++++++------- transaction_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 transaction_test.go diff --git a/errors.go b/errors.go index b939d54..a876e03 100755 --- a/errors.go +++ b/errors.go @@ -32,3 +32,11 @@ func newModelNotFoundError(mr *modelRef) error { Msg: msg, } } + +type WatchError struct { + keys []string +} + +func (e WatchError) Error() string { + return fmt.Sprintf("Watch error: at least one of the following keys has changed: %v", e.keys) +} diff --git a/transaction.go b/transaction.go index f494c84..6ad8dbf 100644 --- a/transaction.go +++ b/transaction.go @@ -18,9 +18,10 @@ import ( // commands or lua scripts. Transactions feature delayed execution, // so nothing toches the database until you call Exec. type Transaction struct { - conn redis.Conn - actions []*Action - err error + conn redis.Conn + actions []*Action + err error + watching []string } // Action is a single step in a transaction and must be either a command @@ -57,6 +58,29 @@ func (t *Transaction) setError(err error) { } } +// Watch issues a Redis WATCH command using the key for the given model. If the +// model changes before the transaction is executed, Exec will return an error +// and the commands in the transaction will not be executed. +func (t *Transaction) Watch(model Model) error { + col, err := getCollectionForModel(model) + if err != nil { + return err + } + key := col.ModelKey(model.ModelId()) + return t.WatchKey(key) +} + +// WatchKey issues a Redis WATCH command using the given key. If the key changes +// before the transaction is executed, Exec will return an error and the +// commands in the transaction will not be executed. +func (t *Transaction) WatchKey(key string) error { + if _, err := t.conn.Do("WATCH", key); err != nil { + return err + } + t.watching = append(t.watching, key) + return nil +} + // Command adds a command action to the transaction with the given args. // handler will be called with the reply from this specific command when // the transaction is executed. @@ -116,8 +140,9 @@ func (t *Transaction) Exec() error { return t.err } - if len(t.actions) == 1 { - // If there is only one command, no need to use MULTI/EXEC + if len(t.actions) == 1 && len(t.watching) == 0 { + // If there is only one command and no keys being watched, no need to use + // MULTI/EXEC a := t.actions[0] reply, err := t.doAction(a) if err != nil { @@ -138,13 +163,14 @@ func (t *Transaction) Exec() error { return err } } - // Invoke redis driver to execute the transaction replies, err := redis.Values(t.conn.Do("EXEC")) if err != nil { + if err == redis.ErrNil && len(t.watching) > 0 { + return WatchError{keys: t.watching} + } return err } - // Iterate through the replies, calling the corresponding handler functions for i, reply := range replies { a := t.actions[i] diff --git a/transaction_test.go b/transaction_test.go new file mode 100644 index 0000000..bcd012e --- /dev/null +++ b/transaction_test.go @@ -0,0 +1,40 @@ +package zoom + +import ( + "github.com/garyburd/redigo/redis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "testing" +) + +func TestWatch(t *testing.T) { + testingSetUp() + defer testingTearDown() + model := &testModel{ + Int: 42, + String: "foo", + Bool: true, + } + require.NoError(t, testModels.Save(model)) + tx := testPool.NewTransaction() + // Issue a WATCH command + require.NoError(t, tx.Watch(model)) + // Update the model directly using a different connection. This should + // trigger WATCH + expectedString := "bar" + model.String = expectedString + require.NoError(t, testModels.Save(model)) + // Try to update the model using the transaction. We expect this to fail + // and return a WatchError + tx.Command("HSET", redis.Args{testModels.ModelKey(model.Id), "Int", 35}, nil) + err := tx.Exec() + assert.Error(t, err) + assert.IsType(t, WatchError{}, err) + // Finally retrieve the model to make sure the changes in the transaction + // were not committed. + other := testModel{} + require.NoError(t, testModels.Find(model.Id, &other)) + assert.Equal(t, expectedString, other.String, "First update *was not* committed") + assert.Equal(t, model.Int, other.Int, "Second update *was* committed") +} From f4c07920aaefdd1113b4ecf80c9071be27dd86e7 Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 21:27:00 -0700 Subject: [PATCH 04/10] Add a test for WatchKey --- transaction_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/transaction_test.go b/transaction_test.go index bcd012e..a12126c 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -38,3 +38,36 @@ func TestWatch(t *testing.T) { assert.Equal(t, expectedString, other.String, "First update *was not* committed") assert.Equal(t, model.Int, other.Int, "Second update *was* committed") } + +func TestWatchKey(t *testing.T) { + testingSetUp() + defer testingTearDown() + conn1 := testPool.NewConn() + defer conn1.Close() + key := "mykey" + _, err := conn1.Do("SET", key, "foo") + require.NoError(t, err) + tx := testPool.NewTransaction() + // Issue a WATCH command + require.NoError(t, tx.WatchKey(key)) + // Update the key directly using a different connection. This should + // trigger WATCH + expectedVal := "bar" + conn2 := testPool.NewConn() + defer conn2.Close() + _, err = conn2.Do("SET", key, expectedVal) + require.NoError(t, err) + // Try to update the key using the transaction. We expect this to fail + // and return a WatchError + tx.Command("SET", redis.Args{key, "should_not_be_set"}, nil) + err = tx.Exec() + assert.Error(t, err) + assert.IsType(t, WatchError{}, err) + // Finally get the key to make sure the changes in the transaction were not + // committed. + conn3 := testPool.NewConn() + defer conn3.Close() + got, err := redis.String(conn3.Do("GET", key)) + require.NoError(t, err) + require.Exactly(t, expectedVal, got) +} From 3b8fc9482048d57fc3a0e9d164f2349491d35f9f Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 21:35:07 -0700 Subject: [PATCH 05/10] Improve Watch and WatchKey docs and return error if transaction has actions --- transaction.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/transaction.go b/transaction.go index 6ad8dbf..43e3194 100644 --- a/transaction.go +++ b/transaction.go @@ -59,9 +59,16 @@ func (t *Transaction) setError(err error) { } // Watch issues a Redis WATCH command using the key for the given model. If the -// model changes before the transaction is executed, Exec will return an error -// and the commands in the transaction will not be executed. +// model changes before the transaction is executed, Exec will return a +// WatchError and the commands in the transaction will not be executed. Unlike +// most other transaction methods, Watch does not use delayed execution. Because +// of how the WATCH command works, Watch must send a command to Redis +// immediately. You must call Watch or WatchKey before any other transaction +// methods. func (t *Transaction) Watch(model Model) error { + if len(t.actions) != 0 { + return fmt.Errorf("Cannot call Watch after other commands have been added to the transaction") + } col, err := getCollectionForModel(model) if err != nil { return err @@ -71,9 +78,15 @@ func (t *Transaction) Watch(model Model) error { } // WatchKey issues a Redis WATCH command using the given key. If the key changes -// before the transaction is executed, Exec will return an error and the -// commands in the transaction will not be executed. +// before the transaction is executed, Exec will return a WatchError and the +// commands in the transaction will not be executed. Unlike most other +// transaction methods, WatchKey does not use delayed execution. Because of how +// the WATCH command works, WatchKey must send a command to Redis immediately. +// You must call Watch or WatchKey before any other transaction methods. func (t *Transaction) WatchKey(key string) error { + if len(t.actions) != 0 { + return fmt.Errorf("Cannot call WatchKey after other commands have been added to the transaction") + } if _, err := t.conn.Do("WATCH", key); err != nil { return err } From 4b4efeca8140a87c501e00fd405dd1b6e86eb7a4 Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 21:38:42 -0700 Subject: [PATCH 06/10] Fix small typos --- transaction.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/transaction.go b/transaction.go index 43e3194..08ebb05 100644 --- a/transaction.go +++ b/transaction.go @@ -13,10 +13,10 @@ import ( "github.com/garyburd/redigo/redis" ) -// Transaction is an abstraction layer around a redis transaction. -// Transactions consist of a set of actions which are either redis +// Transaction is an abstraction layer around a Redis transaction. +// Transactions consist of a set of actions which are either Redis // commands or lua scripts. Transactions feature delayed execution, -// so nothing toches the database until you call Exec. +// so nothing touches the database until you call Exec. type Transaction struct { conn redis.Conn actions []*Action From 4eb90250f77cbdd46b01b360d81236211fd252ef Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 21:40:32 -0700 Subject: [PATCH 07/10] Unexport actionKind, commandAction, and scriptAction --- transaction.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/transaction.go b/transaction.go index 08ebb05..8f6d9ab 100644 --- a/transaction.go +++ b/transaction.go @@ -27,19 +27,19 @@ type Transaction struct { // Action is a single step in a transaction and must be either a command // or a script with optional arguments and a reply handler. type Action struct { - kind ActionKind + kind actionKind name string script *redis.Script args redis.Args handler ReplyHandler } -// ActionKind is either a command or a script -type ActionKind int +// actionKind is either a command or a script +type actionKind int const ( - CommandAction ActionKind = iota - ScriptAction + commandAction actionKind = iota + scriptAction ) // NewTransaction instantiates and returns a new transaction. @@ -99,7 +99,7 @@ func (t *Transaction) WatchKey(key string) error { // the transaction is executed. func (t *Transaction) Command(name string, args redis.Args, handler ReplyHandler) { t.actions = append(t.actions, &Action{ - kind: CommandAction, + kind: commandAction, name: name, args: args, handler: handler, @@ -111,7 +111,7 @@ func (t *Transaction) Command(name string, args redis.Args, handler ReplyHandler // the transaction is executed. func (t *Transaction) Script(script *redis.Script, args redis.Args, handler ReplyHandler) { t.actions = append(t.actions, &Action{ - kind: ScriptAction, + kind: scriptAction, script: script, args: args, handler: handler, @@ -121,9 +121,9 @@ func (t *Transaction) Script(script *redis.Script, args redis.Args, handler Repl // sendAction writes a to a connection buffer using conn.Send() func (t *Transaction) sendAction(a *Action) error { switch a.kind { - case CommandAction: + case commandAction: return t.conn.Send(a.name, a.args...) - case ScriptAction: + case scriptAction: return a.script.Send(t.conn, a.args...) } return nil @@ -133,9 +133,9 @@ func (t *Transaction) sendAction(a *Action) error { // flushes the buffer and reads the reply via conn.Do() func (t *Transaction) doAction(a *Action) (interface{}, error) { switch a.kind { - case CommandAction: + case commandAction: return t.conn.Do(a.name, a.args...) - case ScriptAction: + case scriptAction: return a.script.Do(t.conn, a.args...) } return nil, nil From a837248d761c80c0e8a3ee6b0e068ab81995a096 Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 21:43:50 -0700 Subject: [PATCH 08/10] Remove FindModelsByIdsKey. This method was too complicated and hard to document. I added it to Zoom because I thought I needed it for a different project. However, I ended up using a different approach and this method is no longer needed. It's extremely unlikely that someone else would need it, and we can always add it back in later. --- transaction.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/transaction.go b/transaction.go index 8f6d9ab..f09d5ff 100644 --- a/transaction.go +++ b/transaction.go @@ -244,17 +244,3 @@ func (t *Transaction) ExtractIdsFromFieldIndex(setKey string, destKey string, mi func (t *Transaction) ExtractIdsFromStringIndex(setKey, destKey, min, max string) { t.Script(extractIdsFromStringIndexScript, redis.Args{setKey, destKey, min, max}, nil) } - -func (t *Transaction) FindModelsByIdsKey(collection *Collection, idsKey string, fieldNames []string, limit uint, offset uint, reverse bool, models interface{}) { - if err := collection.checkModelsType(models); err != nil { - t.setError(fmt.Errorf("zoom: error in FindModelsByIdKey: %s", err.Error())) - return - } - redisNames, err := collection.spec.redisNamesForFieldNames(fieldNames) - if err != nil { - t.setError(fmt.Errorf("zoom: error in FindModelsByIdKey: %s", err.Error())) - return - } - sortArgs := collection.spec.sortArgs(idsKey, redisNames, int(limit), offset, reverse) - t.Command("SORT", sortArgs, newScanModelsHandler(collection.spec, append(fieldNames, "-"), models)) -} From 6bb65c4ba2a6d963a66b85b1bdbcb4362206d24e Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 22:11:38 -0700 Subject: [PATCH 09/10] Update README for optimistic locking --- README.md | 102 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index fb20d53..173d94a 100755 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ Table of Contents - [More Information](#more-information) * [Persistence](#persistence) * [Atomicity](#atomicity) - * [Concurrent Updates](#concurrent-updates) + * [Concurrent Updates and Optimistic Locking](#concurrent-updates-and-optimistic-locking) - [Testing & Benchmarking](#testing---benchmarking) - * [Running the Tests:](#running-the-tests-) - * [Running the Benchmarks:](#running-the-benchmarks-) + * [Running the Tests](#running-the-tests) + * [Running the Benchmarks](#running-the-benchmarks) - [Contributing](#contributing) - [Example Usage](#example-usage) - [License](#license) @@ -56,7 +56,7 @@ Development Status ------------------ Zoom was first started in 2013. It is well-tested and going forward the API -will be relatively stable. We are closing in on Version 1.0.0-alpha. +will be relatively stable. We are closing in on Version 1.0. At this time, Zoom can be considered safe for use in low-traffic production applications. However, as with any relatively new package, it is possible that @@ -97,15 +97,15 @@ Zoom might be a good fit if: Zoom might ***not*** be a good fit if: -1. **You are working with a lot of data.** Zoom stores all data in memory at all times, and does not +1. **You are working with a lot of data.** Redis is an in-memory database, and Zoom does not yet support sharding or Redis Cluster. Memory could be a hard constraint for larger applications. Keep in mind that it is possible (if expensive) to run Redis on machines with up to 256GB of memory on cloud providers such as Amazon EC2. -2. **You require the ability to run advanced queries.** Zoom currently only provides support for - basic queries and is not as powerful or flexible as something like SQL. For example, Zoom currently - lacks the equivalent of the `IN` or `OR` SQL keywords. See the - [documentation](http://godoc.org/github.com/albrow/zoom/#Query) for a full list of the types of queries - supported. +2. **You need advanced queries.** Zoom currently only provides support for basic queries and is + not as powerful or flexible as something like SQL. For example, Zoom currently lacks the + equivalent of the `IN` or `OR` SQL keywords. See the + [documentation](http://godoc.org/github.com/albrow/zoom/#Query) for a full list of the types + of queries supported. Installation @@ -197,7 +197,7 @@ To clarify, all you have to do to implement the `Model` interface is add a gette for a unique id property. If you want, you can embed `zoom.RandomId` to give your model all the -required methods. A struct with `zoom.RandomId` embedded will genrate a pseudo-random id for itself +required methods. A struct with `zoom.RandomId` embedded will generate a pseudo-random id for itself the first time the `ModelId` method is called iff it does not already have an id. The pseudo-randomly generated id consists of the current UTC unix time with second precision, an incremented atomic counter, a unique machine identifier, and an additional random string of characters. With ids generated @@ -349,7 +349,8 @@ if err := People.UpdateFields([]string{"Name"}, person); err != nil { `UpdateFields` uses "last write wins" semantics, so if another caller updates the same field, your changes may be overwritten. That means it is not safe for "read before write" updates. See the section on -[Concurrent Updates](#concurrent-updates) for more information. +[Concurrent Updates](#concurrent-updates-and-optimistic-locking) for more +information. ### Finding a Single Model @@ -580,7 +581,7 @@ corrupted. If this happens, Redis will refuse to start until the AOF file is fix easy to fix the problem with the `redis-check-aof` tool, which will remove the partial transaction from the AOF file. -If you intend to issue custom Redis commands or run custom scripts, it is highly recommended that +If you intend to issue Redis commands directly or run custom scripts, it is highly recommended that you also make everything atomic. If you do not, Zoom can no longer guarantee that its indexes are consistent. For example, if you change the value of a field which is indexed, you should also update the index for that field in the same transaction. The keys that Zoom uses for indexes @@ -593,21 +594,29 @@ Read more about: - [Redis scripts](http://redis.io/commands/eval) - [Redis transactions](http://redis.io/topics/transactions) -### Concurrent Updates +### Concurrent Updates and Optimistic Locking -Currently, Zoom does not directly support concurrent "read before write" updates -on models. The `UpdateFields` method introduced in version 0.12 offers some -additional safety for concurrent updates, as long as no concurrent callers -update the same fields (or if you are okay with updates overwriting previous -changes). However, cases where you need to do a "read before write" update are -still not safe if you use a naive implementation. For example, consider the -following code: +Zoom 0.18.0 introduced support for basic optimistic locking. You can use +optimistic locking to safely implement concurrent "read before write" updates. + +Optimistic locking utilizes the `WATCH`, `MULTI`, and `EXEC` commands in Redis +and only works in the context of transactions. You can use the +[`Transaction.Watch`](https://godoc.org/github.com/albrow/zoom#Transaction.Watch) +method to watch a model for changes. If the model changes after you call `Watch` +but before you call `Exec`, the transaction will not be executed and instead +will return a +[`WatchError`](https://godoc.org/github.com/albrow/zoom#WatchError). You can +also use the `WatchKey` method, which functions exactly the same but operates on +keys instead of models. + +To understand why optimistic locking is useful, consider the following code: ``` go +// likePost increments the number of likes for a post with the given id. func likePost(postId string) error { // Find the Post with the given postId post := &Post{} - if err := Posts.Find(postId); err != nil { + if err := Posts.Find(postId, post); err != nil { return err } // Increment the number of likes @@ -628,15 +637,48 @@ multiple machines concurrently, because the `Post` model can change in between the time we retrieved it from the database with `Find` and saved it again with `Save`. -However, since Zoom allows you to run your own Redis commands, you could fix -this code by manually using HINCRBY: +You can use optimistic locking to avoid this problem. Here's the revised code: + +```go +// likePost increments the number of likes for a post with the given id. +func likePost(postId string) error { + // Start a new transaction and watch the post key for changes. It's important + // to call Watch or WatchKey *before* finding the model. + tx := pool.NewTransaction() + if err := tx.WatchKey(Posts.ModelKey(postId)); err != nil { + return err + } + // Find the Post with the given postId + post := &Post{} + if err := Posts.Find(postId, post); err != nil { + return err + } + // Increment the number of likes + post.Likes += 1 + // Save the post in a transaction + tx.Save(Posts, post) + if err := tx.Exec(); err != nil { + // If the post was modified by another goroutine or server, Exec will return + // a WatchError. You could call likePost again to retry the operation. + return err + } +} +``` + +Optimistic locking is not appropriate for models which are frequently updated, +because you would almost always get a `WatchError`. In fact, it's called +"optimistic" locking because you are optimistically assuming that conflicts will +be rare. That's not always a safe assumption. + +Don't forget that Zoom allows you to run Redis commands directly. This +particular problem might be best solved by the `HINCRBY` command. ```go // likePost atomically increments the number of likes for a post with the given // id and then returns the new number of likes. func likePost(postId string) (int, error) { // Get the key which is used to store the post in Redis - postKey := Posts.ModelKey(postId) + postKey := Posts.ModelKey(postId, post) // Start a new transaction tx := pool.NewTransaction() // Add a command to increment the number of Likes. The HINCRBY command returns @@ -654,9 +696,13 @@ func likePost(postId string) (int, error) { } ``` -Future versions of Zoom may provide -[optimistic locking](https://github.com/albrow/zoom/issues/13) or other means to -make "read before write" updates easier. +Finally, if optimistic locking is not appropriate and there is no built-in Redis +command that offers the functionality you need, Zoom also supports custom Lua +scripts via the +[`Transaction.Script`](https://godoc.org/github.com/albrow/zoom#Transaction.Script) +method. Redis is single-threaded and scripts are always executed atomically, so +you can perform complicated updates without worrying about other clients +changing the database. Read more about: - [Redis Commands](http://redis.io/commands) From 61ff06f5be5e6d455015dcff9fdd5ac2fe0e5eb1 Mon Sep 17 00:00:00 2001 From: Alex Browne Date: Mon, 30 May 2016 22:14:41 -0700 Subject: [PATCH 10/10] Bump version to 0.18.0 --- README.md | 2 +- doc.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 173d94a..0b223ba 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Zoom ==== -[![Version](https://img.shields.io/badge/version-develop-5272B4.svg)](https://github.com/albrow/zoom/releases) +[![Version](https://img.shields.io/badge/version-0.18.0-5272B4.svg)](https://github.com/albrow/zoom/releases) [![Circle CI](https://img.shields.io/circleci/project/albrow/zoom/master.svg)](https://circleci.com/gh/albrow/zoom/tree/master) [![GoDoc](https://godoc.org/github.com/albrow/zoom?status.svg)](https://godoc.org/github.com/albrow/zoom) diff --git a/doc.go b/doc.go index 7b3be36..1c29db3 100755 --- a/doc.go +++ b/doc.go @@ -8,7 +8,7 @@ // atomic transactions, lua scripts, and running Redis commands // directly if needed. // -// Version X.X.X +// Version 0.18.0 // // For installation instructions, examples, and more information // visit https://github.com/albrow/zoom.