New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Optimistic locking #265
Optimistic locking #265
Conversation
@Fs02 The reltest setup somewhat confuses me 🤔 An older version of it is still under rel/reltest? How should I push a breaking API change ( |
@dranikpg ah right, let me remove it first |
Removed deprecated reltest: #266 |
That still leaves me wondering how to update both rel and reltest as they depend on each other. The CI currently breaks because reltest still uses the old One solution is to specify dependencies with tags/branches during PR reviews and then change them before merging. |
sorry for the wait, I've decided to extract migrator package, now rel doesn't depends on reltest anymore |
Codecov Report
@@ Coverage Diff @@
## master #265 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 33 33
Lines 2737 2768 +31
=========================================
+ Hits 2737 2768 +31
Continue to review full report at Codecov.
|
@@ -517,13 +520,23 @@ func extractBoolFlag(name string) DocumentFlag { | |||
return Invalid | |||
} | |||
|
|||
func extractIntFlag(name string) DocumentFlag { | |||
if name == "lock_version" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Field names like "created_at" and "updated_at" are also not constants, so I've also kept this a literal everywhere 🤷🏻
mutation.go
Outdated
Type ChangeOp | ||
Field string | ||
Value interface{} | ||
NoReload Reload |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skip reload for queries that use SQL expressions which can be evaluated without querying
Address Address `ref:"address_id" fk:"id"` | ||
Histories *[]History `ref:"id" fk:"transaction_id"` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Auto field name deduction doesn't work with embedded structs 😞 I'll fix this
if unscoped { | ||
return 0, false | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comments below
if err := r.applyMutates(cw, doc, mutation, filter); err != nil { | ||
return err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To reduce function complexity
baseQueries = []Querier{filter, mutation.Unscoped, mutation.Cascade} | ||
queries = baseQueries |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have to reload mutation queriers (like version_lock += 1) when reloading a document, so we store the base slice
repository.go
Outdated
if version, ok := r.lockVersion(*doc, mutation.Unscoped); ok { | ||
Inc("lock_version").SkipReload().Apply(doc, &mutation) | ||
queries = append(queries, LockVersion(version)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And add a query + mutation if the document has a version lock and the query is scoped
|
||
var ( | ||
pField string | ||
query = r.withDefaultScope(doc.data, Build(doc.Table(), queries...), false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I first thought of reusing withDefaultScope for applying version_lock mutations
repository.go
Outdated
if mutation.Reload { | ||
baseQuery := r.withDefaultScope(doc.data, Build(doc.Table(), baseQueries...), false) | ||
if err := r.find(cw, doc, baseQuery.UsePrimary()); err != nil { | ||
return err | ||
} | ||
} else if version, ok := r.lockVersion(*doc, mutation.Unscoped); ok { | ||
doc.SetValue("lock_version", version+1) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But abstracting away version_lock logic doesn't allow us to simply modify the version lock afterwards.
Otherwise we could extend mutates and mark some of them to be "not included" in reload queries and to be computable without a reload. This would, for example, allow handling other increments with a version lock without reloading the document.
cascade = Cascade(false) | ||
cw = fetchContext(ctx, r.rootAdapter) | ||
doc = NewDocument(record) | ||
mutation = applyMutators(nil, false, false, mutators...) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apply mutators without enabling cascading and "structset"
repository.go
Outdated
return err | ||
} | ||
} else if version, ok := r.lockVersion(*doc, mutation.Unscoped); ok { | ||
doc.SetValue("lock_version", version+1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this will return incorrect version if multiple process is updating the same record concurrently without using lock
to make things simple, I think what we can do is:
- if lock is enabled: automatically update using Set with new version value is computed from rel side
- if lock is disabled: don't automatically update version, warn user that lock version will be replaced
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the long pause, had to take a break.
Why? If SET version = version + 1 WHERE version = x
exectues successfully, then the version has to be x + 1. As far as I understand, we don't care about other fields. Mixing usage with and without locks is up to the user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If SET version = version + 1 WHERE version = x exectues successfully, then the version has to be x + 1.
ah right this is true
but I can see the implementation can be simplified if we just do SET version = newVersion WHERE version = previousVersion
, where we locally compute newVersion = previousVersion+1
in memory
this way, we don't have to introduce NoReload, semantics that rel.Inc/rel.Dec/rel.Fragment always trigger reload will remain true.
(it always trigger reload, because there's no way to compute the updated record correctly in memory)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, thats true.
They key problem here is that Set() calls are always applied to the document beforehand (in Mutate.Apply()), regardless of whether the record is found/any erros occured. This is the default behaviour for rel now.
Should version locks be rolled back in case of unsuccessful updates?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah true,
Should version locks be rolled back in case of unsuccessful updates?
I think yes
I've removed all NoReload stuff and replaced the increment with a Set. A simple defer now rollbacks the version lock. |
adapter.AssertExpectations(t) | ||
} | ||
|
||
func TestRepository_LockVersion_Delete(t *testing.T) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you add a test for LockVersion + SoftDelete
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added. If I understand correctly, soft deletes don't update the documents fields?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, Thank you!!
Hi! I've implemented optimistic locking as discussed here.
It's automatically enabled by including a
LockVersion int
field into the struct.It adds a
where lock_version = $
clause to select only valid records and usesset lock_version = lock_version + 1
for updating locks.What I've changed
Mainly
repository.update()
andrepository.delete()
. They now include custom logic for applying locks.Because using SQL expressions like += requires reloading the whole struct, I've added a
NoReload
flag toMutates
to surpress triggering reloads for lock updates. In case no reload is required, the locks are incremented manually.Tests
I've added simple tests for updating and deleting actual and stale records.