diff --git a/Makefile b/Makefile index dbd4fa1..0fc5bee 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ mocks: mocks-v3 mocks-v4 benchmark: bins @#v4 - @cd ./v4 && GO111MODULE=on go test -run XXX -bench . && cd .. + @cd ./v4/internal/benchmark && GO111MODULE=on go test -run XXX -bench . && cd ../../ demo: bins @docker-compose --file ./docker/docker-compose.yaml up -d diff --git a/v4/README.md b/v4/README.md index e97d864..ced4181 100644 --- a/v4/README.md +++ b/v4/README.md @@ -101,17 +101,19 @@ u, err := unit.New(opts...)

-| Name | Type | Description | -| -------------------------------- | ------- | ------------------------------------------------ | -| [_PREFIX._]unit.save.success | counter | The number of successful work unit saves. | -| [_PREFIX._]unit.save | timer | The time duration when saving a work unit. | -| [_PREFIX._]unit.rollback.success | counter | The number of successful work unit rollbacks. | -| [_PREFIX._]unit.rollback.failure | counter | The number of unsuccessful work unit rollbacks. | -| [_PREFIX._]unit.rollback | timer | The time duration when rolling back a work unit. | -| [_PREFIX._]unit.retry.attempt | counter | The number of retry attempts. | -| [_PREFIX._]unit.insert | counter | The number of successful inserts performed. | -| [_PREFIX._]unit.update | counter | The number of successful updates performed. | -| [_PREFIX._]unit.delete | counter | The number of successful deletes performed. | +| Name | Type | Description | +| -------------------------------- | ------- | ------------------------------------------------------------ | +| [_PREFIX._]unit.save.success | counter | The number of successful work unit saves. | +| [_PREFIX._]unit.save | timer | The time duration when saving a work unit. | +| [_PREFIX._]unit.rollback.success | counter | The number of successful work unit rollbacks. | +| [_PREFIX._]unit.rollback.failure | counter | The number of unsuccessful work unit rollbacks. | +| [_PREFIX._]unit.rollback | timer | The time duration when rolling back a work unit. | +| [_PREFIX._]unit.retry.attempt | counter | The number of retry attempts. | +| [_PREFIX._]unit.insert | counter | The number of successful inserts performed. | +| [_PREFIX._]unit.update | counter | The number of successful updates performed. | +| [_PREFIX._]unit.delete | counter | The number of successful deletes performed. | +| [_PREFIX._]unit.cache.insert | counter | The number of registered entities inserted into the cache. | +| [_PREFIX._]unit.cache.delete | counter | The number of registered entities removed from the cache. | ### Uniters In most circumstances, an application has many aspects that result in the diff --git a/v4/best_effort_unit_test.go b/v4/best_effort_unit_test.go index 92ccf51..e1d541b 100644 --- a/v4/best_effort_unit_test.go +++ b/v4/best_effort_unit_test.go @@ -23,6 +23,7 @@ import ( "github.com/freerware/work/v4" "github.com/freerware/work/v4/internal/mock" + "github.com/freerware/work/v4/internal/test" "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" "github.com/uber-go/tally" @@ -60,6 +61,10 @@ type BestEffortUnitTestSuite struct { updateScopeNameWithTags string deleteScopeName string deleteScopeNameWithTags string + cacheInsertScopeName string + cacheDeleteScopeName string + cacheInsertScopeNameWithTags string + cacheDeleteScopeNameWithTags string tags string // suite state. @@ -98,11 +103,15 @@ func (s *BestEffortUnitTestSuite) Setup() { s.updateScopeNameWithTags = fmt.Sprintf("%s%s%s", s.updateScopeName, sep, s.tags) s.deleteScopeName = fmt.Sprintf("%s.%s", s.scopePrefix, "unit.delete") s.deleteScopeNameWithTags = fmt.Sprintf("%s%s%s", s.deleteScopeName, sep, s.tags) + s.cacheInsertScopeName = fmt.Sprintf("%s.%s", s.scopePrefix, "unit.cache.insert") + s.cacheInsertScopeNameWithTags = fmt.Sprintf("%s%s%s", s.cacheInsertScopeName, sep, s.tags) + s.cacheDeleteScopeName = fmt.Sprintf("%s.%s", s.scopePrefix, "unit.cache.delete") + s.cacheDeleteScopeNameWithTags = fmt.Sprintf("%s%s%s", s.cacheDeleteScopeName, sep, s.tags) // test entities. - foo := Foo{ID: 28} + foo := test.Foo{ID: 28} fooTypeName := work.TypeNameOf(foo) - bar := Bar{ID: "28"} + bar := test.Bar{ID: "28"} barTypeName := work.TypeNameOf(bar) // initialize mocks. @@ -141,9 +150,9 @@ func (s *BestEffortUnitTestSuite) SetupTest() { } func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { - foos := []interface{}{Foo{ID: 28}, Foo{ID: 1992}, Foo{ID: 2}, Foo{ID: 1111}} - bars := []interface{}{Bar{ID: "ID"}, Bar{ID: "1992"}} - fooType, barType := work.TypeNameOf(Foo{}), work.TypeNameOf(Bar{}) + foos := []interface{}{test.Foo{ID: 28}, test.Foo{ID: 1992}, test.Foo{ID: 2}, test.Foo{ID: 1111}} + bars := []interface{}{test.Bar{ID: "ID"}, test.Bar{ID: "1992"}} + fooType, barType := work.TypeNameOf(test.Foo{}), work.TypeNameOf(test.Bar{}) return []TableDrivenTest{ { name: "InsertError", @@ -184,9 +193,11 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 2) + s.Len(s.scope.Snapshot().Counters(), 4) s.Contains(s.scope.Snapshot().Counters(), s.rollbackSuccessScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -231,9 +242,11 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("ouch; whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 2) + s.Len(s.scope.Snapshot().Counters(), 4) s.Contains(s.scope.Snapshot().Counters(), s.rollbackFailureScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -296,9 +309,11 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 2) + s.Len(s.scope.Snapshot().Counters(), 4) s.Contains(s.scope.Snapshot().Counters(), s.rollbackSuccessScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -357,9 +372,11 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("ouch; whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 2) + s.Len(s.scope.Snapshot().Counters(), 4) s.Contains(s.scope.Snapshot().Counters(), s.rollbackFailureScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -430,9 +447,11 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 2) + s.Len(s.scope.Snapshot().Counters(), 4) s.Contains(s.scope.Snapshot().Counters(), s.rollbackSuccessScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -499,9 +518,11 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("whoa; ouch"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 2) + s.Len(s.scope.Snapshot().Counters(), 4) s.Contains(s.scope.Snapshot().Counters(), s.rollbackFailureScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -583,8 +604,10 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { }, ctx: context.Background(), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 1) + s.Len(s.scope.Snapshot().Counters(), 3) s.Contains(s.scope.Snapshot().Counters(), s.rollbackSuccessScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -655,8 +678,10 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 1) + s.Len(s.scope.Snapshot().Counters(), 3) s.Contains(s.scope.Snapshot().Counters(), s.rollbackFailureScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -733,8 +758,10 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { ctx: context.Background(), err: errors.New("whoa"), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 1) + s.Len(s.scope.Snapshot().Counters(), 3) s.Contains(s.scope.Snapshot().Counters(), s.rollbackFailureScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) s.Contains(s.scope.Snapshot().Timers(), s.rollbackScopeNameWithTags) @@ -782,11 +809,13 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { }, ctx: context.Background(), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 4) + s.Len(s.scope.Snapshot().Counters(), 6) s.Contains(s.scope.Snapshot().Counters(), s.saveSuccessScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.insertScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.updateScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.deleteScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 1) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) }, @@ -884,12 +913,14 @@ func (s *BestEffortUnitTestSuite) subtests() []TableDrivenTest { }, ctx: context.Background(), assertions: func() { - s.Len(s.scope.Snapshot().Counters(), 6) + s.Len(s.scope.Snapshot().Counters(), 8) s.Contains(s.scope.Snapshot().Counters(), s.saveSuccessScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.retryAttemptScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.insertScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.updateScopeNameWithTags) s.Contains(s.scope.Snapshot().Counters(), s.deleteScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheInsertScopeNameWithTags) + s.Contains(s.scope.Snapshot().Counters(), s.cacheDeleteScopeNameWithTags) s.Len(s.scope.Snapshot().Timers(), 2) s.Contains(s.scope.Snapshot().Timers(), s.saveScopeNameWithTags) }, diff --git a/v4/identifierer.go b/v4/identifierer.go new file mode 100644 index 0000000..4241205 --- /dev/null +++ b/v4/identifierer.go @@ -0,0 +1,22 @@ +/* Copyright 2022 Freerware + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package work + +// identifierer represents an object defined by its identity, not by its attributes. +type identifierer interface { + // Identifier retrieves the identity for the object. + Identifier() interface{} +} diff --git a/v4/ider.go b/v4/ider.go new file mode 100644 index 0000000..6a6678b --- /dev/null +++ b/v4/ider.go @@ -0,0 +1,22 @@ +/* Copyright 2022 Freerware + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package work + +// ider represents an object defined by its identity, not by its attributes. +type ider interface { + // ider retrieves the identity for the object. + ID() interface{} +} diff --git a/v4/benchmarks_test.go b/v4/internal/benchmark/benchmarks_test.go similarity index 81% rename from v4/benchmarks_test.go rename to v4/internal/benchmark/benchmarks_test.go index 5d89566..d8cf219 100644 --- a/v4/benchmarks_test.go +++ b/v4/internal/benchmark/benchmarks_test.go @@ -13,12 +13,13 @@ * limitations under the License. */ -package work_test +package work_benchmark import ( "context" "testing" + "github.com/freerware/work/v4/internal/test" "github.com/freerware/work/v4/unit" ) @@ -26,7 +27,7 @@ const EntityCount = 500 func setupEntities() (entities []interface{}) { for idx := 0; idx < EntityCount; idx++ { - entities = append(entities, Foo{ID: idx}) + entities = append(entities, test.Foo{ID: idx}) } return } @@ -35,7 +36,7 @@ func setupEntities() (entities []interface{}) { func BenchmarkRegister(b *testing.B) { entities := setupEntities() mappers := map[unit.TypeName]unit.DataMapper{ - unit.TypeNameOf(Foo{}): NoOpDataMapper{}, + unit.TypeNameOf(test.Foo{}): NoOpDataMapper{}, } b.StopTimer() b.ResetTimer() @@ -56,7 +57,7 @@ func BenchmarkRegister(b *testing.B) { func BenchmarkAdd(b *testing.B) { entities := setupEntities() mappers := map[unit.TypeName]unit.DataMapper{ - unit.TypeNameOf(Foo{}): NoOpDataMapper{}, + unit.TypeNameOf(test.Foo{}): NoOpDataMapper{}, } b.StopTimer() b.ResetTimer() @@ -77,7 +78,7 @@ func BenchmarkAdd(b *testing.B) { func BenchmarkAlter(b *testing.B) { entities := setupEntities() mappers := map[unit.TypeName]unit.DataMapper{ - unit.TypeNameOf(Foo{}): NoOpDataMapper{}, + unit.TypeNameOf(test.Foo{}): NoOpDataMapper{}, } b.StopTimer() b.ResetTimer() @@ -98,7 +99,7 @@ func BenchmarkAlter(b *testing.B) { func BenchmarkRemove(b *testing.B) { entities := setupEntities() mappers := map[unit.TypeName]unit.DataMapper{ - unit.TypeNameOf(Foo{}): NoOpDataMapper{}, + unit.TypeNameOf(test.Foo{}): NoOpDataMapper{}, } b.StopTimer() b.ResetTimer() @@ -119,7 +120,7 @@ func BenchmarkSave(b *testing.B) { ctx := context.Background() entities := setupEntities() mappers := map[unit.TypeName]unit.DataMapper{ - unit.TypeNameOf(Foo{}): NoOpDataMapper{}, + unit.TypeNameOf(test.Foo{}): NoOpDataMapper{}, } b.StopTimer() b.ResetTimer() @@ -148,3 +149,17 @@ func BenchmarkSave(b *testing.B) { } }) } + +type NoOpDataMapper struct{} + +func (dm NoOpDataMapper) Insert(ctx context.Context, mCtx unit.MapperContext, e ...interface{}) error { + return nil +} + +func (dm NoOpDataMapper) Update(ctx context.Context, mCtx unit.MapperContext, e ...interface{}) error { + return nil +} + +func (dm NoOpDataMapper) Delete(ctx context.Context, mCtx unit.MapperContext, e ...interface{}) error { + return nil +} diff --git a/v4/internal/main/metrics_demo.go b/v4/internal/main/metrics_demo.go index 04c527c..333a69a 100644 --- a/v4/internal/main/metrics_demo.go +++ b/v4/internal/main/metrics_demo.go @@ -1,4 +1,4 @@ -/* Copyright 2021 Freerware +/* Copyright 2022 Freerware * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -111,6 +111,14 @@ func main() { panic(err) } + registrations := []interface{}{} + for j := 0; j < rand.Intn(maximumEntitiesPerOperation); j++ { + registrations = append(registrations, foo{}) + } + if err = unit.Register(registrations...); err != nil { + panic(err) + } + additions := []interface{}{} for j := 0; j < rand.Intn(maximumEntitiesPerOperation); j++ { additions = append(additions, foo{}) diff --git a/v4/internal/test/test.go b/v4/internal/test/test.go new file mode 100644 index 0000000..3a01e1c --- /dev/null +++ b/v4/internal/test/test.go @@ -0,0 +1,38 @@ +/* Copyright 2022 Freerware + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +type Foo struct { + ID int +} + +func (f Foo) Identifier() interface{} { return f.ID } + +type Bar struct { + ID string +} + +func (b Bar) Identifier() interface{} { return b.ID } + +type Baz struct { + Identifier string +} + +func (b Baz) ID() interface{} { return b.Identifier } + +type Biz struct { + Identifier string +} diff --git a/v4/sql_unit_test.go b/v4/sql_unit_test.go index c4551a1..c763253 100644 --- a/v4/sql_unit_test.go +++ b/v4/sql_unit_test.go @@ -2,7 +2,7 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * * http://www.apache.org/licenses/LICENSE-2.0 * @@ -25,6 +25,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/freerware/work/v4" "github.com/freerware/work/v4/internal/mock" + "github.com/freerware/work/v4/internal/test" "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" "github.com/uber-go/tally" @@ -104,9 +105,9 @@ func (s *SQLUnitTestSuite) Setup() { s.deleteScopeNameWithTags = fmt.Sprintf("%s%s%s", s.deleteScopeName, sep, s.tags) // test entities. - foo := Foo{ID: 28} + foo := test.Foo{ID: 28} fooTypeName := work.TypeNameOf(foo) - bar := Bar{ID: "28"} + bar := test.Bar{ID: "28"} barTypeName := work.TypeNameOf(bar) // initialize mocks. @@ -149,9 +150,9 @@ func (s *SQLUnitTestSuite) SetupTest() { } func (s *SQLUnitTestSuite) subtests() []TableDrivenTest { - foos := []interface{}{Foo{ID: 28}, Foo{ID: 1992}, Foo{ID: 2}} - bars := []interface{}{Bar{ID: "ID"}, Bar{ID: "1992"}} - fooType, barType := work.TypeNameOf(Foo{}), work.TypeNameOf(Bar{}) + foos := []interface{}{test.Foo{ID: 28}, test.Foo{ID: 1992}, test.Foo{ID: 2}} + bars := []interface{}{test.Bar{ID: "ID"}, test.Bar{ID: "1992"}} + fooType, barType := work.TypeNameOf(test.Foo{}), work.TypeNameOf(test.Bar{}) return []TableDrivenTest{ { name: "TransactionBeginError", diff --git a/v4/unit.go b/v4/unit.go index 966267d..1bbd913 100644 --- a/v4/unit.go +++ b/v4/unit.go @@ -38,6 +38,8 @@ const ( insert = "insert" update = "update" delete = "delete" + cacheInsert = "cache.insert" + cacheDelete = "cache.delete" ) var ( @@ -58,6 +60,10 @@ type Unit interface { // Register tracks the provided entities as clean. Register(...interface{}) error + // Cached provides the entities that have been previously registered + // and have not been acted on via Add, Alter, or Remove. + Cached() *UnitCache + // Add marks the provided entities as new additions. Add(...interface{}) error @@ -77,6 +83,7 @@ type unit struct { alterations map[TypeName][]interface{} removals map[TypeName][]interface{} registered map[TypeName][]interface{} + cached *UnitCache additionCount int alterationCount int removalCount int @@ -139,6 +146,7 @@ func NewUnit(opts ...UnitOption) (Unit, error) { alterations: make(map[TypeName][]interface{}), removals: make(map[TypeName][]interface{}), registered: make(map[TypeName][]interface{}), + cached: &UnitCache{scope: options.Scope}, logger: options.Logger, scope: options.Scope, actions: options.Actions, @@ -160,6 +168,17 @@ func NewUnit(opts ...UnitOption) (Unit, error) { }, nil } +func id(entity interface{}) (interface{}, bool) { + switch i := entity.(type) { + case identifierer: + return i.Identifier(), true + case ider: + return i.ID(), true + default: + return nil, false + } +} + func (u *unit) Register(entities ...interface{}) (err error) { u.executeActions(UnitActionTypeBeforeRegister) for _, entity := range entities { @@ -173,6 +192,9 @@ func (u *unit) Register(entities ...interface{}) (err error) { u.registered[t] = []interface{}{} } u.registered[t] = append(u.registered[t], entity) + if cacheErr := u.cached.store(entity); cacheErr != nil { + u.logger.Warn(cacheErr.Error()) + } u.registerCount = u.registerCount + 1 u.mutex.Unlock() } @@ -180,6 +202,10 @@ func (u *unit) Register(entities ...interface{}) (err error) { return } +func (u *unit) Cached() *UnitCache { + return u.cached +} + func (u *unit) Add(entities ...interface{}) (err error) { u.executeActions(UnitActionTypeBeforeAdd) for _, entity := range entities { @@ -214,6 +240,7 @@ func (u *unit) Alter(entities ...interface{}) (err error) { } u.alterations[t] = append(u.alterations[t], entity) u.alterationCount = u.alterationCount + 1 + u.cached.delete(entity) u.mutex.Unlock() } u.executeActions(UnitActionTypeAfterAlter) @@ -234,6 +261,7 @@ func (u *unit) Remove(entities ...interface{}) (err error) { } u.removals[t] = append(u.removals[t], entity) u.removalCount = u.removalCount + 1 + u.cached.delete(entity) u.mutex.Unlock() } u.executeActions(UnitActionTypeAfterRemove) diff --git a/v4/unit_cache.go b/v4/unit_cache.go new file mode 100644 index 0000000..ef5cdb8 --- /dev/null +++ b/v4/unit_cache.go @@ -0,0 +1,84 @@ +/* Copyright 2022 Freerware + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package work + +import ( + "errors" + "sync" + + "github.com/uber-go/tally" +) + +// UnitCache represents the cache that the work unit manipulates as a result +// of entity registration. +type UnitCache struct { + m sync.Map + + scope tally.Scope +} + +var ( + // ErrUncachableEntity represents the error that is returned when an attempt + // to cache an entity with an unresolvable ID occurs. + ErrUncachableEntity = errors.New("unable to cache entity - does not implement supported interfaces") +) + +// Delete removes an entity from the work unit cache. +func (uc *UnitCache) delete(entity interface{}) { + t := TypeNameOf(entity) + if id, ok := id(entity); ok { + if entitiesByID, ok := uc.m.Load(t); ok { + if entityMap, ok := entitiesByID.(*sync.Map); ok { + entityMap.Delete(id) + uc.scope.Counter(cacheDelete).Inc(1) + } + } + } +} + +// Store places the provided entity in the work unit cache. +func (uc *UnitCache) store(entity interface{}) (err error) { + id, ok := id(entity) + if !ok { + err = ErrUncachableEntity + return + } + t := TypeNameOf(entity) + if cached, ok := uc.m.Load(t); !ok { + entitiesByID := &sync.Map{} + entitiesByID.Store(id, entity) + uc.m.Store(t, entitiesByID) + uc.scope.Counter(cacheInsert).Inc(1) + return + } else { + if entityMap, ok := cached.(*sync.Map); ok { + entityMap.Store(id, entity) + uc.scope.Counter(cacheInsert).Inc(1) + } + return + } +} + +// Load retrieves the entity with the provided type name and ID from the work +// unit cache. +func (uc *UnitCache) Load(t TypeName, id interface{}) (entity interface{}, loaded bool) { + if entitiesByID, ok := uc.m.Load(t); ok { + if entityMap, ok := entitiesByID.(*sync.Map); ok { + entity, loaded = entityMap.Load(id) + } + } + return +} diff --git a/v4/unit_cache_test.go b/v4/unit_cache_test.go new file mode 100644 index 0000000..c49ded3 --- /dev/null +++ b/v4/unit_cache_test.go @@ -0,0 +1,138 @@ +/* Copyright 2022 Freerware + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package work + +import ( + "testing" + + "github.com/freerware/work/v4/internal/test" + "github.com/stretchr/testify/suite" + "github.com/uber-go/tally" +) + +type UnitCacheTestSuite struct { + suite.Suite + + // system under test. + sut UnitCache +} + +func TestUnitCacheTestSuite(t *testing.T) { + suite.Run(t, new(UnitCacheTestSuite)) +} + +func (s *UnitCacheTestSuite) SetupTest() { + s.sut = UnitCache{scope: tally.NoopScope} +} + +func (s *UnitCacheTestSuite) TestUnitCache_Delete() { + // arrange. + baz := test.Baz{Identifier: "1"} + + // action. + s.sut.delete(baz) + + // assert. + _, ok := s.sut.Load(TypeNameOf(baz), baz.ID()) + s.False(ok) +} + +func (s *UnitCacheTestSuite) TestUnitCache_Load_Exists() { + // arrange. + baz := test.Baz{Identifier: "1"} + s.sut.store(baz) + + // action. + actual, ok := s.sut.Load(TypeNameOf(baz), baz.ID()) + + // assert. + s.True(ok) + s.Equal(baz, actual) +} + +func (s *UnitCacheTestSuite) TestUnitCache_Load_EntityNotExists() { + // arrange. + baz := test.Baz{Identifier: "1"} + + // action. + _, ok := s.sut.Load(TypeNameOf(baz), baz.ID()) + + // assert. + s.False(ok) +} + +func (s *UnitCacheTestSuite) TestUnitCache_Load_TypeNotExists() { + // arrange. + baz := test.Baz{Identifier: "1"} + + // action. + _, ok := s.sut.Load("main.Oops", baz.ID()) + + // assert. + s.False(ok) +} + +func (s *UnitCacheTestSuite) TestUnitCache_Store_DifferentID() { + // arrange. + baz := test.Baz{Identifier: "2"} + bar := test.Bar{ID: "1"} + + // action. + errBaz := s.sut.store(baz) + errBar := s.sut.store(bar) + + // assert. + s.NoError(errBaz) + actualBaz, ok := s.sut.Load(TypeNameOf(baz), baz.ID()) + s.True(ok) + s.Equal(baz, actualBaz) + s.NoError(errBar) + actualBar, ok := s.sut.Load(TypeNameOf(bar), bar.Identifier()) + s.True(ok) + s.Equal(bar, actualBar) +} + +func (s *UnitCacheTestSuite) TestUnitCache_Store_SameID() { + // arrange. + baz := test.Baz{Identifier: "1"} + bar := test.Bar{ID: "1"} + + // action. + errBaz := s.sut.store(baz) + errBar := s.sut.store(bar) + + // assert. + s.NoError(errBaz) + actualBaz, ok := s.sut.Load(TypeNameOf(baz), baz.ID()) + s.True(ok) + s.Equal(baz, actualBaz) + s.NoError(errBar) + actualBar, ok := s.sut.Load(TypeNameOf(bar), bar.Identifier()) + s.True(ok) + s.Equal(bar, actualBar) +} + +func (s *UnitCacheTestSuite) TestUnitCache_Store_UncachableEntityError() { + // arrange. + biz := test.Biz{Identifier: "1"} + + // action. + err := s.sut.store(biz) + + // assert. + s.Error(err) + s.ErrorIs(err, ErrUncachableEntity) +} diff --git a/v4/unit_options.go b/v4/unit_options.go index eb5db9b..38eb9f4 100644 --- a/v4/unit_options.go +++ b/v4/unit_options.go @@ -12,6 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package work import ( diff --git a/v4/unit_test.go b/v4/unit_test.go index ad5082a..9b03a52 100644 --- a/v4/unit_test.go +++ b/v4/unit_test.go @@ -21,6 +21,7 @@ import ( "github.com/freerware/work/v4" "github.com/freerware/work/v4/internal/mock" + "github.com/freerware/work/v4/internal/test" "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" "github.com/uber-go/tally" @@ -48,16 +49,22 @@ func TestUnitTestSuite(t *testing.T) { func (s *UnitTestSuite) SetupTest() { // test entities. - foo := Foo{ID: 28} + foo := test.Foo{ID: 28} fooTypeName := work.TypeNameOf(foo) - bar := Bar{ID: "28"} + bar := test.Bar{ID: "28"} barTypeName := work.TypeNameOf(bar) + baz := test.Baz{Identifier: "28"} + bazTypeName := work.TypeNameOf(baz) + biz := test.Biz{Identifier: "28"} + bizTypeName := work.TypeNameOf(biz) // initialize mocks. s.mc = gomock.NewController(s.T()) s.mappers = make(map[work.TypeName]*mock.DataMapper) s.mappers[fooTypeName] = mock.NewDataMapper(s.mc) s.mappers[barTypeName] = mock.NewDataMapper(s.mc) + s.mappers[bizTypeName] = mock.NewDataMapper(s.mc) + s.mappers[bazTypeName] = mock.NewDataMapper(s.mc) // construct SUT. dm := make(map[work.TypeName]work.DataMapper) @@ -114,10 +121,10 @@ func (s *UnitTestSuite) TestUnit_Add_MissingDataMapper() { // arrange. entities := []interface{}{ - Foo{ID: 28}, + test.Foo{ID: 28}, } mappers := map[work.TypeName]work.DataMapper{ - work.TypeNameOf(Bar{}): &mock.DataMapper{}, + work.TypeNameOf(test.Bar{}): &mock.DataMapper{}, } var err error opts := []work.UnitOption{work.UnitDataMappers(mappers)} @@ -135,8 +142,8 @@ func (s *UnitTestSuite) TestUnit_Add() { // arrange. entities := []interface{}{ - Foo{ID: 28}, - Bar{ID: "28"}, + test.Foo{ID: 28}, + test.Bar{ID: "28"}, } // action. @@ -149,8 +156,8 @@ func (s *UnitTestSuite) TestUnit_Add() { func (s *UnitTestSuite) TestUnit_ConcurrentAdd() { // arrange. - foo := Foo{ID: 28} - bar := Bar{ID: "28"} + foo := test.Foo{ID: 28} + bar := test.Bar{ID: "28"} // action. var err, err2 error @@ -187,10 +194,10 @@ func (s *UnitTestSuite) TestUnit_Alter_MissingDataMapper() { // arrange. entities := []interface{}{ - Foo{ID: 28}, + test.Foo{ID: 28}, } mappers := map[work.TypeName]work.DataMapper{ - work.TypeNameOf(Bar{}): &mock.DataMapper{}, + work.TypeNameOf(test.Bar{}): &mock.DataMapper{}, } var err error opts := []work.UnitOption{work.UnitDataMappers(mappers)} @@ -208,8 +215,8 @@ func (s *UnitTestSuite) TestUnit_Alter() { // arrange. entities := []interface{}{ - Foo{ID: 28}, - Bar{ID: "28"}, + test.Foo{ID: 28}, + test.Bar{ID: "28"}, } // action. @@ -222,8 +229,8 @@ func (s *UnitTestSuite) TestUnit_Alter() { func (s *UnitTestSuite) TestUnit_ConcurrentAlter() { // arrange. - foo := Foo{ID: 28} - bar := Bar{ID: "28"} + foo := test.Foo{ID: 28} + bar := test.Bar{ID: "28"} // action. var err, err2 error @@ -260,10 +267,10 @@ func (s *UnitTestSuite) TestUnit_Remove_MissingDataMapper() { // arrange. entities := []interface{}{ - Bar{ID: "28"}, + test.Bar{ID: "28"}, } mappers := map[work.TypeName]work.DataMapper{ - work.TypeNameOf(Foo{}): &mock.DataMapper{}, + work.TypeNameOf(test.Foo{}): &mock.DataMapper{}, } var err error opts := []work.UnitOption{work.UnitDataMappers(mappers)} @@ -281,8 +288,8 @@ func (s *UnitTestSuite) TestUnit_Remove() { // arrange. entities := []interface{}{ - Foo{ID: 28}, - Bar{ID: "28"}, + test.Foo{ID: 28}, + test.Bar{ID: "28"}, } // action. @@ -295,8 +302,8 @@ func (s *UnitTestSuite) TestUnit_Remove() { func (s *UnitTestSuite) TestUnit_ConcurrentRemove() { // arrange. - foo := Foo{ID: 28} - bar := Bar{ID: "28"} + foo := test.Foo{ID: 28} + bar := test.Bar{ID: "28"} // action. var err, err2 error @@ -333,10 +340,10 @@ func (s *UnitTestSuite) TestUnit_Register_MissingDataMapper() { // arrange. entities := []interface{}{ - Bar{ID: "28"}, + test.Bar{ID: "28"}, } mappers := map[work.TypeName]work.DataMapper{ - work.TypeNameOf(Foo{}): &mock.DataMapper{}, + work.TypeNameOf(test.Foo{}): &mock.DataMapper{}, } var err error opts := []work.UnitOption{work.UnitDataMappers(mappers)} @@ -355,8 +362,8 @@ func (s *UnitTestSuite) TestUnit_Register() { // arrange. entities := []interface{}{ - Foo{ID: 28}, - Bar{ID: "28"}, + test.Foo{ID: 28}, + test.Biz{Identifier: "28"}, } // action. @@ -369,8 +376,8 @@ func (s *UnitTestSuite) TestUnit_Register() { func (s *UnitTestSuite) TestUnit_ConcurrentRegister() { // arrange. - foo := Foo{ID: 28} - bar := Bar{ID: "28"} + foo := test.Foo{ID: 28} + bar := test.Bar{ID: "28"} // action. var err, err2 error @@ -391,6 +398,60 @@ func (s *UnitTestSuite) TestUnit_ConcurrentRegister() { s.NoError(err2) } +func (s *UnitTestSuite) TestUnit_Cache() { + // arrange. + foo := test.Foo{ID: 28} + baz := test.Baz{Identifier: "28"} + s.sut.Register(foo, baz) + + // action. + cached := s.sut.Cached() + + // assert. + cachedFoo, foundFoo := cached.Load(work.TypeNameOf(foo), foo.ID) + s.True(foundFoo) + s.Equal(foo, cachedFoo) + cachedBaz, foundBaz := cached.Load(work.TypeNameOf(baz), baz.Identifier) + s.True(foundBaz) + s.Equal(baz, cachedBaz) +} + +func (s *UnitTestSuite) TestUnit_Remove_InvalidatesCache() { + // arrange. + foo := test.Foo{ID: 28} + baz := test.Baz{Identifier: "28"} + s.sut.Register(foo, baz) + + // action. + err := s.sut.Remove(foo) + + // assert. + s.NoError(err) + cached := s.sut.Cached() + _, foundFoo := cached.Load(work.TypeNameOf(foo), foo.ID) + s.False(foundFoo) + _, foundBaz := cached.Load(work.TypeNameOf(baz), baz.Identifier) + s.True(foundBaz) +} + +func (s *UnitTestSuite) TestUnit_Alter_InvalidatesCache() { + // arrange. + foo := test.Foo{ID: 28} + baz := test.Baz{Identifier: "28"} + s.sut.Register(foo, baz) + + // action. + err := s.sut.Alter(foo) + + // assert. + s.NoError(err) + cached := s.sut.Cached() + _, foundFoo := cached.Load(work.TypeNameOf(foo), foo.ID) + s.False(foundFoo) + _, foundBaz := cached.Load(work.TypeNameOf(baz), baz.Identifier) + s.True(foundBaz) +} + func (s *UnitTestSuite) TearDownTest() { s.sut = nil s.mc.Finish() diff --git a/v4/uniter_test.go b/v4/uniter_test.go index 67c295e..599ab2b 100644 --- a/v4/uniter_test.go +++ b/v4/uniter_test.go @@ -22,6 +22,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/freerware/work/v4" "github.com/freerware/work/v4/internal/mock" + "github.com/freerware/work/v4/internal/test" "github.com/stretchr/testify/suite" ) @@ -44,9 +45,9 @@ func TestUniterTestSuite(t *testing.T) { func (s *UniterTestSuite) SetupTest() { // test entities. - foo := Foo{ID: 28} + foo := test.Foo{ID: 28} fooTypeName := work.TypeNameOf(foo) - bar := Bar{ID: "28"} + bar := test.Bar{ID: "28"} barTypeName := work.TypeNameOf(bar) // initialize mocks. diff --git a/v4/work_test.go b/v4/work_test.go index f4eaca9..5079508 100644 --- a/v4/work_test.go +++ b/v4/work_test.go @@ -17,32 +17,8 @@ package work_test import ( "context" - - "github.com/freerware/work/v4/unit" ) -type NoOpDataMapper struct{} - -func (dm NoOpDataMapper) Insert(ctx context.Context, mCtx unit.MapperContext, e ...interface{}) error { - return nil -} - -func (dm NoOpDataMapper) Update(ctx context.Context, mCtx unit.MapperContext, e ...interface{}) error { - return nil -} - -func (dm NoOpDataMapper) Delete(ctx context.Context, mCtx unit.MapperContext, e ...interface{}) error { - return nil -} - -type Foo struct { - ID int -} - -type Bar struct { - ID string -} - type TableDrivenTest struct { name string registers []interface{}