examples: update version example to follow the locking blog post

This commit is contained in:
Ariel Mashraki
2021-07-25 16:22:25 +03:00
committed by Ariel Mashraki
parent cdfa3e35bb
commit 9704c4c87b
9 changed files with 44 additions and 110 deletions

View File

@@ -1,9 +1,10 @@
# [Optimistic Lock](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) Using Hooks
# [Optimistic Lock](https://en.wikipedia.org/wiki/Optimistic_concurrency_control)
In this example, we implement an optimistic locking mechanism using an `ent.Hook`.
In this example, we implement an optimistic locking mechanism using the technique mentioned in
[Ent Blog](https://entgo.io/blog/2021/07/22/database-locking-techniques-with-ent/).
The idea is to add to our schema a `version` field that holds the Unix time of when the latest update occurred.
When an `UpdateOne` operation is executed, the hook updates the `version` field with the new value and adds a predicate
When an `Update` operation is executed, the hook updates the `version` field with the new value and adds a predicate
to verify that the `version` wasn't updated by another process/transaction during the mutation.
An error is returned if the versions are mismatched, and the user should reload the entity and retry the mutation.

View File

@@ -212,6 +212,5 @@ func (c *UserClient) GetX(ctx context.Context, id int) *User {
// Hooks returns the client hooks.
func (c *UserClient) Hooks() []Hook {
hooks := c.hooks.User
return append(hooks[:len(hooks):len(hooks)], user.Hooks[:]...)
return c.hooks.User
}

View File

@@ -9,4 +9,4 @@
// Package internal holds a loadable version of the latest schema.
package internal
const Schema = `{"Schema":"entgo.io/ent/examples/version/ent/schema","Package":"entgo.io/ent/examples/version/ent","Schemas":[{"name":"User","config":{"Table":""},"fields":[{"name":"version","type":{"Type":13,"Ident":"","PkgPath":"","Nillable":false,"RType":null},"default":true,"default_kind":19,"position":{"Index":0,"MixedIn":true,"MixinIndex":0},"comment":"Unix time of when the latest update occurred"},{"name":"status","type":{"Type":6,"Ident":"user.Status","PkgPath":"","Nillable":false,"RType":null},"enums":[{"N":"online","V":"online"},{"N":"offline","V":"offline"}],"position":{"Index":0,"MixedIn":false,"MixinIndex":0}}],"hooks":[{"Index":0,"MixedIn":true,"MixinIndex":0},{"Index":1,"MixedIn":true,"MixinIndex":0}]}],"Features":["schema/snapshot"]}`
const Schema = `{"Schema":"entgo.io/ent/examples/version/ent/schema","Package":"entgo.io/ent/examples/version/ent","Schemas":[{"name":"User","config":{"Table":""},"fields":[{"name":"version","type":{"Type":13,"Ident":"","PkgPath":"","Nillable":false,"RType":null},"default":true,"default_kind":19,"position":{"Index":0,"MixedIn":true,"MixinIndex":0},"comment":"Unix time of when the latest update occurred"},{"name":"status","type":{"Type":6,"Ident":"user.Status","PkgPath":"","Nillable":false,"RType":null},"enums":[{"N":"online","V":"online"},{"N":"offline","V":"offline"}],"position":{"Index":0,"MixedIn":false,"MixinIndex":0}}]}],"Features":["schema/snapshot"]}`

View File

@@ -6,4 +6,22 @@
package ent
// The schema-stitching logic is generated in entgo.io/ent/examples/version/ent/runtime/runtime.go
import (
"entgo.io/ent/examples/version/ent/schema"
"entgo.io/ent/examples/version/ent/user"
)
// The init function reads all schema descriptors with runtime code
// (default values, validators, hooks and policies) and stitches it
// to their package variables.
func init() {
userMixin := schema.User{}.Mixin()
userMixinFields0 := userMixin[0].Fields()
_ = userMixinFields0
userFields := schema.User{}.Fields()
_ = userFields
// userDescVersion is the schema descriptor for version field.
userDescVersion := userMixinFields0[0].Descriptor()
// user.DefaultVersion holds the default value on creation for the version field.
user.DefaultVersion = userDescVersion.Default.(func() int64)
}

View File

@@ -6,28 +6,7 @@
package runtime
import (
"entgo.io/ent/examples/version/ent/schema"
"entgo.io/ent/examples/version/ent/user"
)
// The init function reads all schema descriptors with runtime code
// (default values, validators, hooks and policies) and stitches it
// to their package variables.
func init() {
userMixin := schema.User{}.Mixin()
userMixinHooks0 := userMixin[0].Hooks()
user.Hooks[0] = userMixinHooks0[0]
user.Hooks[1] = userMixinHooks0[1]
userMixinFields0 := userMixin[0].Fields()
_ = userMixinFields0
userFields := schema.User{}.Fields()
_ = userFields
// userDescVersion is the schema descriptor for version field.
userDescVersion := userMixinFields0[0].Descriptor()
// user.DefaultVersion holds the default value on creation for the version field.
user.DefaultVersion = userDescVersion.Default.(func() int64)
}
// The schema-stitching logic is generated in entgo.io/ent/examples/version/ent/runtime.go
const (
Version = "(devel)" // Version of ent codegen.

View File

@@ -5,17 +5,9 @@
package schema
import (
"context"
"fmt"
"time"
gen "entgo.io/ent/examples/version/ent"
"entgo.io/ent/examples/version/ent/hook"
"entgo.io/ent/examples/version/ent/predicate"
"entgo.io/ent/examples/version/ent/user"
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/mixin"
)
@@ -56,52 +48,3 @@ func (VersionMixin) Fields() []ent.Field {
Comment("Unix time of when the latest update occurred"),
}
}
// Hooks of the VersionMixin.
func (VersionMixin) Hooks() []ent.Hook {
return []ent.Hook{
// Apply the `OptimisticLock` hook only on `UpdateOne` operations,
// and block all `Update` (update-many) operations as we don't have
// access to the nodes that are affected by these mutation.
hook.On(OptimisticLock(), ent.OpUpdateOne),
hook.Reject(ent.OpUpdate),
}
}
func OptimisticLock() ent.Hook {
return func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *gen.UserMutation) (ent.Value, error) {
oldV, err := m.OldVersion(ctx)
if err != nil {
return nil, err
}
curV, exists := m.Version()
if !exists {
curV = time.Now().UnixNano()
m.SetVersion(curV)
} else if curV <= oldV {
return nil, fmt.Errorf("version field must be > previous value: %v <= %v", curV, oldV)
}
// Add an SQL predicate that validates the "version" column is equal
// to "oldV" (it wasn't changed during the mutation by another process).
m.Where(MatchVersion(oldV, curV))
v, err := next.Mutate(ctx, m)
if gen.IsNotFound(err) {
id, _ := m.ID()
return nil, fmt.Errorf("user %d was changed by another process", id)
}
return v, err
})
}
}
// MatchVersion returns a "dynamic User predicate". First, it checks that the "version"
// field was not changed by another process when executing the `UPDATE` operation. Next,
// it checks that the new value matches when `SELECT`-ing the node from the database.
func MatchVersion(oldV, curV int64) predicate.User {
p := user.Version(oldV)
return func(s *sql.Selector) {
p(s)
p = user.Version(curV)
}
}

View File

@@ -8,8 +8,6 @@ package user
import (
"fmt"
"entgo.io/ent"
)
const (
@@ -42,14 +40,7 @@ func ValidColumn(column string) bool {
return false
}
// Note that the variables below are initialized by the runtime
// package on the initialization of the application. Therefore,
// it should be imported in the main as follows:
//
// import _ "entgo.io/ent/examples/version/ent/runtime"
//
var (
Hooks [2]ent.Hook
// DefaultVersion holds the default value on creation for the "version" field.
DefaultVersion func() int64
)

View File

@@ -54,9 +54,7 @@ func (uc *UserCreate) Save(ctx context.Context) (*User, error) {
err error
node *User
)
if err := uc.defaults(); err != nil {
return nil, err
}
uc.defaults()
if len(uc.hooks) == 0 {
if err = uc.check(); err != nil {
return nil, err
@@ -115,15 +113,11 @@ func (uc *UserCreate) ExecX(ctx context.Context) {
}
// defaults sets the default values of the builder before save.
func (uc *UserCreate) defaults() error {
func (uc *UserCreate) defaults() {
if _, ok := uc.mutation.Version(); !ok {
if user.DefaultVersion == nil {
return fmt.Errorf("ent: uninitialized user.DefaultVersion (forgotten import ent/runtime?)")
}
v := user.DefaultVersion()
uc.mutation.SetVersion(v)
}
return nil
}
// check runs all checks and user-defined validators on the builder.

View File

@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"log"
"time"
"entgo.io/ent/examples/version/ent"
_ "entgo.io/ent/examples/version/ent/runtime"
@@ -32,15 +33,23 @@ func Example_OptimisticLock() {
fmt.Println(usr.ID, usr.Status)
usrCopy := client.User.Query().OnlyX(ctx)
usrCopy = usrCopy.Update().SetStatus(user.StatusOffline).SaveX(ctx)
fmt.Println(usrCopy.ID, usrCopy.Status)
affected := client.User.Update().
Where(user.ID(usrCopy.ID), user.Version(usrCopy.Version)).
SetStatus(user.StatusOffline).
SetVersion(time.Now().UnixNano()).
SaveX(ctx)
fmt.Println(affected)
// The operation fails because the user was updated by another process (usrCopy).
_, err = usr.Update().SetStatus(user.StatusOffline).Save(ctx)
fmt.Println(err)
// The operation won't updated the database because the user was updated by another process (usrCopy).
affected = client.User.Update().
Where(user.ID(usr.ID), user.Version(usr.Version)).
SetStatus(user.StatusOffline).
SetVersion(time.Now().UnixNano()).
SaveX(ctx)
fmt.Println(affected)
// Output:
// 1 online
// 1 offline
// user 1 was changed by another process
// 1
// 0
}