blobby 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://travis-ci.org/realestate-com-au/blobby.svg?branch=master)](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
|