royal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1ae44a417ca2d8441ecd326e7d93fb50df992802a3857bff8b1e71e487b43600
4
+ data.tar.gz: 352ce9d947cfbe551e93dbce55d6491d75d7948b409d6d4f340866c2f41e4513
5
+ SHA512:
6
+ metadata.gz: ae6451b26677cea399826b945967c251b2440a7bf9231050bff5d26869d86b4a604e5cf39c63cc59a23378ae148d018a7c0e56ed0ba07ec3d8110a4437ff4dac
7
+ data.tar.gz: 5578bcba479a0ab03cb11831dbdc18b28e1253c020224a382e293e6e31adba2c98a8f52b530d51f857c18c6a04d521f9512e642b48f1353754da8358fd591af3
@@ -0,0 +1,70 @@
1
+ name: Ruby
2
+
3
+ on: [push,pull_request]
4
+
5
+ jobs:
6
+ rubocop:
7
+ name: Rubocop
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v2
11
+ - name: Set up Ruby
12
+ uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: 3.0
15
+ bundler-cache: true
16
+ - name: Run Rubocop
17
+ run: bin/rubocop
18
+
19
+ rspec-sqlite:
20
+ name: RSpec (SQLite3)
21
+ runs-on: ubuntu-latest
22
+ strategy:
23
+ matrix:
24
+ ruby-version: [2.6, 2.7, 3.0, 3.1]
25
+ locking-mode: [optimistic, pessimistic]
26
+ steps:
27
+ - uses: actions/checkout@v2
28
+ - name: Set up Ruby ${{ matrix.ruby-version }}
29
+ uses: ruby/setup-ruby@v1
30
+ with:
31
+ ruby-version: ${{ matrix.ruby-version }}
32
+ bundler-cache: true
33
+ - name: Run RSpec (${{ matrix.locking-mode }} locking)
34
+ env:
35
+ TEST_LOCKING_MODE: ${{ matrix.locking-mode }}
36
+ TEST_DATABASE_ADAPTER: sqlite3
37
+ run: bin/rspec
38
+
39
+ rspec-postgresql:
40
+ name: RSpec (PostgreSQL)
41
+ runs-on: ubuntu-latest
42
+ strategy:
43
+ matrix:
44
+ ruby-version: [2.6, 2.7, 3.0, 3.1]
45
+ locking-mode: [optimistic, pessimistic, advisory]
46
+ services:
47
+ postgres:
48
+ image: postgres:13
49
+ ports:
50
+ - 5432:5432
51
+ env:
52
+ POSTGRES_USER: postgres
53
+ POSTGRES_PASSWORD: postgres
54
+ POSTGRES_DB: royal_test
55
+ options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
56
+ steps:
57
+ - uses: actions/checkout@v2
58
+ - name: Set up Ruby ${{ matrix.ruby-version }}
59
+ uses: ruby/setup-ruby@v1
60
+ with:
61
+ ruby-version: ${{ matrix.ruby-version }}
62
+ bundler-cache: true
63
+ - name: Run RSpec (${{ matrix.locking-mode }} locking)
64
+ env:
65
+ PGHOST: localhost
66
+ PGUSER: postgres
67
+ PGPASSWORD: postgres
68
+ TEST_LOCKING_MODE: ${{ matrix.locking-mode }}
69
+ TEST_DATABASE_ADAPTER: postgresql
70
+ run: bin/rspec
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /.rspec_status
data/.rubocop.yml ADDED
@@ -0,0 +1,53 @@
1
+ require:
2
+ - rubocop-performance
3
+ - rubocop-rails
4
+ - rubocop-rspec
5
+
6
+ AllCops:
7
+ NewCops: enable
8
+ SuggestExtensions: false
9
+ TargetRubyVersion: 2.6
10
+ Exclude:
11
+ - bin/*
12
+ - vendor/**/*
13
+
14
+ Layout/AccessModifierIndentation:
15
+ EnforcedStyle: outdent
16
+
17
+ Layout/LineLength:
18
+ Max: 120
19
+
20
+ Lint/AmbiguousBlockAssociation:
21
+ Exclude:
22
+ - spec/**/*.rb
23
+
24
+ Metrics/BlockLength:
25
+ Exclude:
26
+ - spec/**/*.rb
27
+
28
+ Naming/RescuedExceptionsVariableName:
29
+ PreferredName: error
30
+
31
+ Rails/ApplicationRecord:
32
+ Enabled: false
33
+
34
+ RSpec/ExpectChange:
35
+ EnforcedStyle: block
36
+
37
+ RSpec/HookArgument:
38
+ EnforcedStyle: each
39
+
40
+ Style/Documentation:
41
+ Enabled: false
42
+
43
+ Style/RescueModifier:
44
+ Exclude:
45
+ - spec/**/*.rb
46
+
47
+ Style/StringLiterals:
48
+ Enabled: true
49
+ EnforcedStyle: single_quotes
50
+
51
+ Style/StringLiteralsInInterpolation:
52
+ Enabled: true
53
+ EnforcedStyle: single_quotes
@@ -0,0 +1,12 @@
1
+ {
2
+ "[ruby]": {
3
+ "editor.tabSize": 2
4
+ },
5
+ "ruby.rubocop.useBundler": true,
6
+ "cSpell.words": [
7
+ "hashtext",
8
+ "pointable",
9
+ "rubygems",
10
+ "xact"
11
+ ]
12
+ }
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in royal.gemspec
6
+ gemspec
7
+
8
+ gem 'rake', '~> 13.0'
9
+ gem 'rspec', '~> 3.0'
10
+ gem 'rubocop', '~> 1.25'
11
+ gem 'rubocop-performance', '~> 1.12'
12
+ gem 'rubocop-rails', '~> 2.13'
13
+ gem 'rubocop-rspec', '~> 2.8'
14
+
15
+ gem 'pg', '~> 1.3'
16
+ gem 'sqlite3', '~> 1.4'
data/Gemfile.lock ADDED
@@ -0,0 +1,92 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ royal (0.1.0)
5
+ activerecord (>= 5, < 8)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ activemodel (6.1.4.1)
11
+ activesupport (= 6.1.4.1)
12
+ activerecord (6.1.4.1)
13
+ activemodel (= 6.1.4.1)
14
+ activesupport (= 6.1.4.1)
15
+ activesupport (6.1.4.1)
16
+ concurrent-ruby (~> 1.0, >= 1.0.2)
17
+ i18n (>= 1.6, < 2)
18
+ minitest (>= 5.1)
19
+ tzinfo (~> 2.0)
20
+ zeitwerk (~> 2.3)
21
+ ast (2.4.2)
22
+ concurrent-ruby (1.1.9)
23
+ diff-lcs (1.4.4)
24
+ i18n (1.8.11)
25
+ concurrent-ruby (~> 1.0)
26
+ minitest (5.15.0)
27
+ parallel (1.21.0)
28
+ parser (3.1.0.0)
29
+ ast (~> 2.4.1)
30
+ pg (1.3.1)
31
+ rack (2.2.3)
32
+ rainbow (3.1.1)
33
+ rake (13.0.6)
34
+ regexp_parser (2.2.0)
35
+ rexml (3.2.5)
36
+ rspec (3.10.0)
37
+ rspec-core (~> 3.10.0)
38
+ rspec-expectations (~> 3.10.0)
39
+ rspec-mocks (~> 3.10.0)
40
+ rspec-core (3.10.1)
41
+ rspec-support (~> 3.10.0)
42
+ rspec-expectations (3.10.1)
43
+ diff-lcs (>= 1.2.0, < 2.0)
44
+ rspec-support (~> 3.10.0)
45
+ rspec-mocks (3.10.2)
46
+ diff-lcs (>= 1.2.0, < 2.0)
47
+ rspec-support (~> 3.10.0)
48
+ rspec-support (3.10.3)
49
+ rubocop (1.25.0)
50
+ parallel (~> 1.10)
51
+ parser (>= 3.1.0.0)
52
+ rainbow (>= 2.2.2, < 4.0)
53
+ regexp_parser (>= 1.8, < 3.0)
54
+ rexml
55
+ rubocop-ast (>= 1.15.1, < 2.0)
56
+ ruby-progressbar (~> 1.7)
57
+ unicode-display_width (>= 1.4.0, < 3.0)
58
+ rubocop-ast (1.15.1)
59
+ parser (>= 3.0.1.1)
60
+ rubocop-performance (1.12.0)
61
+ rubocop (>= 1.7.0, < 2.0)
62
+ rubocop-ast (>= 0.4.0)
63
+ rubocop-rails (2.13.2)
64
+ activesupport (>= 4.2.0)
65
+ rack (>= 1.1)
66
+ rubocop (>= 1.7.0, < 2.0)
67
+ rubocop-rspec (2.8.0)
68
+ rubocop (~> 1.19)
69
+ ruby-progressbar (1.11.0)
70
+ sqlite3 (1.4.2)
71
+ tzinfo (2.0.4)
72
+ concurrent-ruby (~> 1.0)
73
+ unicode-display_width (2.1.0)
74
+ zeitwerk (2.5.3)
75
+
76
+ PLATFORMS
77
+ x86_64-darwin-19
78
+ x86_64-linux
79
+
80
+ DEPENDENCIES
81
+ pg (~> 1.3)
82
+ rake (~> 13.0)
83
+ royal!
84
+ rspec (~> 3.0)
85
+ rubocop (~> 1.25)
86
+ rubocop-performance (~> 1.12)
87
+ rubocop-rails (~> 2.13)
88
+ rubocop-rspec (~> 2.8)
89
+ sqlite3 (~> 1.4)
90
+
91
+ BUNDLED WITH
92
+ 2.2.22
data/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # Royal
2
+
3
+ Royal adds loyalty (and other types of) points to your ActiveRecord models.
4
+ It's backed by an internal ledger for balance tracking, provides a simple programmer interface, and is designed to handle concurrency and locking for you.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'royal'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ $ bundle install
17
+
18
+ Or install it yourself as:
19
+
20
+ $ gem install royal
21
+
22
+ ### Generating Configuration
23
+
24
+ Generate the configuration file with:
25
+
26
+ $ bin/rails g royal:install
27
+
28
+ This will place a configuration file at `config/initializers/royal.rb` in your Rails application directory.
29
+
30
+ ### Generating Migrations
31
+
32
+ Generate the migration file with:
33
+
34
+ $ bin/rails g royal:migration
35
+
36
+ And then run the migration:
37
+
38
+ $ bin/rails db:migrate
39
+
40
+ ## Usage
41
+
42
+ To add a points balance to any of your ActiveRecord models, include the `Royal::Points` module.
43
+ For the purposes of the examples below, we'll be using a hypothetical `User` model:
44
+
45
+ ```ruby
46
+ class User < ApplicationRecord
47
+ include Royal::Points
48
+
49
+ # ...
50
+ end
51
+ ```
52
+
53
+ Royal uses a separate ledger table to track points for your models, so it's not necessary to add any columns to existing tables.
54
+
55
+ ### Viewing the Point Balance
56
+
57
+ To view the current balance of a record, use the `current_points` method:
58
+
59
+ ```ruby
60
+ user = User.first
61
+
62
+ user.current_points # => 0
63
+ ```
64
+
65
+ **NOTE:** The `current_points` method accesses the database each time it's called.
66
+
67
+ ### Adding Loyalty Points
68
+
69
+ ```ruby
70
+ user.add_points(100) # => 100
71
+ ```
72
+
73
+ This method returns the new points balance after the operation.
74
+
75
+ ### Spending Loyalty Points
76
+
77
+ ```ruby
78
+ user.subtract_points(75) # => 25
79
+ ```
80
+
81
+ This method returns the new points balance after the operation.
82
+
83
+ If the current balance is less than the specified amount of points to subtract, a `Royal::InsufficientPointsError` is raised:
84
+
85
+ ```ruby
86
+ user.subtract_points(200) # => raises #<Royal::InsufficientPointsError ...>
87
+ ```
88
+
89
+ ### Including a Reason or Note
90
+
91
+ It's possible to include a String of text when adding or subtracting points, which will be stored with the change:
92
+
93
+ ```ruby
94
+ user.add_points(50, reason: 'Birthday points!')
95
+ ```
96
+
97
+ **NOTE:** By default, the reason can be at most 1000 characters long.
98
+
99
+ ### Linking to Another Record
100
+
101
+ It's possible to link an additional record when adding or subtracting points.
102
+ Below is an example using a hypothetical `Reward` model:
103
+
104
+ ```ruby
105
+ reward = Reward.find_by(name: 'Gift Card')
106
+
107
+ user.subtract_points(50, pointable: reward)
108
+ ```
109
+
110
+ ### Loyalty Point Balance History
111
+
112
+ Since points are stored in a ledger, it's possible to view and display the complete history of a points balance:
113
+
114
+ ```erb
115
+ <table>
116
+ <tr>
117
+ <th>Operation</th>
118
+ <th>Balance</th>
119
+ <th>Reason</th>
120
+ </tr>
121
+ <% user.point_balances.order(:sequence).each do |point_balance| %>
122
+ <tr>
123
+ <td><%= point_balance.amount.positive? ? 'Added' : 'Spent' %> <%= point_balance.amount %></td>
124
+ <td><%= point_balance.balance %></td>
125
+ <td><%= point_balance.reason %></td>
126
+ </tr>
127
+ <% end %>
128
+ </table>
129
+ ```
130
+
131
+ **NOTE:** For data integrity purposes, the `owner`, `amount`, `balance`, and `sequence` attributes on `Royal::PointBalance` records are as marked readonly once persisted to the database. Modifying the fields or removing existing balance records is not supported.
132
+
133
+ ## Concurrency and Locking
134
+
135
+ Royal comes with built-in automatic locking around point balance changes.
136
+ Since each applicable behaves differently, it also includes a few alternative locking strategies.
137
+
138
+ Royal supports three possible locking modes:
139
+ * Optimistic locking (default)
140
+ * Pessimistic locking
141
+ * Advisory locking (PosgreSQL only)
142
+
143
+ Each mode has its own advantages and disadvantages, which are outlined in detail below.
144
+ Optimistic locking is used by default and should work well in most use cases and even in high-load applications.
145
+ In general, you shouldn't need to change this unless you start to experience `SequenceError`s.
146
+
147
+ ### Optimistic Locking (Default)
148
+
149
+ This locking mode relies on a unique index specific to `owner` record such that: `(owner_id, owner_type, sequence)`.
150
+ A `sequence` column is increased by 1 for every write to the points balance ledger, so any conflicting writes to the table would produce a duplicate value for the `sequence` column and be rejected by the uniqueness constraint.
151
+
152
+ Whenever an update is rejected by this constraint, the operation is re-attempted with a new sequence value and updated balance calculation.
153
+ By default, an update can be re-attempted up to 10 times before a `Royal::SequenceError` is raised.
154
+
155
+ For most applications, updates should only be very rarely retried and generally resolve it an most one or two attempts.
156
+ However, if your application needs to perform frequent, parallel balance updates for a single record, this may become a concern.
157
+ (For example, an organization that receives points from all of its employees, or some other group that receives points from all of its members.)
158
+
159
+ **NOTE:** You shouldn't usually need to change locking strategies unless you see frequent `SequenceError`s or otherwise experience poor performance.
160
+ Even in cases where your application does perform a large number of parallel balance updates for the same record, you may see better results changing how your application applies these updates. (e.g. Using a queue to asynchronously apply updates in sequence from a separate task instead of in parallel.)
161
+
162
+ ### Pessimistic Locking
163
+
164
+ This locking mode relies on row-level exclusive locks acquired on the `owner` record for the points balance.
165
+
166
+ In general, this strategy is the slowest as it will be blocked by any other locks on the record, and will block all other operations on the record for the duration of the update.
167
+ Unless you are experiencing issues with the optimistic locking strategy and are unable to leverage advisory locks for your applicable, use of the strategy is not advised.
168
+
169
+ ### Advisory Locking (PostgreSQL Only)
170
+
171
+ This locking mode uses an exclusive advisory lock on a key that's unique to each `owner` record via `pg_advisory_xact_lock`.
172
+ See more here: https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
173
+
174
+ Using advisory locking provides similar advantages to pessimistic locking without having to acquire a row-level lock on the owner record.
175
+ Locking is done using the 2-argument version of `pg_advisory_xact_lock` using values computed based from their `owner` record's ID and polymorphic name.
176
+
177
+ e.g. A lock for a `User` record with ID 1 would acquire a lock as so:
178
+ ```sql
179
+ SELECT pg_advisory_xact_lock(hashtext('User'), 1)
180
+ ```
181
+
182
+ When using advisory locks, there are situations where lock-keys could potentially experience collisions.
183
+ Since advisory lock keys are 64-bits wide, it is unlikely to happen for most applications, but is still worth considering.
184
+
185
+ Two possible situations where these keys would collide are:
186
+ 1. Your applicable already heavily relies on advisory locks. Note that the single 64-bit argument advisory lock uses a separate key space and does not overlap with the 2-argument key space.
187
+ 2. Your models have 64-bit (or greater) IDs and the bottom 32-bits are the same. Since the 2 key segments are restricted to 32-bits, this locking system only uses the bottom 32-bits of the `owner` record's ID as part of the lock key.
188
+
189
+ In most cases, lock-keys colliding would just result in two or more unrelated records waiting before applying a point balance update.
190
+ Unless your applicable has a very large number of `owner` records with overlapping keys that are frequently updated together, this shouldn't create any cause for concern.
191
+
192
+ However, if your application falls into situation #1 described above and also holds advisory locks for extended periods of time, balance updates may stall for much longer than expected as they'll be blocked until the lock is released.
193
+
194
+ ### Transactions and Deadlock Prevention
195
+
196
+ When adding or subtracting points from a single record, Royal automatically handles locking and usually poses no risk of deadlock (as locks are only acquired for a single record).
197
+ However, when changing the points balances of multiple records together, care must be taken to avoid conditions that would result in a stall or deadlock.
198
+
199
+ To make this easier, Royal providers a `Transaction` object which can be used to define and safely execute a sequence on points balance changes on a number of objects all at once.
200
+ In the example below, we transfer 100 points from one user to another:
201
+
202
+ ```ruby
203
+ transaction = Royal::Transaction.new
204
+
205
+ user1 = User.find(1)
206
+ user2 = User.find(2)
207
+
208
+ transaction.add_points(user1, 100)
209
+ transaction.subtract_points(user2, 100)
210
+
211
+ transaction.call
212
+ ```
213
+
214
+ Transactions have no limit on how many records can be involved or how many operations can be performed on each record. For example, it's possible to have a single transaction both add and subtract points from the same `User` record.
215
+
216
+ Royal will re-order the addition/subtraction operations in such a way that:
217
+ * Records will be modified in a deterministic order, to avoid deadlocks
218
+ * If a single record has both addition and subtraction operations, addition will be performed first
219
+
220
+ Just like when adding or subtracting points from a single record, the transaction interface allows you to specify a `reason` or associated `pointable` record.
221
+
222
+ ```ruby
223
+ trade = Trade.find(...) # Some hypothetical trade record.
224
+
225
+ transaction.add_points(user1, 100, reason: 'Trade from User 2', pointable: trade)
226
+ transaction.subtract_points(user2, 100, reason: 'Trade to User 1', pointable: trade)
227
+ ```
228
+
229
+ The transaction `add_points` and `subtract_points` methods also return the transaction object, so it's also possible to chain these method calls together if desired:
230
+
231
+ ```ruby
232
+ transaction
233
+ .add_points(user1, 100)
234
+ .subtract_points(user2, 100)
235
+ .call
236
+ ```
237
+
238
+ ## Future Functionality and Wishlist
239
+
240
+ * [ ] Support multiple types of points per model
241
+ * [ ] Add RSpec matchers to assist in testing
242
+ * [ ] Improve documentation and examples
243
+
244
+ ## Development
245
+
246
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
247
+
248
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
249
+
250
+ ## Contributing
251
+
252
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mintyfresh/royal.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rubocop/rake_task'
5
+
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: :rubocop
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'royal'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/rspec ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rspec-core", "rspec")
data/bin/rubocop ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rubocop' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require 'pathname'
12
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path('../bundle', __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require 'rubygems'
27
+ require 'bundler/setup'
28
+
29
+ load Gem.bin_path('rubocop', 'rubocop')
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Royal
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def create_initializer
11
+ template 'royal.rb', 'config/initializers/royal.rb'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Royal
7
+ module Generators
8
+ class MigrationGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ def self.next_migration_number(dir)
14
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
15
+ end
16
+
17
+ def create_point_balances_migration
18
+ migration_template 'create_point_balances.rb.erb', 'db/migrate/create_point_balances.rb'
19
+ end
20
+
21
+ private
22
+
23
+ def migration_version
24
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails.version >= '5.0.0'
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePointBalances < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :point_balances do |t|
6
+ t.belongs_to :owner, polymorphic: true, null: false
7
+ t.belongs_to :pointable, polymorphic: true, null: true
8
+ t.string :reason, null: true
9
+ t.integer :amount, null: false
10
+ t.integer :balance, null: false
11
+ t.integer :sequence, null: false
12
+ t.timestamps
13
+
14
+ t.index %i[owner_id owner_type sequence], unique: true
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ Royal.configure do |config|
4
+ # Sets the locking for the points balance ledger.
5
+ #
6
+ # Available modes are:
7
+ # - :optimistic (default)
8
+ # - :pessimistic
9
+ # - :advisory (PostgreSQL only)
10
+ #
11
+ # Most applications should use the default locking mode.
12
+ # For further information about each of the supported modes, see the README file:
13
+ # https://github.com/mintyfresh/royal#concurrency-and-locking
14
+ #
15
+ # config.locking = :optimistic
16
+
17
+ # Sets the maximum number of retries when using the optimistic locking mode. (default 10)
18
+ # config.max_retries = 10
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ class Config
5
+ DEFAULT_LOCKING = :optimistic
6
+ DEFAULT_MAX_RETRIES = 10
7
+
8
+ # The configured locking mechanism for the points balance ledger.
9
+ #
10
+ # @return [#call]
11
+ attr_reader :locking
12
+ # @return [Integer]
13
+ attr_reader :max_retries
14
+
15
+ def initialize
16
+ self.locking = DEFAULT_LOCKING
17
+ self.max_retries = DEFAULT_MAX_RETRIES
18
+ end
19
+
20
+ # @param locator [Symbol]
21
+ # @return [void]
22
+ def locking=(locator)
23
+ @locking = Royal::Locking.resolve(locator)
24
+ end
25
+
26
+ # @param max_retries [Integer]
27
+ # @return [void]
28
+ def max_retries=(max_retries)
29
+ raise ArgumentError, 'Max retries must be an Integer' unless max_retries.is_a?(Integer)
30
+ raise ArgumentError, 'Max retries must be positive' unless max_retries.positive?
31
+
32
+ @max_retries = max_retries
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ Error = Class.new(StandardError)
5
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ class InsufficientPointsError < Error
5
+ # @return [ActiveRecord::Base]
6
+ attr_reader :owner
7
+ # @return [Integer]
8
+ attr_reader :amount
9
+ # @return [Integer]
10
+ attr_reader :balance
11
+ # @return [String, nil]
12
+ attr_reader :reason
13
+ # @return [ActiveRecord::Base, nil]
14
+ attr_reader :pointable
15
+
16
+ # @param owner [ActiveRecord::Base]
17
+ # @param amount [Integer]
18
+ # @param balance [Integer]
19
+ # @param reason [String, nil]
20
+ # @param pointable [ActiveRecord::Base, nil]
21
+ def initialize(owner, amount, balance, reason, pointable)
22
+ @owner = owner
23
+ @amount = amount
24
+ @balance = balance
25
+ @reason = reason
26
+ @pointable = pointable
27
+
28
+ super("Insufficient points: #{amount} (balance: #{balance})")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ module Locking
5
+ class Advisory
6
+ # Used to extract the lower 32-bits of owner IDs.
7
+ # The 2-argument version of `pg_advisory_xact_lock` accepts 32 bit integers,
8
+ # and this ensures we do not overflow that constraint.
9
+ ID_LOBITS_MASK = 0xFFFFFFFF
10
+
11
+ # @param owner [ActiveRecord::Base]
12
+ def call(owner)
13
+ PointBalance.transaction(requires_new: true) do
14
+ acquire_advisory_lock_on_owner(owner)
15
+ yield
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ # @param owner [ActiveRecord::Base]
22
+ # @return [void]
23
+ def acquire_advisory_lock_on_owner(owner)
24
+ sql = PointBalance.sanitize_sql_array([<<-SQL.squish, owner.class.polymorphic_name, owner.id & ID_LOBITS_MASK])
25
+ SELECT pg_advisory_xact_lock(hashtext(?), ?)
26
+ SQL
27
+
28
+ PointBalance.connection.query(sql)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ module Locking
5
+ class Optimistic
6
+ # @param owner [Royal::PointBalance]
7
+ def call(_owner, &block)
8
+ result = nil
9
+
10
+ up_to_max_retries do
11
+ success, result = try_create_record(&block)
12
+ return result if success
13
+ end
14
+
15
+ # NOTE: Failed to insert record after maximum number of attempts.
16
+ # This could be caused by too much write contention for the same owner.
17
+ # One possible solution is to acquire a row-level lock on the owner record and retry.
18
+ # Other solutions like advisory locks or sequential processing queues may work better in some situations.
19
+ raise Royal::SequenceError, "Failed to update points: #{result.message}"
20
+ end
21
+
22
+ private
23
+
24
+ def up_to_max_retries(&block)
25
+ Royal.config.max_retries.times(&block)
26
+ end
27
+
28
+ # @return [[Boolean, Object]]
29
+ def try_create_record
30
+ success = true
31
+ result = nil
32
+
33
+ PointBalance.transaction(requires_new: true) do
34
+ result = yield
35
+ rescue ActiveRecord::RecordNotUnique => error
36
+ success = false
37
+ result = error
38
+ raise ActiveRecord::Rollback
39
+ end
40
+
41
+ [success, result]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ module Locking
5
+ class Pessimistic
6
+ # @param owner [ActiveRecord::Base]
7
+ def call(owner)
8
+ owner.transaction(requires_new: true) do
9
+ # NOTE: Avoid using `lock!` to prevent reloading the record from DB.
10
+ # We don't need any updated state, just exclusive use of the record.
11
+ owner.class.where(id: owner).lock(true).take!
12
+ yield
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ module Locking
5
+ autoload :Advisory, 'royal/locking/advisory'
6
+ autoload :Optimistic, 'royal/locking/optimistic'
7
+ autoload :Pessimistic, 'royal/locking/pessimistic'
8
+
9
+ # @param locator [Symbol]
10
+ # @return [#call]
11
+ def self.resolve(locator)
12
+ case locator
13
+ when :advisory then Advisory.new
14
+ when :optimistic then Optimistic.new
15
+ when :pessimistic then Pessimistic.new
16
+ else raise ArgumentError, "Unsupported locking type: #{locator.inspect}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module Royal
6
+ class PointBalance < ActiveRecord::Base
7
+ attr_readonly :owner_id, :owner_type, :amount, :balance, :sequence
8
+
9
+ belongs_to :owner, polymorphic: true, optional: false
10
+ belongs_to :pointable, polymorphic: true, optional: true
11
+
12
+ validates :amount, numericality: { other_than: 0 }
13
+ validates :reason, length: { maximum: 1000 }
14
+
15
+ before_create do
16
+ previous_balance = self.class.latest_for_owner(owner)
17
+
18
+ self.sequence = (previous_balance&.sequence || 0) + 1
19
+ self.balance = (previous_balance&.balance || 0) + amount
20
+ end
21
+
22
+ after_create if: -> { balance.negative? } do
23
+ # Rollback the transaction _after_ the operation to ensure amount
24
+ # was applied against the most recent points balance.
25
+ raise InsufficientPointsError.new(owner, amount, original_balance, reason, pointable)
26
+ end
27
+
28
+ # @param owner [ActiveRecord::Base]
29
+ # @return [PointBalance, nil]
30
+ def self.latest_for_owner(owner)
31
+ PointBalance.where(owner: owner).order(:sequence).last
32
+ end
33
+
34
+ # @param owner [ActiveRecord::Base]
35
+ def self.with_lock_on_owner(owner, &block)
36
+ Royal.config.locking.call(owner, &block)
37
+ end
38
+
39
+ # @param owner [ActiveRecord::Base]
40
+ # @param amount [Integer]
41
+ # @param attributes [Hash] Additional attributes to set on the new record.
42
+ # @return [Integer] Returns the new points balance.
43
+ def self.apply_change_to_points(owner, amount, **attributes)
44
+ with_lock_on_owner(owner) do
45
+ create!(owner: owner, amount: amount, **attributes).balance
46
+ end
47
+ end
48
+
49
+ # Returns the balance before this operation.
50
+ #
51
+ # @return [Integer]
52
+ def original_balance
53
+ balance - amount
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Royal
6
+ module Points
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ has_many :point_balances, as: :owner, inverse_of: :owner,
11
+ class_name: 'Royal::PointBalance', dependent: :delete_all
12
+ end
13
+
14
+ # Returns the current number of points in the record's balance.
15
+ #
16
+ # @return [Integer]
17
+ def current_points
18
+ point_balances.order(sequence: :desc).limit(1).pluck(:balance).first || 0
19
+ end
20
+
21
+ # Adds a number of points to the record's current points balance.
22
+ #
23
+ # @param amount [Integer] The number of points to add to the blance.
24
+ # @param reason [String, nil] An optional reason to store with the balance change.
25
+ # @param pointable [ActiveRecord::Base, nil] An optional record to associate to the balance change.
26
+ # @return [Integer] Returns the new points balance.
27
+ def add_points(amount, reason: nil, pointable: nil)
28
+ point_balances.apply_change_to_points(self, amount, reason: reason, pointable: pointable)
29
+ end
30
+
31
+ # Subtracts a number of points to the record's current points balance.
32
+ #
33
+ # @param amount [Integer] The number of points to subtract from the balance.
34
+ # @param reason [String, nil] An optional reason to store with the balance change.
35
+ # @param pointable [ActiveRecord::Base, nil] An optional record to associate to the balance change.
36
+ # @return [Integer] Returns the new points balance.
37
+ def subtract_points(amount, reason: nil, pointable: nil)
38
+ point_balances.apply_change_to_points(self, -amount, reason: reason, pointable: pointable)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ SequenceError = Class.new(Error)
5
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ class Transaction
5
+ Operation = Struct.new(:owner, :amount, :reason, :pointable) do
6
+ # @return [void]
7
+ def perform
8
+ owner.add_points(amount, reason: reason, pointable: pointable)
9
+ end
10
+
11
+ # Used to ensure a deterministic order of operations in a transaction to avoid deadlocks.
12
+ #
13
+ # @return [Array]
14
+ def sorting_key
15
+ [owner.class.polymorphic_name, owner.id, -amount]
16
+ end
17
+ end
18
+
19
+ def initialize
20
+ @operations = []
21
+ end
22
+
23
+ # @param owner [ActiveRecord::Base]
24
+ # @param amount [Integer]
25
+ # @param reason [String, nil]
26
+ # @param pointable [ActiveRecord::Base, nil]
27
+ # @return [self]
28
+ def add_points(owner, amount, reason: nil, pointable: nil)
29
+ @operations << Operation.new(owner, amount, reason, pointable).freeze
30
+
31
+ self
32
+ end
33
+
34
+ # @param owner [ActiveRecord::Base]
35
+ # @param amount [Integer]
36
+ # @param reason [String, nil]
37
+ # @param pointable [ActiveRecord::Base, nil]
38
+ # @return [self]
39
+ def subtract_points(owner, amount, reason: nil, pointable: nil)
40
+ add_points(owner, -amount, reason: reason, pointable: pointable)
41
+ end
42
+
43
+ # @return [self]
44
+ def call
45
+ PointBalance.transaction(requires_new: true) do
46
+ @operations.sort_by(&:sorting_key).each(&:perform)
47
+ end
48
+
49
+ self
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Royal
4
+ VERSION = '0.1.0'
5
+ end
data/lib/royal.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'royal/version'
4
+
5
+ require_relative 'royal/error'
6
+ require_relative 'royal/insufficient_points_error'
7
+ require_relative 'royal/sequence_error'
8
+
9
+ require_relative 'royal/config'
10
+ require_relative 'royal/points'
11
+ require_relative 'royal/point_balance'
12
+ require_relative 'royal/locking'
13
+ require_relative 'royal/transaction'
14
+
15
+ module Royal
16
+ # @return [Royal::Config]
17
+ def self.config
18
+ @config ||= Royal::Config.new.freeze
19
+ end
20
+
21
+ # @return [void]
22
+ def self.configure
23
+ config = Royal::Config.new
24
+ yield(config)
25
+
26
+ @config = config.freeze
27
+ end
28
+ end
data/royal.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/royal/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'royal'
7
+ spec.version = Royal::VERSION
8
+ spec.authors = ['Minty Fresh']
9
+ spec.email = ['7896757+mintyfresh@users.noreply.github.com']
10
+
11
+ spec.summary = 'Loyalty Points for ActiveRecord'
12
+ spec.description = 'Ledger backed loyalty points for your ActiveRecord models'
13
+ spec.homepage = 'https://github.com/mintyfresh/royal'
14
+
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
16
+
17
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org/'
18
+ spec.metadata['rubygems_mfa_required'] = 'true'
19
+
20
+ spec.metadata['homepage_uri'] = spec.homepage
21
+ spec.metadata['source_code_uri'] = spec.homepage
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
27
+ end
28
+
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_dependency 'activerecord', '>= 5', '< 8'
34
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: royal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Minty Fresh
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-02-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8'
33
+ description: Ledger backed loyalty points for your ActiveRecord models
34
+ email:
35
+ - 7896757+mintyfresh@users.noreply.github.com
36
+ executables: []
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - ".github/workflows/main.yml"
41
+ - ".gitignore"
42
+ - ".rubocop.yml"
43
+ - ".vscode/settings.json"
44
+ - Gemfile
45
+ - Gemfile.lock
46
+ - README.md
47
+ - Rakefile
48
+ - bin/console
49
+ - bin/rspec
50
+ - bin/rubocop
51
+ - bin/setup
52
+ - lib/generators/royal/install_generator.rb
53
+ - lib/generators/royal/migration_generator.rb
54
+ - lib/generators/royal/templates/create_point_balances.rb.erb
55
+ - lib/generators/royal/templates/royal.rb
56
+ - lib/royal.rb
57
+ - lib/royal/config.rb
58
+ - lib/royal/error.rb
59
+ - lib/royal/insufficient_points_error.rb
60
+ - lib/royal/locking.rb
61
+ - lib/royal/locking/advisory.rb
62
+ - lib/royal/locking/optimistic.rb
63
+ - lib/royal/locking/pessimistic.rb
64
+ - lib/royal/point_balance.rb
65
+ - lib/royal/points.rb
66
+ - lib/royal/sequence_error.rb
67
+ - lib/royal/transaction.rb
68
+ - lib/royal/version.rb
69
+ - royal.gemspec
70
+ homepage: https://github.com/mintyfresh/royal
71
+ licenses: []
72
+ metadata:
73
+ allowed_push_host: https://rubygems.org/
74
+ rubygems_mfa_required: 'true'
75
+ homepage_uri: https://github.com/mintyfresh/royal
76
+ source_code_uri: https://github.com/mintyfresh/royal
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 2.6.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.2.15
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Loyalty Points for ActiveRecord
96
+ test_files: []