Skip to content

Commit

Permalink
Merge branch release-0.18.0
Browse files Browse the repository at this point in the history
  • Loading branch information
albrow committed May 31, 2016
2 parents 66a5ee6 + 61ff06f commit d56f99c
Show file tree
Hide file tree
Showing 7 changed files with 274 additions and 69 deletions.
104 changes: 75 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Zoom
====

[![Version](https://img.shields.io/badge/version-0.17.0-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)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
30 changes: 27 additions & 3 deletions collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ 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
// 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
Expand Down Expand Up @@ -575,14 +575,38 @@ 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) {
t := c.pool.NewTransaction()
count := 0
t.Count(c, &count)
if err := t.Exec(); err != nil {
return count, err
return 0, err
}
return count, nil
}
Expand Down
31 changes: 30 additions & 1 deletion collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// atomic transactions, lua scripts, and running Redis commands
// directly if needed.
//
// Version 0.17.0
// Version 0.18.0
//
// For installation instructions, examples, and more information
// visit https://github.com/albrow/zoom.
Expand Down
8 changes: 8 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading

0 comments on commit d56f99c

Please sign in to comment.