Skip to content
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

Merged
merged 6 commits into from Mar 22, 2022
Merged

Optimistic locking #265

merged 6 commits into from Mar 22, 2022

Conversation

dranikpg
Copy link
Contributor

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 uses set lock_version = lock_version + 1 for updating locks.

What I've changed

Mainly repository.update() and repository.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 to Mutates 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.

@dranikpg
Copy link
Contributor Author

@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 (Repository.Delete()) so that rel and reltest stay in sync?

@Fs02
Copy link
Member

Fs02 commented Feb 20, 2022

@dranikpg ah right, let me remove it first

@Fs02
Copy link
Member

Fs02 commented Feb 20, 2022

Removed deprecated reltest: #266

@dranikpg
Copy link
Contributor Author

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 Repository interface. Updating reltest requires having an updated version of rel itself 🔁

One solution is to specify dependencies with tags/branches during PR reviews and then change them before merging.

@Fs02
Copy link
Member

Fs02 commented Feb 23, 2022

sorry for the wait, I've decided to extract migrator package, now rel doesn't depends on reltest anymore
#267

@codecov
Copy link

codecov bot commented Feb 23, 2022

Codecov Report

Merging #265 (2bfede5) into master (23fd349) will not change coverage.
The diff coverage is 100.00%.

@@            Coverage Diff            @@
##            master      #265   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           33        33           
  Lines         2737      2768   +31     
=========================================
+ Hits          2737      2768   +31     
Impacted Files Coverage Δ
document.go 100.00% <100.00%> (ø)
filter_query.go 100.00% <100.00%> (ø)
mutation.go 100.00% <100.00%> (ø)
repository.go 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 23fd349...2bfede5. Read the comment docs.

@@ -517,13 +520,23 @@ func extractBoolFlag(name string) DocumentFlag {
return Invalid
}

func extractIntFlag(name string) DocumentFlag {
if name == "lock_version" {
Copy link
Contributor Author

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
Copy link
Contributor Author

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

Comment on lines +63 to +64
Address Address `ref:"address_id" fk:"id"`
Histories *[]History `ref:"id" fk:"transaction_id"`
Copy link
Contributor Author

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

Comment on lines +495 to +497
if unscoped {
return 0, false
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments below

Comment on lines +514 to +515
if err := r.applyMutates(cw, doc, mutation, filter); err != nil {
return err
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce function complexity

Comment on lines +534 to +535
baseQueries = []Querier{filter, mutation.Unscoped, mutation.Cascade}
queries = baseQueries
Copy link
Contributor Author

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
Comment on lines 537 to 539
if version, ok := r.lockVersion(*doc, mutation.Unscoped); ok {
Inc("lock_version").SkipReload().Apply(doc, &mutation)
queries = append(queries, LockVersion(version))
Copy link
Contributor Author

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)
Copy link
Contributor Author

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
Comment on lines 557 to 565
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)
}

Copy link
Contributor Author

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...)
Copy link
Contributor Author

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)
Copy link
Member

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

Copy link
Contributor Author

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.

Copy link
Member

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)

Copy link
Contributor Author

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?

Copy link
Member

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

@dranikpg dranikpg marked this pull request as ready for review March 20, 2022 09:21
@dranikpg
Copy link
Contributor Author

dranikpg commented Mar 20, 2022

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) {
Copy link
Member

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

Copy link
Contributor Author

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?

Copy link
Member

@Fs02 Fs02 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Thank you!!

@Fs02 Fs02 merged commit f3a1b01 into go-rel:master Mar 22, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants