activerecord-slotted_counters 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +22 -0
- data/README.md +93 -0
- data/lib/activerecord-slotted_counters.rb +7 -0
- data/lib/activerecord_slotted_counters/has_slotted_counter.rb +110 -0
- data/lib/activerecord_slotted_counters/railtie.rb +16 -0
- data/lib/activerecord_slotted_counters/utils.rb +22 -0
- data/lib/activerecord_slotted_counters/version.rb +5 -0
- data/lib/generators/slotted_counters/install_generator.rb +21 -0
- data/lib/generators/slotted_counters/templates/migration.rb.tt +15 -0
- metadata +143 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8f3309ca0e6671a6dbb844a5e3c7e8f0bf289b6125e9cbbed9d8234ed2afb596
|
4
|
+
data.tar.gz: be5e21d8aea9e6dc73a0150bed7cf13f349a6af7dc71cbe38cc86bd0ed785e3c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5ec60f4a1eaeff23861b5c1fe7ba6ea2ab866097f2f3d2ced182fcf36c82b34d402a536f37567423f3cdb4b9fe7baa5ca3d434d99fe2235adaeb30ebc0348493
|
7
|
+
data.tar.gz: fcee85f67d01bc7a4938fc7b261962157ee96d7db69e65003f9091fcffc9d6776fc3b905f6efb6516aeeaa95ae2ac104826671b00e030948f90675dcee4f20c8
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2022 Evil Martians
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
[![Gem Version](https://badge.fury.io/rb/activerecord-slotted_counters.svg)](https://rubygems.org/gems/activerecord-slotted_counters) [![Build](https://github.com/evilmartians/activerecord-slotted_counters/workflows/Build/badge.svg)](https://github.com/evilmartians/activerecord-slotted_counters/actions)
|
2
|
+
|
3
|
+
# Active Record slotted counters
|
4
|
+
|
5
|
+
This gem adds **slotted counters** support to [Active Record counter cache][counter-cache]. Slotted counters help to reduce contention on a single row update in case you many concurrent operations (like updating a page views counter during traffic spikes).
|
6
|
+
|
7
|
+
Read more about slotted counters in [this post](https://planetscale.com/blog/the-slotted-counter-pattern).
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add to your project:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
# Gemfile
|
15
|
+
gem "activerecord-slotted_counters"
|
16
|
+
```
|
17
|
+
|
18
|
+
### Supported Ruby versions
|
19
|
+
|
20
|
+
- Ruby (MRI) >= 2.7.0
|
21
|
+
|
22
|
+
## Usage
|
23
|
+
|
24
|
+
First, add and apply the required migration(-s):
|
25
|
+
|
26
|
+
```sh
|
27
|
+
bin/rails generate slotted_counters:install
|
28
|
+
bin/rails db:migrate
|
29
|
+
```
|
30
|
+
|
31
|
+
Then, add the following line to the model to add a slotted counter:
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
class User < ApplicationRecord
|
35
|
+
has_slotted_counter :comments
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
Now you can use all the common counter cache APIs as before:
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
# Manipulating the counter explicitly
|
43
|
+
user = User.first
|
44
|
+
|
45
|
+
User.increment_counter(:comments_count, user.id)
|
46
|
+
User.decrement_counter(:comments_count, user.id)
|
47
|
+
User.reset_counters(user.id, :comments)
|
48
|
+
# etc.
|
49
|
+
|
50
|
+
# Reading the value
|
51
|
+
user.comments_count
|
52
|
+
```
|
53
|
+
|
54
|
+
Under the hood, a row in the `slotted_counters` table is created associated with the record.
|
55
|
+
|
56
|
+
**NOTE:** Reading the current value performs SQL query once:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
user.comments_count #=> select count from slotted_counters where ...
|
60
|
+
user.comments_count #=> no sql
|
61
|
+
```
|
62
|
+
|
63
|
+
If you want to want preload counters for multiple records, you can use a convinient `#with_slotted_counters` method:
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
User.all.with_slotted_counters(:comments).find_each do
|
67
|
+
_1.comments_count #=> no sql
|
68
|
+
end
|
69
|
+
```
|
70
|
+
|
71
|
+
Using `counter_cache: true` on `belongs_to` associations also works as expected.
|
72
|
+
|
73
|
+
## Limitations / TODO
|
74
|
+
|
75
|
+
- Add `reset_counters` implementation
|
76
|
+
- Add `update_counters` implementation
|
77
|
+
- Add `with_slotted_counters` scope
|
78
|
+
- Add multiple `has_slotted_counter` support
|
79
|
+
- Rails 6 support
|
80
|
+
|
81
|
+
## Contributing
|
82
|
+
|
83
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/evilmartians/activerecord-slotted_counters](https://github.com/evilmartians/activerecord-slotted_counters).
|
84
|
+
|
85
|
+
## Credis
|
86
|
+
|
87
|
+
This gem is generated via [new-gem-generator](https://github.com/palkan/new-gem-generator).
|
88
|
+
|
89
|
+
## License
|
90
|
+
|
91
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
92
|
+
|
93
|
+
[counter-cache]: https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support"
|
4
|
+
require "activerecord_slotted_counters/utils"
|
5
|
+
|
6
|
+
module ActiveRecordSlottedCounters
|
7
|
+
class SlottedCounter < ::ActiveRecord::Base
|
8
|
+
scope :associated_records, ->(counter_name, id, klass) do
|
9
|
+
where(counter_name: counter_name, associated_record_id: id, associated_record_type: klass)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module HasSlottedCounter
|
14
|
+
extend ActiveSupport::Concern
|
15
|
+
include ActiveRecordSlottedCounters::Utils
|
16
|
+
|
17
|
+
# TODO setup in gem config
|
18
|
+
DEFAULT_MAX_SLOT_NUMBER = 100
|
19
|
+
|
20
|
+
SLOTTED_COUNTERS_ASSOCIATION_OPTIONS = {
|
21
|
+
class_name: "ActiveRecordSlottedCounters::SlottedCounter",
|
22
|
+
foreign_key: "associated_record_id"
|
23
|
+
}
|
24
|
+
|
25
|
+
class_methods do
|
26
|
+
include ActiveRecordSlottedCounters::Utils
|
27
|
+
|
28
|
+
def has_slotted_counter(counter_type)
|
29
|
+
counter_name = slotted_counter_name(counter_type)
|
30
|
+
association_name = slotted_counter_association_name(counter_type)
|
31
|
+
|
32
|
+
has_many association_name, **SLOTTED_COUNTERS_ASSOCIATION_OPTIONS
|
33
|
+
|
34
|
+
_slotted_counters << counter_type
|
35
|
+
|
36
|
+
define_method(counter_name) do
|
37
|
+
read_slotted_counter(counter_type)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def increment_counter(counter_name, id, touch: nil)
|
42
|
+
return super unless registered_slotted_counter? counter_name
|
43
|
+
|
44
|
+
insert_counter_record(counter_name, id, 1)
|
45
|
+
end
|
46
|
+
|
47
|
+
def decrement_counter(counter_name, id, touch: nil)
|
48
|
+
return super unless registered_slotted_counter? counter_name
|
49
|
+
|
50
|
+
insert_counter_record(counter_name, id, -1)
|
51
|
+
end
|
52
|
+
|
53
|
+
def slotted_counters
|
54
|
+
if superclass.respond_to?(:slotted_counters)
|
55
|
+
superclass.slotted_counters + _slotted_counters
|
56
|
+
else
|
57
|
+
_slotted_counters
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def _slotted_counters
|
64
|
+
@_slotted_counters ||= []
|
65
|
+
end
|
66
|
+
|
67
|
+
def registered_slotted_counter?(counter_name)
|
68
|
+
counter_type = slotted_counter_type(counter_name)
|
69
|
+
|
70
|
+
slotted_counters.include? counter_type
|
71
|
+
end
|
72
|
+
|
73
|
+
def insert_counter_record(counter_name, id, count)
|
74
|
+
slot = rand(DEFAULT_MAX_SLOT_NUMBER)
|
75
|
+
on_duplicate_clause = "count = slotted_counters.count + #{count}"
|
76
|
+
|
77
|
+
result = ActiveRecordSlottedCounters::SlottedCounter.upsert(
|
78
|
+
{
|
79
|
+
counter_name: counter_name,
|
80
|
+
associated_record_type: name,
|
81
|
+
associated_record_id: id,
|
82
|
+
slot: slot,
|
83
|
+
count: count
|
84
|
+
},
|
85
|
+
on_duplicate: Arel.sql(on_duplicate_clause),
|
86
|
+
unique_by: :index_slotted_counters
|
87
|
+
)
|
88
|
+
|
89
|
+
result.rows.count
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def read_slotted_counter(counter_type)
|
96
|
+
association_name = slotted_counter_association_name(counter_type)
|
97
|
+
|
98
|
+
if association_cached?(association_name)
|
99
|
+
scope = association(association_name).scope
|
100
|
+
counter = scope.sum(&:count)
|
101
|
+
|
102
|
+
return counter
|
103
|
+
end
|
104
|
+
|
105
|
+
counter_name = slotted_counter_name(counter_type)
|
106
|
+
scope = send(association_name).associated_records(counter_name, id, self.class.to_s)
|
107
|
+
scope.sum(:count)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordSlottedCounters # :nodoc:
|
4
|
+
class Railtie < ::Rails::Railtie # :nodoc:
|
5
|
+
config.app_generators do
|
6
|
+
# TODO can I use slotted_counters:install naming without relative import?
|
7
|
+
require_relative "../../lib/generators/slotted_counters/install_generator"
|
8
|
+
end
|
9
|
+
|
10
|
+
initializer "extend ActiveRecord with ActiveRecordSlottedCounters" do |_app|
|
11
|
+
ActiveSupport.on_load(:active_record) do
|
12
|
+
ActiveRecord::Base.include ActiveRecordSlottedCounters::HasSlottedCounter
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support"
|
4
|
+
|
5
|
+
module ActiveRecordSlottedCounters
|
6
|
+
module Utils
|
7
|
+
private
|
8
|
+
|
9
|
+
def slotted_counter_association_name(counter_type)
|
10
|
+
"#{counter_type}_slotted_counters".to_sym
|
11
|
+
end
|
12
|
+
|
13
|
+
def slotted_counter_name(counter_type)
|
14
|
+
"#{counter_type}_count".to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
# TODO refactoring
|
18
|
+
def slotted_counter_type(counter_name)
|
19
|
+
counter_name.to_s.split("_")[0..-2].join("_").to_sym
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module SlottedCounters
|
7
|
+
module Generators
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
9
|
+
include ActiveRecord::Generators::Migration
|
10
|
+
source_root File.join(__dir__, "templates")
|
11
|
+
|
12
|
+
def copy_migration
|
13
|
+
migration_template "migration.rb", "db/migrate/create_slotted_counters.rb", migration_version: migration_version
|
14
|
+
end
|
15
|
+
|
16
|
+
def migration_version
|
17
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def change
|
3
|
+
create_table :slotted_counters do |t|
|
4
|
+
t.string :counter_name, null: false
|
5
|
+
t.string :associated_record_type, null: false
|
6
|
+
t.integer :associated_record_id, null: false
|
7
|
+
t.integer :slot, null: false
|
8
|
+
t.integer :count, null: false
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index :slotted_counters, [:associated_record_id, :associated_record_type, :counter_name, :slot], unique: true, name: 'index_slotted_counters'
|
14
|
+
end
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord-slotted_counters
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Egor Lukin
|
8
|
+
- Vladimir Dementyev
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2022-09-05 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activerecord
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '6.1'
|
21
|
+
type: :runtime
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '6.1'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: bundler
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - ">="
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '1.15'
|
35
|
+
type: :development
|
36
|
+
prerelease: false
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '1.15'
|
42
|
+
- !ruby/object:Gem::Dependency
|
43
|
+
name: rake
|
44
|
+
requirement: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '13.0'
|
49
|
+
type: :development
|
50
|
+
prerelease: false
|
51
|
+
version_requirements: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '13.0'
|
56
|
+
- !ruby/object:Gem::Dependency
|
57
|
+
name: rspec
|
58
|
+
requirement: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '3.9'
|
63
|
+
type: :development
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '3.9'
|
70
|
+
- !ruby/object:Gem::Dependency
|
71
|
+
name: pg
|
72
|
+
requirement: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '1.4'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '1.4'
|
84
|
+
- !ruby/object:Gem::Dependency
|
85
|
+
name: sqlite3
|
86
|
+
requirement: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
type: :development
|
92
|
+
prerelease: false
|
93
|
+
version_requirements: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
description: Active Record slotted counters support
|
99
|
+
email:
|
100
|
+
- dementiev.vm@gmail.com
|
101
|
+
executables: []
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files: []
|
104
|
+
files:
|
105
|
+
- CHANGELOG.md
|
106
|
+
- LICENSE.txt
|
107
|
+
- README.md
|
108
|
+
- lib/activerecord-slotted_counters.rb
|
109
|
+
- lib/activerecord_slotted_counters/has_slotted_counter.rb
|
110
|
+
- lib/activerecord_slotted_counters/railtie.rb
|
111
|
+
- lib/activerecord_slotted_counters/utils.rb
|
112
|
+
- lib/activerecord_slotted_counters/version.rb
|
113
|
+
- lib/generators/slotted_counters/install_generator.rb
|
114
|
+
- lib/generators/slotted_counters/templates/migration.rb.tt
|
115
|
+
homepage: http://github.com/evilmartians/activerecord-slotted_counters
|
116
|
+
licenses:
|
117
|
+
- MIT
|
118
|
+
metadata:
|
119
|
+
bug_tracker_uri: http://github.com/evilmartians/activerecord-slotted_counters/issues
|
120
|
+
changelog_uri: https://github.com/evilmartians/activerecord-slotted_counters/blob/master/CHANGELOG.md
|
121
|
+
documentation_uri: http://github.com/evilmartians/activerecord-slotted_counters
|
122
|
+
homepage_uri: http://github.com/evilmartians/activerecord-slotted_counters
|
123
|
+
source_code_uri: http://github.com/evilmartians/activerecord-slotted_counters
|
124
|
+
post_install_message:
|
125
|
+
rdoc_options: []
|
126
|
+
require_paths:
|
127
|
+
- lib
|
128
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
129
|
+
requirements:
|
130
|
+
- - ">="
|
131
|
+
- !ruby/object:Gem::Version
|
132
|
+
version: '2.7'
|
133
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
134
|
+
requirements:
|
135
|
+
- - ">="
|
136
|
+
- !ruby/object:Gem::Version
|
137
|
+
version: '0'
|
138
|
+
requirements: []
|
139
|
+
rubygems_version: 3.3.11
|
140
|
+
signing_key:
|
141
|
+
specification_version: 4
|
142
|
+
summary: Active Record slotted counters support
|
143
|
+
test_files: []
|