riak-ruby-ledger 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new('test') do |t|
5
+ t.libs << 'test'
6
+ t.pattern = "test/**/**/*_test.rb"
7
+ t.pattern = "test/**/*_test.rb"
8
+ end
@@ -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
@@ -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,5 @@
1
+ module Riak
2
+ class Ledger
3
+ VERSION = "0.0.4"
4
+ end
5
+ 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