analysand 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.sw?
3
+ .rbx
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
@@ -0,0 +1,8 @@
1
+ language: ruby
2
+ rvm:
3
+ - rbx-19mode
4
+ - 1.9.3
5
+ before_script:
6
+ - "./script/setup_database.rb"
7
+ services:
8
+ - couchdb
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in analysand.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 David Yip
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,41 @@
1
+ Analysand - a terrible burden for a couch
2
+ -----------------------------------------
3
+
4
+ Analysand is a CouchDB client library of dubious worth. It was extracted from
5
+ https://github.com/amvorg-underground/catalog.
6
+
7
+ Analysand was written for Ruby 1.9. It is known to work on Ruby 1.9.3-p194 and
8
+ Rubinius 2.0.0.
9
+
10
+ Features:
11
+
12
+ * GET, PUT, DELETE on databases
13
+ * GET, PUT, DELETE, HEAD, COPY on documents
14
+ * GET, PUT on document attachments
15
+ * GET on views
16
+ * POST /_session
17
+ * POST /_bulk_docs
18
+ * Celluloid::IO-based change feed watchers
19
+ * Cookie and HTTP Basic authentication for all of the above
20
+ * Database objects can be safely shared across threads
21
+
22
+ Developing Analysand
23
+ --------------------
24
+
25
+ You'll need a CouchDB >= 1.1.0 instance. I recommend not using a CouchDB
26
+ instance that you're using for anything else; Analysand requires the presence
27
+ of specific admin and non-admin users for its test suite.
28
+
29
+ See spec/support/test_parameters.rb for usernames, passwords, and connection
30
+ information.
31
+
32
+ Naturally, we hang with all the cool kids:
33
+
34
+ * Travis CI: http://travis-ci.org/#!/yipdw/analysand
35
+ * Code Climate: https://codeclimate.com/github/yipdw/analysand
36
+ * Gemnasium: https://gemnasium.com/yipdw/analysand
37
+
38
+ License
39
+ -------
40
+
41
+ Copyright 2012 David Yip; made available under the MIT license.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new
7
+
8
+ task :default => :spec
@@ -0,0 +1,30 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/analysand/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["David Yip"]
6
+ gem.email = ["yipdw@member.fsf.org"]
7
+ gem.description = %q{A terrible burden for a couch}
8
+ gem.summary = %q{A CouchDB client of dubious worth}
9
+ gem.homepage = ""
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "analysand"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Analysand::VERSION
17
+
18
+ gem.add_dependency 'celluloid', '>= 0.12'
19
+ gem.add_dependency 'celluloid-io'
20
+ gem.add_dependency 'http_parser.rb'
21
+ gem.add_dependency 'json'
22
+ gem.add_dependency 'net-http-persistent'
23
+ gem.add_dependency 'rack'
24
+ gem.add_dependency 'yajl-ruby'
25
+
26
+ gem.add_development_dependency 'rake'
27
+ gem.add_development_dependency 'rspec'
28
+ gem.add_development_dependency 'vcr'
29
+ gem.add_development_dependency 'webmock'
30
+ end
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
4
+
5
+ require 'analysand/database'
6
+ require 'irb'
7
+ require 'uri'
8
+
9
+ $URI = URI('http://localhost:5984/analysand_test')
10
+
11
+
12
+ def make_db(uri = $URI)
13
+ Analysand::Database.new(uri)
14
+ end
15
+
16
+ puts <<-END
17
+ ------------------------------------------------------------------------------
18
+ Type make_db to make an Analysand::Database object. The default URI is
19
+
20
+ #{$URI}
21
+
22
+ To point at different databases, supply a URI object to make_db, e.g.
23
+
24
+ make_db(URI('https://couchdb.example.org:6984/supersekrit'))"
25
+ ------------------------------------------------------------------------------
26
+ END
27
+
28
+ IRB.start
@@ -0,0 +1,5 @@
1
+ require "analysand/version"
2
+
3
+ module Analysand
4
+ # Your code goes here...
5
+ end
@@ -0,0 +1,14 @@
1
+ require 'analysand/response'
2
+
3
+ module Analysand
4
+ ##
5
+ # A subclass of Response that adjusts success? to check for individual error
6
+ # records.
7
+ class BulkResponse < Response
8
+ def success?
9
+ body.none? { |r| r.has_key?('error') }
10
+ end
11
+ end
12
+ end
13
+
14
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,276 @@
1
+ require 'celluloid'
2
+ require 'celluloid/io'
3
+ require 'analysand/connection_testing'
4
+ require 'http/parser'
5
+ require 'net/http'
6
+ require 'rack/utils'
7
+ require 'uri'
8
+ require 'yajl'
9
+
10
+ module Analysand
11
+ ##
12
+ # A Celluloid::IO actor that watches the changes feed of a CouchDB database.
13
+ # When a change is received, it passes the change to a #process method.
14
+ #
15
+ # ChangeWatchers monitor changes using continuous mode and set up a heartbeat
16
+ # to fire approximately every 10 seconds.
17
+ #
18
+ # ChangeWatchers begin watching for changes as soon as they are initialized.
19
+ # To send a shutdown message:
20
+ #
21
+ # a.stop
22
+ #
23
+ # The watcher will terminate on the next heartbeat.
24
+ #
25
+ #
26
+ # Failure modes
27
+ # =============
28
+ #
29
+ # ChangeWatcher deals with the following failures in the following ways:
30
+ #
31
+ # * If Errno::ECONNREFUSED is raised whilst connecting to CouchDB, it will
32
+ # retry the connection in 30 seconds.
33
+ # * If the connection to CouchDB's changes feed is abruptly terminated, it
34
+ # dies.
35
+ # * If an exception is raised during HTTP or JSON parsing, it dies.
36
+ #
37
+ # Situations where the actor dies should be handled by a supervisor.
38
+ #
39
+ #
40
+ # Example usage
41
+ # =============
42
+ #
43
+ # class Accumulator < Analysand::ChangeWatcher
44
+ # attr_accessor :results
45
+ #
46
+ # def initialize(database)
47
+ # super(database)
48
+ #
49
+ # self.results = []
50
+ # end
51
+ #
52
+ # def process(change)
53
+ # results << change
54
+ #
55
+ # # Once a ChangeWatcher has successfully processed a change, it
56
+ # # SHOULD invoke #change_processed.
57
+ # change_processed(change)
58
+ # end
59
+ # end
60
+ #
61
+ # a = Accumulator.new('http://localhost:5984/mydb')
62
+ #
63
+ # # or with supervision:
64
+ # a = Accumulator.supervise('http://localhost:5984/mydb')
65
+ class ChangeWatcher
66
+ include Celluloid::IO
67
+ include Celluloid::Logger
68
+ include ConnectionTesting
69
+ include Rack::Utils
70
+
71
+ # Read at most this many bytes off the socket at a time.
72
+ QUANTUM = 4096
73
+
74
+ ##
75
+ # Checks services. If all services pass muster, enters a read loop.
76
+ #
77
+ # The database parameter may be either a URL-as-string or a
78
+ # Analysand::Database.
79
+ #
80
+ # If overriding the initializer, you MUST call super.
81
+ def initialize(database)
82
+ @db = database
83
+ @waiting = {}
84
+ @http_parser = Http::Parser.new(self)
85
+ @json_parser = Yajl::Parser.new
86
+ @json_parser.on_parse_complete = lambda { |doc| process(doc) }
87
+
88
+ async.start
89
+ end
90
+
91
+ # The URI of the changes feed. This URI incorporates any changes
92
+ # made by customize_query.
93
+ def changes_feed_uri
94
+ query = {
95
+ 'feed' => 'continuous',
96
+ 'heartbeat' => '10000'
97
+ }
98
+
99
+ customize_query(query)
100
+
101
+ uri = (@db.respond_to?(:uri) ? @db.uri : URI(@db)).dup
102
+ uri.path += '/_changes'
103
+ uri.query = build_query(query)
104
+ uri
105
+ end
106
+
107
+ # The connection_ok method is called before connecting to the changes feed.
108
+ # By default, it checks that there's an HTTP service listening on the
109
+ # changes feed.
110
+ #
111
+ # If the method returns true, then we connect to the changes feed and begin
112
+ # processing. If it returns false, a warning message is logged and the
113
+ # connection check will be retried in 30 seconds.
114
+ #
115
+ # This method can be overridden if you need to check additional services.
116
+ # When you override the method, make sure that you don't discard the return
117
+ # value of the original definition:
118
+ #
119
+ # # Wrong
120
+ # def connection_ok
121
+ # super
122
+ # ...
123
+ # end
124
+ #
125
+ # # Right
126
+ # def connection_ok
127
+ # ok = super
128
+ #
129
+ # ok && my_other_test
130
+ # end
131
+ def connection_ok
132
+ test_http_connection(changes_feed_uri)
133
+ end
134
+
135
+ def start
136
+ return if @started
137
+
138
+ @started = true
139
+
140
+ while !connection_ok
141
+ error "Some services used by #{self.class.name} did not check out ok; will retry in 30 seconds"
142
+ sleep 30
143
+ end
144
+
145
+ connect
146
+
147
+ info "#{self.class} entering read loop"
148
+
149
+ @running = true
150
+
151
+ while @running
152
+ @http_parser << @socket.readpartial(QUANTUM)
153
+ end
154
+
155
+ # Once we're done, close things up.
156
+ @started = false
157
+ @socket.close
158
+ end
159
+
160
+ def stop
161
+ @running = false
162
+ end
163
+
164
+ ##
165
+ # Called by Celluloid::IO's actor shutdown code.
166
+ def finalize
167
+ @socket.close if @socket && !@socket.closed?
168
+ end
169
+
170
+ ##
171
+ # Can be used to set query parameters. query is a Hash. The query hash
172
+ # has two default parameters:
173
+ #
174
+ # | Key | Value |
175
+ # | feed | continuous |
176
+ # | heartbeat | 10000 |
177
+ #
178
+ # It is NOT RECOMMENDED that they be changed.
179
+ #
180
+ # By default, this does nothing. Provide behavior in a subclass.
181
+ def customize_query(query)
182
+ end
183
+
184
+ ##
185
+ # Can be used to add headers. req is a Net::HTTP::Get instance.
186
+ #
187
+ # By default, this does nothing. Provide behavior in a subclass.
188
+ def customize_request(req)
189
+ end
190
+
191
+ ##
192
+ # This method should implement your change-processing logic.
193
+ #
194
+ # change is a Hash containing keys id, seq, and changes. See [0] for
195
+ # more information.
196
+ #
197
+ # By default, this does nothing. Provide behavior in a subclass.
198
+ #
199
+ # [0]: http://guide.couchdb.org/draft/notifications.html#continuous
200
+ def process(change)
201
+ end
202
+
203
+ class Waiter < Celluloid::Future
204
+ alias_method :wait, :value
205
+ end
206
+
207
+ ##
208
+ # Returns an object that can be used to block a thread until a document
209
+ # with the given ID has been processed.
210
+ #
211
+ # Intended for testing.
212
+ def waiter_for(id)
213
+ @waiting[id] = true
214
+
215
+ Waiter.new do
216
+ loop do
217
+ break true if !@waiting[id]
218
+ sleep 0.1
219
+ end
220
+ end
221
+ end
222
+
223
+ ##
224
+ # Notify waiters.
225
+ def change_processed(change)
226
+ @waiting.delete(change['id'])
227
+ end
228
+
229
+ ##
230
+ # Http::Parser callback.
231
+ #
232
+ # @private
233
+ def on_headers_complete(parser)
234
+ status = @http_parser.status_code.to_i
235
+
236
+ raise "Request failed: expected status 200, got #{status}" unless status == 200
237
+ end
238
+
239
+ ##
240
+ # Http::Parser callback.
241
+ #
242
+ # @private
243
+ def on_body(chunk)
244
+ @json_parser << chunk
245
+ end
246
+
247
+ ##
248
+ # @private
249
+ def connect
250
+ req = prepare_request
251
+ uri = changes_feed_uri
252
+
253
+ info "#{self.class} connecting to #{req.path}"
254
+
255
+ @socket = TCPSocket.new(uri.host, uri.port)
256
+
257
+ # Make the request.
258
+ data = [
259
+ "GET #{req.path} HTTP/1.1"
260
+ ]
261
+
262
+ req.each_header { |k, v| data << "#{k}: #{v}" }
263
+
264
+ @socket.write(data.join("\r\n"))
265
+ @socket.write("\r\n\r\n")
266
+ end
267
+
268
+ ##
269
+ # @private
270
+ def prepare_request
271
+ Net::HTTP::Get.new(changes_feed_uri.to_s).tap do |req|
272
+ customize_request(req)
273
+ end
274
+ end
275
+ end
276
+ end