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 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
@@ -0,0 +1,20 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .rbenv-*
6
+ .ruby-version
7
+ .yardoc
8
+ Gemfile.lock
9
+ InstalledFiles
10
+ _yardoc
11
+ coverage
12
+ doc/
13
+ lib/bundler/man
14
+ pkg
15
+ rdoc
16
+ spec/reports
17
+ test/tmp
18
+ test/version_tmp
19
+ tmp
20
+ .rvmrc
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
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0
4
+ - 2.1.5
5
+ - 2.2.0
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Runtime dependencies in blobby.gemspec
4
+ gemspec
5
+
6
+ # Development/test dependencies below
7
+ gem "rake", "~> 10.0"
8
+ gem "rspec", "~> 3.1"
9
+ gem "sinatra"
10
+ gem "sham_rack", "~> 1.3.5"
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
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require "bundler"
4
+
5
+ Bundler::GemHelper.install_tasks
6
+
7
+ require "rspec/core/rake_task"
8
+
9
+ task "default" => "spec"
10
+
11
+ RSpec::Core::RakeTask.new(:spec) do |t|
12
+ t.rspec_opts = ["--colour"]
13
+ end
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