blobby 1.0.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 +7 -0
- data/.gitignore +20 -0
- data/.rspec +1 -0
- data/.rubocop.yml +47 -0
- data/.travis.yml +5 -0
- data/Gemfile +10 -0
- data/README.md +43 -0
- data/Rakefile +13 -0
- data/blobby.gemspec +20 -0
- data/lib/blobby/composite_store.rb +64 -0
- data/lib/blobby/fake_success_store.rb +52 -0
- data/lib/blobby/filesystem_store.rb +133 -0
- data/lib/blobby/http_store.rb +135 -0
- data/lib/blobby/in_memory_store.rb +64 -0
- data/lib/blobby/key_constraint.rb +34 -0
- data/lib/blobby/key_transforming_store.rb +25 -0
- data/lib/blobby/logging_store.rb +67 -0
- data/lib/blobby/placeholder.png +0 -0
- data/lib/blobby/version.rb +3 -0
- data/spec/blobby/composite_store_spec.rb +54 -0
- data/spec/blobby/fake_success_store_spec.rb +134 -0
- data/spec/blobby/filesystem_store_spec.rb +206 -0
- data/spec/blobby/http_store_spec.rb +206 -0
- data/spec/blobby/in_memory_store_spec.rb +12 -0
- data/spec/blobby/key_transforming_store_spec.rb +102 -0
- data/spec/blobby/logging_store_spec.rb +45 -0
- data/spec/blobby/store_behaviour.rb +174 -0
- metadata +77 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4a0b96e9f498791cc7df4c6dfe2a16fe3333495f
|
4
|
+
data.tar.gz: c45aa9cff2b917fed75bb174a7ca02482b39ff4e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6e1643a9675acfdfa5a64ea97a8d1e7fd98557f150b243dd75838be5a5ddbe9a7b8e8a312b5c4560b0097eef0f99423c94a1a5e359e5b5b7b7f3365d5c71fc61
|
7
|
+
data.tar.gz: 5a010f2432a9a6dd3caa22e5eed35b8b6986180c5b6ace5ca2d49c4b2f52d9d1100a8965913c4d693d6464d49f8c3c9a86cf3598526755ba86f15e4a38ea5b58
|
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
Eval:
|
2
|
+
Exclude:
|
3
|
+
- "Rakefile"
|
4
|
+
|
5
|
+
Metrics/AbcSize:
|
6
|
+
Enabled: false
|
7
|
+
|
8
|
+
Metrics/LineLength:
|
9
|
+
Max: 120
|
10
|
+
|
11
|
+
Metrics/MethodLength:
|
12
|
+
Max: 30
|
13
|
+
|
14
|
+
Style/ClassAndModuleChildren:
|
15
|
+
EnforcedStyle: nested
|
16
|
+
Exclude:
|
17
|
+
- "spec/**/*"
|
18
|
+
|
19
|
+
Style/Documentation:
|
20
|
+
Exclude:
|
21
|
+
- "spec/**/*"
|
22
|
+
|
23
|
+
Style/EmptyLinesAroundBlockBody:
|
24
|
+
Enabled: false
|
25
|
+
|
26
|
+
Style/EmptyLinesAroundClassBody:
|
27
|
+
EnforcedStyle: empty_lines
|
28
|
+
|
29
|
+
Style/EmptyLinesAroundModuleBody:
|
30
|
+
Enabled: false
|
31
|
+
|
32
|
+
Style/Encoding:
|
33
|
+
EnforcedStyle: when_needed
|
34
|
+
Enabled: true
|
35
|
+
|
36
|
+
Style/FileName:
|
37
|
+
Exclude:
|
38
|
+
- "bin/*"
|
39
|
+
|
40
|
+
Style/HashSyntax:
|
41
|
+
EnforcedStyle: hash_rockets
|
42
|
+
|
43
|
+
Style/StringLiterals:
|
44
|
+
EnforcedStyle: double_quotes
|
45
|
+
|
46
|
+
Style/WordArray:
|
47
|
+
Enabled: false
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# Blobby
|
2
|
+
|
3
|
+
[](https://travis-ci.org/realestate-com-au/blobby)
|
4
|
+
|
5
|
+
This gem provides a standard interface for storing big chunks of data.
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
|
9
|
+
It supports popular BLOBs operations such as reading:
|
10
|
+
|
11
|
+
store["key"].read
|
12
|
+
|
13
|
+
writing:
|
14
|
+
|
15
|
+
store["key"].write("some content")
|
16
|
+
store["key"].write(File.open("kitty.png"))
|
17
|
+
|
18
|
+
checking for existance:
|
19
|
+
|
20
|
+
store["key"].exists?
|
21
|
+
|
22
|
+
and even deleting:
|
23
|
+
|
24
|
+
store["key"].delete
|
25
|
+
|
26
|
+
This gem provides several "store" implementations:
|
27
|
+
|
28
|
+
# on disk
|
29
|
+
Blobby::FilesystemStore.new("/big/data")
|
30
|
+
|
31
|
+
# in memory
|
32
|
+
Blobby::InMemoryStore.new
|
33
|
+
|
34
|
+
# generic HTTP
|
35
|
+
Blobby::HttpStore.new("http://attachment-store/objects")
|
36
|
+
|
37
|
+
# fake success
|
38
|
+
Blobby::FakeSuccessStore.new
|
39
|
+
|
40
|
+
Other gems provide additional implementations:
|
41
|
+
|
42
|
+
# gem "blobby-s3"
|
43
|
+
Blobby::S3Store.new("mybucket")
|
data/Rakefile
ADDED
data/blobby.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
$LOAD_PATH << File.expand_path("../lib", __FILE__)
|
4
|
+
require "blobby/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
|
8
|
+
gem.authors = ["Mike Williams"]
|
9
|
+
gem.email = ["mdub@dogbiscuit.org"]
|
10
|
+
gem.summary = "Various ways of storing BLOBs"
|
11
|
+
gem.homepage = "https://github.com/realestate-com.au/blobby"
|
12
|
+
|
13
|
+
gem.files = `git ls-files`.split($OUTPUT_RECORD_SEPARATOR)
|
14
|
+
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
15
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
16
|
+
gem.name = "blobby"
|
17
|
+
gem.require_paths = ["lib"]
|
18
|
+
gem.version = Blobby::VERSION
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Blobby
|
2
|
+
|
3
|
+
# Compose a number of stores.
|
4
|
+
#
|
5
|
+
# Writes go to all stores. Reads use the first store to respond.
|
6
|
+
#
|
7
|
+
class CompositeStore
|
8
|
+
|
9
|
+
def initialize(stores)
|
10
|
+
@stores = stores
|
11
|
+
end
|
12
|
+
|
13
|
+
def [](key)
|
14
|
+
KeyConstraint.must_allow!(key)
|
15
|
+
objects = stores.map { |store| store[key] }
|
16
|
+
StoredObject.new(objects)
|
17
|
+
end
|
18
|
+
|
19
|
+
def available?
|
20
|
+
stores.all?(&:available?)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :stores
|
26
|
+
|
27
|
+
class StoredObject
|
28
|
+
|
29
|
+
def initialize(objects)
|
30
|
+
@objects = objects
|
31
|
+
end
|
32
|
+
|
33
|
+
def exists?
|
34
|
+
objects.any?(&:exists?)
|
35
|
+
end
|
36
|
+
|
37
|
+
def read(&block)
|
38
|
+
objects.each do |o|
|
39
|
+
return o.read(&block) if o.exists?
|
40
|
+
end
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def write(content)
|
45
|
+
content = content.read if content.respond_to?(:read)
|
46
|
+
objects.each do |o|
|
47
|
+
o.write(content)
|
48
|
+
end
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete
|
53
|
+
objects.all?(&:delete)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
attr_reader :objects
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "blobby/key_constraint"
|
2
|
+
|
3
|
+
module Blobby
|
4
|
+
|
5
|
+
# A BLOB store that is always successful.
|
6
|
+
#
|
7
|
+
class FakeSuccessStore
|
8
|
+
|
9
|
+
def available?
|
10
|
+
true
|
11
|
+
end
|
12
|
+
|
13
|
+
def [](key)
|
14
|
+
KeyConstraint.must_allow!(key)
|
15
|
+
StoredObject.new
|
16
|
+
end
|
17
|
+
|
18
|
+
class StoredObject
|
19
|
+
|
20
|
+
def exists?
|
21
|
+
true
|
22
|
+
end
|
23
|
+
|
24
|
+
def read
|
25
|
+
image_path = Pathname(File.dirname(__FILE__)) + "placeholder.png"
|
26
|
+
image_path.open("rb") do |io|
|
27
|
+
if block_given?
|
28
|
+
while chunk = io.read(512)
|
29
|
+
yield chunk
|
30
|
+
end
|
31
|
+
nil
|
32
|
+
else
|
33
|
+
io.read
|
34
|
+
end
|
35
|
+
end
|
36
|
+
rescue Errno::ENOENT
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def write(_content)
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def delete
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require "blobby/key_constraint"
|
2
|
+
require "fileutils"
|
3
|
+
require "forwardable"
|
4
|
+
require "pathname"
|
5
|
+
require "tempfile"
|
6
|
+
|
7
|
+
module Blobby
|
8
|
+
|
9
|
+
# A BLOB store backed by a file-system.
|
10
|
+
#
|
11
|
+
class FilesystemStore
|
12
|
+
|
13
|
+
def initialize(dir, options = {}, &sharding_strategy)
|
14
|
+
@dir = Pathname(dir)
|
15
|
+
@umask = options[:umask] || File.umask
|
16
|
+
@sharding_strategy = sharding_strategy || noop_sharding_strategy
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :dir
|
20
|
+
attr_reader :umask
|
21
|
+
|
22
|
+
def available?
|
23
|
+
dir.directory? && dir.readable? && dir.writable?
|
24
|
+
end
|
25
|
+
|
26
|
+
def [](key)
|
27
|
+
KeyConstraint.must_allow!(key)
|
28
|
+
relative_path = @sharding_strategy.call(key)
|
29
|
+
StoredObject.new(dir + relative_path, umask)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def noop_sharding_strategy
|
35
|
+
->(key) { key }
|
36
|
+
end
|
37
|
+
|
38
|
+
class StoredObject
|
39
|
+
|
40
|
+
def initialize(path, umask)
|
41
|
+
@path = path
|
42
|
+
@umask = umask
|
43
|
+
end
|
44
|
+
|
45
|
+
extend Forwardable
|
46
|
+
|
47
|
+
def_delegator :@path, :exist?, :exists?
|
48
|
+
|
49
|
+
def read
|
50
|
+
@path.open("rb") do |io|
|
51
|
+
if block_given?
|
52
|
+
while chunk = io.read(512)
|
53
|
+
yield chunk
|
54
|
+
end
|
55
|
+
nil
|
56
|
+
else
|
57
|
+
io.read
|
58
|
+
end
|
59
|
+
end
|
60
|
+
rescue Errno::ENOENT
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
|
64
|
+
def write(content)
|
65
|
+
atomic_create(@path) do |out|
|
66
|
+
if content.respond_to?(:read)
|
67
|
+
FileUtils.copy_stream(content, out)
|
68
|
+
else
|
69
|
+
out << content
|
70
|
+
end
|
71
|
+
end
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
|
75
|
+
def delete
|
76
|
+
!!FileUtils.rm(@path)
|
77
|
+
rescue Errno::ENOENT
|
78
|
+
false
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def apply_umask(mode)
|
84
|
+
mode & ~@umask
|
85
|
+
end
|
86
|
+
|
87
|
+
def using_default_umask?
|
88
|
+
@umask == File.umask
|
89
|
+
end
|
90
|
+
|
91
|
+
RAND_MAX = ("F" * 10).to_i(16)
|
92
|
+
|
93
|
+
def tmp_name
|
94
|
+
sprintf("tmp-%X", rand(RAND_MAX))
|
95
|
+
end
|
96
|
+
|
97
|
+
def atomic_create(store_path)
|
98
|
+
store_dir = store_path.parent
|
99
|
+
tmp_path = store_dir + tmp_name
|
100
|
+
|
101
|
+
tmp = nil
|
102
|
+
begin
|
103
|
+
tmp = tmp_path.open(File::CREAT | File::EXCL | File::WRONLY, 0666)
|
104
|
+
tmp.binmode
|
105
|
+
rescue Errno::ENOENT => e
|
106
|
+
FileUtils.mkdir_p(store_dir.to_s, :mode => apply_umask(0777))
|
107
|
+
retry
|
108
|
+
end
|
109
|
+
|
110
|
+
begin
|
111
|
+
yield tmp
|
112
|
+
tmp.chmod(apply_umask(0666)) unless using_default_umask?
|
113
|
+
ensure
|
114
|
+
tmp.close
|
115
|
+
end
|
116
|
+
|
117
|
+
first_try = true
|
118
|
+
begin
|
119
|
+
tmp_path.rename(store_path)
|
120
|
+
rescue Errno::ESTALE => e
|
121
|
+
raise unless first_try
|
122
|
+
first_try = false
|
123
|
+
now = Time.now
|
124
|
+
File.utime(now, now, store_dir.to_s)
|
125
|
+
retry
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require "blobby/key_constraint"
|
2
|
+
require "net/http"
|
3
|
+
|
4
|
+
module Blobby
|
5
|
+
|
6
|
+
# A BLOB store backed by HTTP.
|
7
|
+
#
|
8
|
+
class HttpStore
|
9
|
+
|
10
|
+
def initialize(base_url, options = {})
|
11
|
+
@base_url = base_url
|
12
|
+
@base_url += "/" unless @base_url.end_with?("/")
|
13
|
+
@max_retries = options.fetch(:max_retries, 2)
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_reader :base_url
|
17
|
+
attr_reader :max_retries
|
18
|
+
|
19
|
+
def available?
|
20
|
+
with_http_connection do
|
21
|
+
true
|
22
|
+
end
|
23
|
+
rescue
|
24
|
+
false
|
25
|
+
end
|
26
|
+
|
27
|
+
def [](key)
|
28
|
+
KeyConstraint.must_allow!(key)
|
29
|
+
StoredObject.new(self, key)
|
30
|
+
end
|
31
|
+
|
32
|
+
def base_uri
|
33
|
+
URI(base_url)
|
34
|
+
end
|
35
|
+
|
36
|
+
def with_http_connection
|
37
|
+
remaining_retry_intervals = retry_intervals(max_retries)
|
38
|
+
begin
|
39
|
+
Net::HTTP.start(base_uri.host, base_uri.port) do |http|
|
40
|
+
yield http, base_uri.path
|
41
|
+
end
|
42
|
+
rescue *retryable_exceptions => e
|
43
|
+
raise e if remaining_retry_intervals.empty?
|
44
|
+
sleep(remaining_retry_intervals.shift) && retry
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
protected
|
49
|
+
|
50
|
+
def retryable_exceptions
|
51
|
+
[EOFError, Errno::ECONNRESET]
|
52
|
+
end
|
53
|
+
|
54
|
+
def retry_intervals(n)
|
55
|
+
# exponential backoff: [0.5, 1, 2, 4, 8, ...]
|
56
|
+
scaling_factor = (0.5 + Kernel.rand * 0.1) # a little random avoids throbbing
|
57
|
+
Array.new(n) { |i| (2**i) * scaling_factor }
|
58
|
+
end
|
59
|
+
|
60
|
+
class StoredObject
|
61
|
+
|
62
|
+
def initialize(store, key)
|
63
|
+
@store = store
|
64
|
+
@key = key
|
65
|
+
end
|
66
|
+
|
67
|
+
attr_reader :key
|
68
|
+
|
69
|
+
def exists?
|
70
|
+
with_http_connection do |http, path|
|
71
|
+
response = http.head(path)
|
72
|
+
response.code == "200"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def read(&block)
|
77
|
+
with_http_connection do |http, path|
|
78
|
+
http.request_get(path) do |response|
|
79
|
+
case response
|
80
|
+
when Net::HTTPNotFound then
|
81
|
+
return nil
|
82
|
+
when Net::HTTPSuccess then
|
83
|
+
if block_given?
|
84
|
+
response.read_body(&block)
|
85
|
+
return nil
|
86
|
+
else
|
87
|
+
return response.read_body
|
88
|
+
end
|
89
|
+
end
|
90
|
+
response.error!
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def write(content)
|
96
|
+
content = content.read if content.respond_to?(:read)
|
97
|
+
with_http_connection do |http, path|
|
98
|
+
put = Net::HTTP::Put.new(path)
|
99
|
+
put.body = content
|
100
|
+
put["Content-Type"] = "application/octet-stream"
|
101
|
+
response = http.request(put)
|
102
|
+
response.error! unless response.is_a?(Net::HTTPSuccess)
|
103
|
+
true
|
104
|
+
end
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
def delete
|
109
|
+
with_http_connection do |http, path|
|
110
|
+
delete = Net::HTTP::Delete.new(path)
|
111
|
+
response = http.request(delete)
|
112
|
+
case response
|
113
|
+
when Net::HTTPSuccess then
|
114
|
+
true
|
115
|
+
when Net::HTTPNotFound then
|
116
|
+
false
|
117
|
+
else
|
118
|
+
response.error!
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def with_http_connection
|
126
|
+
@store.with_http_connection do |http, base_path|
|
127
|
+
yield http, base_path + key
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|