riak-ruby-ledger 0.0.4
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 +15 -0
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE +16 -0
- data/README.md +419 -0
- data/Rakefile +8 -0
- data/lib/crdt/tgcounter.rb +171 -0
- data/lib/crdt/tpncounter.rb +64 -0
- data/lib/ledger.rb +173 -0
- data/lib/ledger/version.rb +5 -0
- data/riak-ruby-ledger.gemspec +25 -0
- data/test/lib/ledger/version_test.rb +9 -0
- data/test/lib/ledger_test.rb +142 -0
- data/test/lib/tgcounter_test.rb +99 -0
- data/test/lib/tpncounter_test.rb +97 -0
- data/test/test_helper.rb +5 -0
- metadata +125 -0
data/Rakefile
ADDED
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module Riak::CRDT
|
4
|
+
class TGCounter
|
5
|
+
attr_accessor :counts, :actor, :history_length
|
6
|
+
|
7
|
+
# Create a new Transaction GCounter
|
8
|
+
# @param [Hash] options
|
9
|
+
# {
|
10
|
+
# :actor [String]
|
11
|
+
# :history_length [Integer]
|
12
|
+
# }
|
13
|
+
def initialize(options)
|
14
|
+
self.actor = options[:actor]
|
15
|
+
self.history_length = options[:history_length]
|
16
|
+
self.counts = Hash.new()
|
17
|
+
self.counts[self.actor] = Hash.new()
|
18
|
+
self.counts[self.actor]["total"] = 0
|
19
|
+
self.counts[self.actor]["txns"] = TransactionArray.new()
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_hash
|
23
|
+
c = Hash.new()
|
24
|
+
self.counts.each do |a, values|
|
25
|
+
c[a] = Hash.new()
|
26
|
+
c[a]["total"] = values["total"]
|
27
|
+
c[a]["txns"] = values["txns"].arr
|
28
|
+
end
|
29
|
+
|
30
|
+
{
|
31
|
+
type: 'TGCounter',
|
32
|
+
c: c
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_json
|
37
|
+
self.to_hash.to_json
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.from_hash(h, options)
|
41
|
+
gc = new(options)
|
42
|
+
|
43
|
+
h['c'].each do |a, values|
|
44
|
+
gc.counts[a] = Hash.new() unless gc.counts[a]
|
45
|
+
gc.counts[a]["total"] = values["total"]
|
46
|
+
gc.counts[a]["txns"] = TransactionArray.new(values["txns"])
|
47
|
+
end
|
48
|
+
|
49
|
+
return gc
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.from_json(json, options)
|
53
|
+
h = JSON.parse json
|
54
|
+
raise ArgumentError.new 'unexpected type field in JSON' unless h['type'] == 'TGCounter'
|
55
|
+
|
56
|
+
from_hash(h, options)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Increment this actor's transaction array, overwriting if the value exists
|
60
|
+
# @param [String] transaction
|
61
|
+
# @param [Integer] value
|
62
|
+
def increment(transaction, value)
|
63
|
+
self.counts[actor]["txns"][transaction] = value
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get unique list of all transactions and values across all known actors
|
67
|
+
# @param [String] ignore_actor
|
68
|
+
# @return [Hash]
|
69
|
+
def unique_transactions(ignore_actor=nil)
|
70
|
+
txns = Hash.new()
|
71
|
+
|
72
|
+
self.counts.each do |a, values|
|
73
|
+
unless a == ignore_actor
|
74
|
+
values["txns"].arr.each do |arr|
|
75
|
+
txns[arr[0]] = arr[1]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
txns
|
81
|
+
end
|
82
|
+
|
83
|
+
def has_transaction?(transaction)
|
84
|
+
self.unique_transactions().keys.member?(transaction)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Sum of totals and currently tracked transactions
|
88
|
+
# @return [Integer]
|
89
|
+
def value()
|
90
|
+
total = self.unique_transactions().values.inject(0, &:+)
|
91
|
+
|
92
|
+
self.counts.values.each do |a|
|
93
|
+
total += a["total"]
|
94
|
+
end
|
95
|
+
|
96
|
+
total
|
97
|
+
end
|
98
|
+
|
99
|
+
# Merge actor data from a sibling into self, additionally compress oldest
|
100
|
+
# transactions that exceed the :history_length param into actor's total
|
101
|
+
# @param [TGCounter] other
|
102
|
+
def merge(other)
|
103
|
+
# Combine all actors first
|
104
|
+
other.counts.each do |other_actor, other_values|
|
105
|
+
if self.counts[other_actor]
|
106
|
+
# Max of totals
|
107
|
+
mine = self.counts[other_actor]["total"]
|
108
|
+
self.counts[other_actor]["total"] = [mine, other_values["total"]].max
|
109
|
+
|
110
|
+
# Max of unique transactions
|
111
|
+
other_values["txns"].arr.each do |arr|
|
112
|
+
other_txn, other_value = arr
|
113
|
+
mine = (self.counts[other_actor]["txns"][other_txn]) ?
|
114
|
+
self.counts[other_actor]["txns"][other_txn] : 0
|
115
|
+
self.counts[other_actor]["txns"][other_txn] = [mine, other_value].max
|
116
|
+
end
|
117
|
+
else
|
118
|
+
self.counts[other_actor] = other_values
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Remove duplicate transactions if other actors have claimed them
|
123
|
+
self.unique_transactions(actor).keys.each do |txn|
|
124
|
+
self.counts[actor]["txns"].delete(txn)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Merge this actor's data based on history_length
|
128
|
+
total = 0
|
129
|
+
if self.counts[actor]["txns"].length > self.history_length
|
130
|
+
to_delete = self.counts[actor]["txns"].length - self.history_length
|
131
|
+
self.counts[actor]["txns"].arr.slice!(0..to_delete - 1).each do |arr|
|
132
|
+
total += arr[1]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
self.counts[actor]["total"] += total
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Ease of use class: Wraps an ordered array with some hash-like functions
|
142
|
+
class TransactionArray
|
143
|
+
attr_accessor :arr
|
144
|
+
|
145
|
+
def initialize(arr=Array.new())
|
146
|
+
self.arr = arr
|
147
|
+
end
|
148
|
+
|
149
|
+
def length()
|
150
|
+
self.arr.length
|
151
|
+
end
|
152
|
+
|
153
|
+
def ==(other)
|
154
|
+
self.arr == other.arr
|
155
|
+
end
|
156
|
+
|
157
|
+
def []=(key, value)
|
158
|
+
self.delete(key) if self.[](key)
|
159
|
+
self.arr << [key, value]
|
160
|
+
end
|
161
|
+
|
162
|
+
def [](key)
|
163
|
+
res = self.arr.select { |a| a[0] == key }
|
164
|
+
res.first[1] if res && res.length > 0 &&res.first.length == 2
|
165
|
+
end
|
166
|
+
|
167
|
+
def delete(key)
|
168
|
+
index = self.arr.index { |a| a[0] == key }
|
169
|
+
self.arr.delete_at(index) if index
|
170
|
+
end
|
171
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'crdt/tgcounter'
|
2
|
+
|
3
|
+
module Riak::CRDT
|
4
|
+
class TPNCounter
|
5
|
+
attr_accessor :p, :n
|
6
|
+
|
7
|
+
# Create a new Transaction PNCounter
|
8
|
+
# @param [Hash] options
|
9
|
+
# {
|
10
|
+
# :actor [String]
|
11
|
+
# :history_length [Integer]
|
12
|
+
# }
|
13
|
+
def initialize(options)
|
14
|
+
self.p = TGCounter.new(options)
|
15
|
+
self.n = TGCounter.new(options)
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_json
|
19
|
+
{
|
20
|
+
type: 'TPNCounter',
|
21
|
+
p: self.p.to_hash,
|
22
|
+
n: self.n.to_hash
|
23
|
+
}.to_json
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.from_json(json, options)
|
27
|
+
h = JSON.parse json
|
28
|
+
raise ArgumentError.new 'unexpected type field in JSON' unless h['type'] == 'TPNCounter'
|
29
|
+
|
30
|
+
pnc = new(options)
|
31
|
+
pnc.p = TGCounter.from_hash(h['p'], options)
|
32
|
+
pnc.n = TGCounter.from_hash(h['n'], options)
|
33
|
+
|
34
|
+
return pnc
|
35
|
+
end
|
36
|
+
|
37
|
+
# Increment this actor's positive transaction array, overwriting if the value exists
|
38
|
+
# @param [String] transaction
|
39
|
+
# @param [Integer] value
|
40
|
+
def increment(transaction, value)
|
41
|
+
self.p.increment(transaction, value)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Increment this actor's negative transaction array, overwriting if the value exists
|
45
|
+
# @param [String] transaction
|
46
|
+
# @param [Integer] value
|
47
|
+
def decrement(transaction, value)
|
48
|
+
self.n.increment(transaction, value)
|
49
|
+
end
|
50
|
+
|
51
|
+
def value
|
52
|
+
self.p.value - self.n.value
|
53
|
+
end
|
54
|
+
|
55
|
+
def has_transaction?(transaction)
|
56
|
+
self.p.has_transaction?(transaction) || self.n.has_transaction?(transaction)
|
57
|
+
end
|
58
|
+
|
59
|
+
def merge(other)
|
60
|
+
self.p.merge(other.p)
|
61
|
+
self.n.merge(other.n)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/ledger.rb
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
require 'ledger/version'
|
2
|
+
require 'crdt/tpncounter'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Riak
|
6
|
+
class Ledger
|
7
|
+
attr_accessor :bucket, :key, :counter, :retry_count, :counter_options
|
8
|
+
|
9
|
+
# Create a new Ledger object
|
10
|
+
# @param [Riak::Bucket] bucket
|
11
|
+
# @param [String] key
|
12
|
+
# @param [Hash] options
|
13
|
+
# {
|
14
|
+
# :actor [String]: default Thread.current["name"] || "ACTOR1"
|
15
|
+
# :history_length [Integer]: default 10
|
16
|
+
# :retry_count [Integer]: default 10
|
17
|
+
# }
|
18
|
+
def initialize(bucket, key, options={})
|
19
|
+
self.bucket = bucket
|
20
|
+
self.key = key
|
21
|
+
self.retry_count = options[:retry_count] || 10
|
22
|
+
|
23
|
+
self.counter_options = {}
|
24
|
+
self.counter_options[:actor] = options[:actor] || Thread.current["name"] || "ACTOR1"
|
25
|
+
self.counter_options[:history_length] = options[:history_length] || 10
|
26
|
+
self.counter = Riak::CRDT::TPNCounter.new(self.counter_options)
|
27
|
+
|
28
|
+
unless bucket.allow_mult
|
29
|
+
self.bucket.allow_mult = true
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Find an existing Ledger object, merge and save it
|
34
|
+
# @param [Riak::Bucket] bucket
|
35
|
+
# @param [String] key
|
36
|
+
# @param [Hash] options
|
37
|
+
# {
|
38
|
+
# :actor [String]: default Thread.current["name"] || "ACTOR1"
|
39
|
+
# :history_length [Integer]: default 10
|
40
|
+
# :retry_count [Integer]: default 10
|
41
|
+
# }
|
42
|
+
# @return [Riak::Ledger]
|
43
|
+
def self.find!(bucket, key, options={})
|
44
|
+
candidate = new(bucket, key, options)
|
45
|
+
vclock = candidate.refresh()
|
46
|
+
candidate.save(vclock)
|
47
|
+
|
48
|
+
return candidate
|
49
|
+
end
|
50
|
+
|
51
|
+
# Increment the counter, merge and save it
|
52
|
+
# @param [String] transaction
|
53
|
+
# @param [Positive Integer] value
|
54
|
+
# @see update!(transaction, value)
|
55
|
+
# @return [Boolean]
|
56
|
+
def credit!(transaction, value)
|
57
|
+
update!(transaction, value)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Decrement the counter, merge and save it
|
61
|
+
# @param [String] transaction
|
62
|
+
# @param [Positive Integer] value
|
63
|
+
# @see update!(transaction, value)
|
64
|
+
# @return [Boolean]
|
65
|
+
def debit!(transaction, value)
|
66
|
+
update!(transaction, value * -1)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Update the counter, merge and save it. Retry if unsuccessful
|
70
|
+
# @param [String] transaction
|
71
|
+
# @param [Integer] value
|
72
|
+
# @param [Integer] current_retry
|
73
|
+
# @return [Boolean]
|
74
|
+
def update!(transaction, value, current_retry=nil)
|
75
|
+
# Failure case, not able to successfully complete the operation, retry a.s.a.p.
|
76
|
+
if current_retry && current_retry <= 0
|
77
|
+
return false
|
78
|
+
end
|
79
|
+
|
80
|
+
# Get the current merged state of this counter
|
81
|
+
vclock = refresh()
|
82
|
+
|
83
|
+
|
84
|
+
if has_transaction?(transaction)
|
85
|
+
# If the transaction already exists in the counter, no problem
|
86
|
+
return true
|
87
|
+
else
|
88
|
+
# If the transaction doesn't exist, attempt to add it and save
|
89
|
+
if value < 0
|
90
|
+
self.counter.decrement(transaction, value * -1)
|
91
|
+
else
|
92
|
+
self.counter.increment(transaction, value)
|
93
|
+
end
|
94
|
+
|
95
|
+
unless save(vclock)
|
96
|
+
# If the save wasn't successful, retry
|
97
|
+
current_retry = self.retry_count unless current_retry
|
98
|
+
update!(transaction, value, current_retry - 1)
|
99
|
+
else
|
100
|
+
# If the save succeeded, no problem
|
101
|
+
return true
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Create a new Ledger object
|
107
|
+
# @param [String] transaction
|
108
|
+
# @return [Boolean]
|
109
|
+
def has_transaction?(transaction)
|
110
|
+
self.counter.has_transaction?(transaction)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Calculate the current value of the counter
|
114
|
+
# @return [Integer]
|
115
|
+
def value()
|
116
|
+
self.counter.value
|
117
|
+
end
|
118
|
+
|
119
|
+
# Delete the counter
|
120
|
+
# @return [Boolean]
|
121
|
+
def delete()
|
122
|
+
begin
|
123
|
+
self.bucket.delete(self.key)
|
124
|
+
return true
|
125
|
+
rescue => e
|
126
|
+
return false
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Get the current state of the counter and merge it
|
131
|
+
# @return [String]
|
132
|
+
def refresh()
|
133
|
+
obj = self.bucket.get_or_new(self.key)
|
134
|
+
return if obj.nil?
|
135
|
+
|
136
|
+
self.counter = Riak::CRDT::TPNCounter.new(self.counter_options)
|
137
|
+
|
138
|
+
if obj.siblings.length > 1
|
139
|
+
obj.siblings.each do | sibling |
|
140
|
+
unless sibling.raw_data.nil? or sibling.raw_data.empty?
|
141
|
+
self.counter.merge(Riak::CRDT::TPNCounter.from_json(sibling.raw_data, self.counter_options))
|
142
|
+
end
|
143
|
+
end
|
144
|
+
elsif !obj.raw_data.nil?
|
145
|
+
self.counter.merge(Riak::CRDT::TPNCounter.from_json(obj.raw_data, self.counter_options))
|
146
|
+
end
|
147
|
+
|
148
|
+
return obj.vclock
|
149
|
+
end
|
150
|
+
|
151
|
+
# Save the counter with an optional vclock
|
152
|
+
# @param [String] vclock
|
153
|
+
# @return [Boolean]
|
154
|
+
def save(vclock=nil)
|
155
|
+
object = self.bucket.new(self.key)
|
156
|
+
object.vclock = vclock if vclock
|
157
|
+
object.content_type = 'application/json'
|
158
|
+
object.raw_data = to_json
|
159
|
+
|
160
|
+
begin
|
161
|
+
options = {:returnbody => false}
|
162
|
+
object.store(options)
|
163
|
+
return true
|
164
|
+
rescue => e
|
165
|
+
return false
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def to_json()
|
170
|
+
self.counter.to_json
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'ledger/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "riak-ruby-ledger"
|
8
|
+
spec.version = Riak::Ledger::VERSION
|
9
|
+
spec.authors = ["drewkerrigan"]
|
10
|
+
spec.email = ["dkerrigan@basho.com"]
|
11
|
+
spec.description = %q{A PNCounter CRDT based ledger with support for transaction ids and tunable write idempotence}
|
12
|
+
spec.summary = %q{This gem attempts to provide a tunable Counter option by combining non-idempotent GCounters and a partially idempotent GSet for calculating a running counter or ledger. By allowing clients to set how many transactions to keep in the counter object as well as set a retry policy on the Riak actions performed on the counter, a good balance can be achieved.}
|
13
|
+
spec.homepage = "https://github.com/drewkerrigan/riak-ruby-ledger"
|
14
|
+
spec.license = "Apache2"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
spec.add_dependency "json"
|
24
|
+
spec.add_dependency "riak-client"
|
25
|
+
end
|