riak-ruby-ledger 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- 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
|