redisarray 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in redisarray.gemspec
4
+ gemspec
@@ -0,0 +1,28 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ redisarray (0.0.1)
5
+ redis (~> 2.2.2)
6
+
7
+ GEM
8
+ remote: http://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.1.3)
11
+ rake (0.9.2.2)
12
+ redis (2.2.2)
13
+ rspec (2.7.0)
14
+ rspec-core (~> 2.7.0)
15
+ rspec-expectations (~> 2.7.0)
16
+ rspec-mocks (~> 2.7.0)
17
+ rspec-core (2.7.1)
18
+ rspec-expectations (2.7.0)
19
+ diff-lcs (~> 1.1.2)
20
+ rspec-mocks (2.7.0)
21
+
22
+ PLATFORMS
23
+ x86-mingw32
24
+
25
+ DEPENDENCIES
26
+ rake (~> 0.9)
27
+ redisarray!
28
+ rspec (~> 2.7.0)
@@ -0,0 +1,96 @@
1
+ redisarray
2
+ ==========
3
+
4
+ [RedisArray] Implements memory efficient algorithm allowing to store tables or two dimensional arrays inside Redis.
5
+
6
+ Introduction
7
+ ============
8
+
9
+ Often we need to store a table with headers and rows or just an adjacent 2D array inside our Redis key-value store. We could quickly design our own custom solution but it probably would not be optimized for memory usage.
10
+ The purpose of this project is to design general solution for storing two dimensional arrays inside Redis with memory optimization in mind.
11
+ How is that accomplished? All the data that is going to be written is just sliced on pieces behind the scenes and stored in hashes with 100 of fields each which is memory efficient. To read more about this technique follow this page: http://redis.io/topics/memory-optimization
12
+
13
+
14
+ Compatibility
15
+ =============
16
+
17
+ So far compatible with ruby 1.8.7. Does not work with 1.9.2. There are a few adjustments needed concerning the use of CSV class which has significantly changed in 1.9.2.
18
+
19
+ Install
20
+ =======
21
+
22
+ gem install redisarray
23
+
24
+ Usage
25
+ =====
26
+
27
+ There are two classes worth noting:
28
+ - RedisTable - where most of the action takes place
29
+ - RedisWorkbook - a wrapper for RedisTable that simplifies storage of array of arrays or a workbook of worksheets with headers which is itself an array of arrays
30
+
31
+ You can start using redisarray gem like this:
32
+
33
+ require 'rubygems'
34
+ require 'redisarray'
35
+ include RedisArray
36
+
37
+ group_name = RedisHashGroup.new.name
38
+ @workbook = RedisWorkbook.new group_name, 'my_workbook'
39
+ @workbook.set_sheet_data "my_sheet_name",
40
+ rows = [['row 1 cell 1','row 1 cell 2'], ['row 2 cell 1','row 2 cell 2']],
41
+ :start_from_row => 3
42
+ p @workbook.get_sheet_data("my_sheet_name")
43
+
44
+ By default it will assume redis is listening on localhost port 6379, to change it do this:
45
+
46
+ require 'rubygems'
47
+ require 'redisarray'
48
+ require 'redis'
49
+ include RedisArray
50
+
51
+ RedisTable.set_redis Redis.new(:host => 'localhost', :port => 6379)
52
+
53
+ group_name = RedisHashGroup.new.name
54
+ ...
55
+
56
+ For more examples take a look at spec directory especially in redis_workbook_spec.rb
57
+
58
+ Developer Instructions
59
+ ======================
60
+
61
+ The dependencies for the gem and for developing the gem are managed by Bundler.
62
+
63
+ gem install bundler
64
+ git clone http://github.com/ksob/redisarray.git
65
+ cd ./redisarray
66
+ bundle install
67
+
68
+ Specs can be run with (they require some preconditions to be met like running Redis on localhost port 6379):
69
+
70
+ bundle exec rspec spec
71
+
72
+ License
73
+ =======
74
+
75
+ (The MIT License)
76
+
77
+ Copyright (c) 2011 Kamil Sobieraj, ksobej@gmail.com
78
+
79
+ Permission is hereby granted, free of charge, to any person obtaining
80
+ a copy of this software and associated documentation files (the
81
+ 'Software'), to deal in the Software without restriction, including
82
+ without limitation the rights to use, copy, modify, merge, publish,
83
+ distribute, sublicense, and/or sell copies of the Software, and to
84
+ permit persons to whom the Software is furnished to do so, subject to
85
+ the following conditions:
86
+
87
+ The above copyright notice and this permission notice shall be
88
+ included in all copies or substantial portions of the Software.
89
+
90
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
91
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
92
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
93
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
94
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
95
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
96
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,12 @@
1
+ module RedisArray
2
+ require 'redisarray/core_ext/hash/reverse_merge'
3
+ require 'redisarray/core_ext/class/attribute_accessors'
4
+ require 'csv'
5
+ require 'redis'
6
+
7
+ require 'redisarray/version'
8
+ require 'redisarray/redis_table'
9
+ require 'redisarray/redis_hash_group'
10
+ require 'redisarray/redis_workbook'
11
+ end
12
+
@@ -0,0 +1,29 @@
1
+ class Hash
2
+ # By default, only instances of Hash itself are extractable.
3
+ # Subclasses of Hash may implement this method and return
4
+ # true to declare themselves as extractable. If a Hash
5
+ # is extractable, Array#extract_options! pops it from
6
+ # the Array when it is the last element of the Array.
7
+ def extractable_options?
8
+ instance_of?(Hash)
9
+ end
10
+ end
11
+
12
+ class Array
13
+ # Extracts options from a set of arguments. Removes and returns the last
14
+ # element in the array if it's a hash, otherwise returns a blank hash.
15
+ #
16
+ # def options(*args)
17
+ # args.extract_options!
18
+ # end
19
+ #
20
+ # options(1, 2) # => {}
21
+ # options(1, 2, :a => :b) # => {:a=>:b}
22
+ def extract_options!
23
+ if last.is_a?(Hash) && last.extractable_options?
24
+ pop
25
+ else
26
+ {}
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,79 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), "..", "array/extract_options"))
2
+
3
+ # Extends the class object with class and instance accessors for class attributes,
4
+ # just like the native attr* accessors for instance attributes.
5
+ #
6
+ # Note that unlike +class_attribute+, if a subclass changes the value then that would
7
+ # also change the value for parent class. Similarly if parent class changes the value
8
+ # then that would change the value of subclasses too.
9
+ #
10
+ # class Person
11
+ # cattr_accessor :hair_colors
12
+ # end
13
+ #
14
+ # Person.hair_colors = [:brown, :black, :blonde, :red]
15
+ # Person.hair_colors # => [:brown, :black, :blonde, :red]
16
+ # Person.new.hair_colors # => [:brown, :black, :blonde, :red]
17
+ #
18
+ # To opt out of the instance writer method, pass :instance_writer => false.
19
+ # To opt out of the instance reader method, pass :instance_reader => false.
20
+ #
21
+ # class Person
22
+ # cattr_accessor :hair_colors, :instance_writer => false, :instance_reader => false
23
+ # end
24
+ #
25
+ # Person.new.hair_colors = [:brown] # => NoMethodError
26
+ # Person.new.hair_colors # => NoMethodError
27
+ class Class
28
+ def cattr_reader(*syms)
29
+ options = syms.extract_options!
30
+ syms.each do |sym|
31
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
32
+ unless defined? @@#{sym}
33
+ @@#{sym} = nil
34
+ end
35
+
36
+ def self.#{sym}
37
+ @@#{sym}
38
+ end
39
+ EOS
40
+
41
+ unless options[:instance_reader] == false
42
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
43
+ def #{sym}
44
+ @@#{sym}
45
+ end
46
+ EOS
47
+ end
48
+ end
49
+ end
50
+
51
+ def cattr_writer(*syms)
52
+ options = syms.extract_options!
53
+ syms.each do |sym|
54
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
55
+ unless defined? @@#{sym}
56
+ @@#{sym} = nil
57
+ end
58
+
59
+ def self.#{sym}=(obj)
60
+ @@#{sym} = obj
61
+ end
62
+ EOS
63
+
64
+ unless options[:instance_writer] == false
65
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
66
+ def #{sym}=(obj)
67
+ @@#{sym} = obj
68
+ end
69
+ EOS
70
+ end
71
+ self.send("#{sym}=", yield) if block_given?
72
+ end
73
+ end
74
+
75
+ def cattr_accessor(*syms, &blk)
76
+ cattr_reader(*syms)
77
+ cattr_writer(*syms, &blk)
78
+ end
79
+ end
@@ -0,0 +1,23 @@
1
+ class Hash
2
+ # Merges the caller into +other_hash+. For example,
3
+ #
4
+ # options = options.reverse_merge(:size => 25, :velocity => 10)
5
+ #
6
+ # is equivalent to
7
+ #
8
+ # options = {:size => 25, :velocity => 10}.merge(options)
9
+ #
10
+ # This is particularly useful for initializing an options hash
11
+ # with default values.
12
+ def reverse_merge(other_hash)
13
+ other_hash.merge(self)
14
+ end
15
+
16
+ # Destructive +reverse_merge+.
17
+ def reverse_merge!(other_hash)
18
+ # right wins if there is no left
19
+ merge!(other_hash) { |key, left, right| left }
20
+ end
21
+
22
+ alias_method :reverse_update, :reverse_merge!
23
+ end
@@ -0,0 +1,22 @@
1
+ module RedisArray
2
+ class RedisHashGroup
3
+ attr_reader :name
4
+
5
+ def initialize options={}
6
+ options.reverse_merge!(:existing_group_name => nil)
7
+ if options[:prefix]
8
+ @name = options[:prefix] + "-" + rand(100000).to_s
9
+ while not RedisTable.get_redis.keys("#{@name}:*").empty? do
10
+ @name = options[:prefix] + "-" + rand(100000).to_s
11
+ end
12
+ elsif options[:existing_group_name].nil?
13
+ @name = rand(100000).to_s
14
+ while not RedisTable.get_redis.keys("#{@name}:*").empty? do
15
+ @name = rand(100000).to_s
16
+ end
17
+ else
18
+ @name = options[:existing_group_name].to_s
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,187 @@
1
+ require 'redis'
2
+ require 'csv'
3
+
4
+ module RedisArray
5
+ class RedisTable
6
+ cattr_accessor :logger
7
+ @silence = false
8
+
9
+ def self.silence?
10
+ @silence
11
+ end
12
+
13
+ # Silence the logger.
14
+ def self.silence!
15
+ @silence = true
16
+ self
17
+ end
18
+
19
+ @redis = Redis.new(:host => 'localhost', :port => 6379)
20
+
21
+ def self.get_redis
22
+ @redis
23
+ end
24
+
25
+ def self.set_redis redis
26
+ @redis = redis
27
+ end
28
+
29
+ def self.get_table_names main_key_part
30
+ res = []
31
+ @redis.keys("#{main_key_part}:*").each do |key|
32
+ res << key.scan(/^#{Regexp.escape(main_key_part)}\:(.+):\d+/)[0][0]
33
+ end
34
+ res
35
+ end
36
+
37
+ def self.get_row_ranges main_key_part, table_name
38
+ row_ranges = []
39
+ @redis.keys("#{main_key_part}:#{table_name}:*").each do |key|
40
+ row_ranges << key.scan(/^#{Regexp.escape(main_key_part)}\:#{Regexp.escape(table_name)}\:(\d+)/)[0][0]
41
+ end
42
+ row_ranges
43
+ end
44
+
45
+ # Parameters:
46
+ # +rows+ - specifies a range of rows to return,
47
+ # it is header row agnostic in meaning that its up to you
48
+ # to track if you supplied a header or not
49
+ # As to the place of the header:
50
+ # if you supplied headers in +header_row+ parameter for +set_table+
51
+ # then it is stored at index 0 and you can retrieve it usign +get_table_data+
52
+ # in two ways:
53
+ # 1. specify +rows+ with value of :all
54
+ # 2. specify +rows+ with value of 0
55
+ def self.get_table_data main_key_part, table_name, rows = 0..-1
56
+ table = {}
57
+ if rows == (0..-1)
58
+ get_row_ranges(main_key_part, table_name).each do |kri|
59
+ @redis.hgetall("#{main_key_part}:#{table_name}:#{kri}").each_pair do |k, v|
60
+ table[(kri.to_i*100) + k.to_i] = v
61
+ end
62
+ end
63
+ elsif rows.kind_of? Integer
64
+ get_row_ranges(main_key_part, table_name).each do |kri|
65
+ next if kri.to_i != rows / 100
66
+ table[(kri.to_i*100) + rows.to_i] = @redis.hget("#{main_key_part}:#{table_name}:#{kri}", rows)
67
+ end
68
+ elsif rows.kind_of? Range
69
+ # TODO: handle case where rows is range like 15..20
70
+ raise "TODO: handle case where rows is range like 15..20"
71
+ else
72
+ raise "Unsupported type of 'rows' parameter!"
73
+ end
74
+
75
+ table
76
+ end
77
+
78
+ def self.get_table_data_as_array main_key_part, table_name, rows = 0..-1
79
+ hash = get_table_data main_key_part, table_name, rows
80
+ res = hash.inject([]) do |array, (k, v)|
81
+ array[k] = CSV.parse(v+"\r\n"+v)[0].collect { |cell| cell.to_s } #if not cell.nil?}
82
+ array
83
+ end
84
+ if rows.kind_of? Integer
85
+ res.compact
86
+ else
87
+ res
88
+ end
89
+ end
90
+
91
+ def self.append_table_data main_key_part, table_name, rows, options={}
92
+
93
+ kri = get_row_ranges(main_key_part, table_name).sort[-1].to_i
94
+ res = @redis.hgetall("#{main_key_part}:#{table_name}:#{kri}").sort do |p1, p2|
95
+ p1[0].to_i <=> p2[0].to_i
96
+ end
97
+ first_empty_ri = 0 # the nearest (to the beginning) empty row index
98
+ if res.length > 0
99
+ first_empty_ri = res[-1][0].to_i + 1
100
+ else
101
+ # it is empty so start from the beginning
102
+ end
103
+
104
+ start_from_row = kri.to_i * 100 + first_empty_ri
105
+ if start_from_row == 0
106
+ set_table_data main_key_part, table_name, rows
107
+ else
108
+ set_table_data main_key_part, table_name, rows, options={:start_from_row => start_from_row}
109
+ end
110
+ end
111
+
112
+ # options:
113
+ # :start_from_row - i.e. the output index or in other words an offset of the output data
114
+ # it cannot be greater than 99
115
+ # :header_row - array of strings - if specified they will be written at index 0 and the data rows will start at index 1
116
+ # :skip_headers - boolean - if specified the :header_row will be skipped and the data rows will start at index 0
117
+ # (unless the :start_from_row is specified at the same time that would overwrite this option,
118
+ # that is when :start_from_row is specified the :skip_headers will not cause shifting the data)
119
+ def self.set_table_data main_key_part, table_name, rows, options={}
120
+ if options[:skip_headers]
121
+ options.reverse_merge!(:start_from_row => 1)
122
+ else
123
+ options.reverse_merge!(:start_from_row => 0)
124
+ end
125
+ new_rows = Marshal::load(Marshal.dump(rows))
126
+ new_rows.unshift(options[:header_row]) if options[:header_row] and not options[:skip_headers]
127
+ source_row_index = 0 # the index in the source array of data (i.e. the one we are copying data from to put them to redis)
128
+ (0).upto((new_rows.count + options[:start_from_row]) / 100) do |kri|
129
+ next if kri < options[:start_from_row] / 100
130
+ (options[:start_from_row] / 100 == kri ? options[:start_from_row] % 100 : 0).upto(99).each do |ri|
131
+ break if source_row_index >= new_rows.count #+ options[:start_from_row]
132
+ curr_key = "#{main_key_part}:#{table_name}:#{kri}", ri # + (kri==0 ? options[:start_from_row] : 0)
133
+ log("HGET", "#{curr_key.to_s} == #{@redis.hget(*curr_key).to_s}")
134
+ log("HSET", "#{curr_key.to_s} == #{CSV.generate_line(new_rows[source_row_index]).to_s}")
135
+ if RUBY_VERSION < '1.9'
136
+ @redis.hset(*(curr_key << CSV.generate_line(new_rows[source_row_index])))
137
+ else
138
+ @redis.hset(*(curr_key << CSV.generate_line(new_rows[source_row_index], :row_sep => '')))
139
+ end
140
+ source_row_index += 1
141
+ end
142
+ end
143
+ end
144
+
145
+ def self.delete_all_table main_key_part, table_name
146
+ self.get_row_ranges(main_key_part, table_name).each do |kri|
147
+ curr_key = "#{main_key_part}:#{table_name}:#{kri}"
148
+ @redis.del(curr_key)
149
+ end
150
+ end
151
+
152
+ def self.delete_table_data main_key_part, table_name, options={}
153
+ if options[:skip_headers]
154
+ options.reverse_merge!(:start_from_row => 1)
155
+ else
156
+ options.reverse_merge!(:start_from_row => 0)
157
+ end
158
+
159
+ # TODO: start_from_row is not handled yet
160
+
161
+ if options[:skip_headers]
162
+ header = self.get_table_data main_key_part, table_name, rows = 0
163
+ self.delete_all_table main_key_part, table_name
164
+ self.set_table_data main_key_part, table_name, [], :header_row => header
165
+ else
166
+ self.delete_all_table main_key_part, table_name
167
+ end
168
+ end
169
+
170
+ def set_rows table_name, rows
171
+ rows.each_with_index do |row, idx|
172
+ @redis.zadd("matrix.#{table_name}.rows", idx, row)
173
+ end
174
+ end
175
+
176
+ def append_row table_name, row
177
+ @redis.zadd("matrix.#{table_name}.rows", idx, row)
178
+ end
179
+
180
+ private
181
+ def self.log(operation, message)
182
+ return unless logger && !silence?
183
+ logger.debug("RedisTable: #{operation}: #{message}")
184
+ end
185
+
186
+ end
187
+ end