dolly 3.1.5 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c05943aabfd347226c64d622f7e2f872ad8f1f78e52e1229697a3a60364a5e53
4
- data.tar.gz: 7a64b93ef1232cec80ca842f9d4e4ebb5dd1e260b967549d8b6ec0d12b0e929d
3
+ metadata.gz: 7a6b7aaee7e192101dddab520fbdc363a6e7379d1a52f08c36b3c21fe1371f21
4
+ data.tar.gz: 34e0686cb8726fc60d69f0c84457664c07e86fbbfec579aa0a56ad84c131ce1c
5
5
  SHA512:
6
- metadata.gz: a50e0365e3dabf0620e6f0c2d13d57f029709616d02733758f2bb1317ef0d4906c4ec999dfef81f5dc95f94d94997dba36004ed1f3a7bf4f0be59f51895fe578
7
- data.tar.gz: ff4faa8af117ef82db9c428387a0d31d24dd1d59f3e831d41fe4399ef03faf5ea710ac732bc39108a710191645fefdd31a5284a7448803de5dd1786893b704c0
6
+ metadata.gz: 6b43b48ccd6bfd844c68712dbd008c4e75cdff27d43c6c0fa885174e35cd170135b9975a352042a1382b29efa587e120648095a6971d089528d06d1227b4eec2
7
+ data.tar.gz: 374986fde329b1519c228a11fa844f140d8a7cf718b54d04b10f39c6071944bfe5852e2699bffdc34768a43720b4b46c52ad11d48dc2b6f481c8756d0767228a
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Dolly3
1
+ # Dolly
2
2
 
3
- The thing you move your couch with...
3
+ CouchDB adapter for Ruby. The thing you move your couch with...
4
4
 
5
5
  ![example workflow](https://github.com/amco/dolly/actions/workflows/ruby.yml/badge.svg)
6
6
 
@@ -9,7 +9,7 @@ The thing you move your couch with...
9
9
  Add this line to your application's Gemfile:
10
10
 
11
11
  ```ruby
12
- gem 'dolly3'
12
+ gem 'dolly'
13
13
  ```
14
14
 
15
15
  And then execute:
@@ -18,61 +18,112 @@ And then execute:
18
18
 
19
19
  Or install it yourself as:
20
20
 
21
- $ gem install dolly3
21
+ $ gem install dolly
22
+
23
+ Configure CouchDB in `config/couchdb.yml` (see the [sample config](config/couchdb.yml) in this repo).
22
24
 
23
25
  ## Usage
24
26
 
25
- TODO: Write usage instructions here
27
+ Define documents as usual:
26
28
 
27
- ## Development
29
+ ```ruby
30
+ class FooBar < Dolly::Document
31
+ property :foo, :bar
32
+ timestamps!
33
+ end
28
34
 
29
- After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
35
+ doc = FooBar.create(foo: 'a', bar: 'b')
36
+ FooBar.find(doc.id)
37
+ ```
30
38
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
39
+ The public `Dolly::Connection` API (`get`, `post`, `put`, `delete`, `request`, `view`, `attach`, `uuids`, etc.) is unchanged for application code.
32
40
 
33
- ## Contributing
41
+ ### Connection reuse (Curb)
34
42
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/dolly3.
43
+ Dolly uses [Curb](https://github.com/taf2/curb) under the hood. Newer Curb releases keep HTTP connections alive, which previously leaked one TCP socket per Dolly request and could exhaust CouchDB's accept backlog during bursts (Sidekiq jobs, seeds, Passenger workers).
36
44
 
45
+ Dolly now reuses **one `Curl::Easy` handle per thread** via internal `Dolly::Curl`
46
+ collaborators (`Connection` for transport/retries, `WriteReconciler` for ambiguous writes):
37
47
 
38
- ## Migrating from couch 1.x to 2.x
48
+ - Persistent keep-alive to CouchDB without opening a new socket on every call
49
+ - Safe for typical **Passenger** (one request per thread/process) and **Sidekiq** (one job per thread) deployments
50
+ - Not safe under fiber-based servers that interleave multiple Dolly requests on a single thread
39
51
 
40
- [Official docs](https://docs.couchdb.org/en/2.3.1/install/index.html)
52
+ ### Stale keep-alive handling
53
+
54
+ If CouchDB closes an idle keep-alive socket, Curb may raise a transport error (`GotNothingError`, `RecvError`, `SendError`, `PartialFileError`). Dolly always discards the dead handle, then:
55
+
56
+ | Request | Behavior |
57
+ |---------|----------|
58
+ | `GET`, `HEAD` | Retry once on a fresh connection |
59
+ | Read-only `POST` (`/_find`, `/_all_docs`) | Retry once on a fresh connection |
60
+ | Document `PUT` | Reconcile by reading the document; succeed if content matches, retry once if the write clearly did not land, otherwise raise |
61
+ | Document `DELETE` (with `rev`) | Reconcile by reading the document; succeed if gone/deleted, retry once if the same rev is still present, otherwise raise |
62
+ | `POST /_bulk_docs` | Reconcile via `/_all_docs`; retry only documents that clearly did not apply |
63
+ | Attachments and other writes | Do **not** blind-retry; raise |
64
+
65
+ ### `Dolly::AmbiguousWriteError`
66
+
67
+ Raised when a write may or may not have been applied (transport failed after the request was possibly processed, and reconciliation cannot prove the outcome).
41
68
 
69
+ ```ruby
70
+ begin
71
+ doc.save!
72
+ rescue Dolly::AmbiguousWriteError => e
73
+ e.method # => :put
74
+ e.uri # => #<URI::HTTP ...>
75
+ e.cause # => original Curl::Err::*
76
+ end
77
+ ```
42
78
 
43
- You will need to uninstall the couchdb service with brew
79
+ Handle this in jobs/seeds by reloading or re-checking the document before retrying the business operation.
44
80
 
45
- `brew services stop couchdb`
46
- `brew services uninstall couchdb`
81
+ ## Development
47
82
 
48
- Download the application from the following source
83
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
49
84
 
50
- http://couchdb.apache.org/#download
85
+ ```bash
86
+ bundle exec rake test
87
+ ```
51
88
 
52
- launch fauxton and check your installation
89
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
53
90
 
54
- Copy [this file](https://github.com/apache/couchdb/blob/master/rel/overlay/bin/couchup) into your filesystem
91
+ ## Contributing
55
92
 
93
+ Bug reports and pull requests are welcome on GitHub at https://github.com/amco/dolly.
56
94
 
57
- make it executable
95
+ ## Migrating from CouchDB 1.x to 2.x
58
96
 
59
- `chmod +x couchup.py`
97
+ [Official docs](https://docs.couchdb.org/en/2.3.1/install/index.html)
60
98
 
61
- and run it
99
+ You will need to uninstall the couchdb service with brew:
62
100
 
63
- `./couchup.py -h`
101
+ ```bash
102
+ brew services stop couchdb
103
+ brew services uninstall couchdb
104
+ ```
64
105
 
65
- You might need to install python 3 and pip3 and the following libs
106
+ Download the application from http://couchdb.apache.org/#download, launch Fauxton, and check your installation.
66
107
 
67
- `pip3 install requests progressbar2`
108
+ Copy [this file](https://github.com/apache/couchdb/blob/master/rel/overlay/bin/couchup) into your filesystem, make it executable, and run it:
68
109
 
69
- move your .couch files into the specified `database_dir` in your [fauxton config](http://127.0.0.1:5984/_utils/#_config/couchdb@localhost)
110
+ ```bash
111
+ chmod +x couchup.py
112
+ ./couchup.py -h
113
+ ```
70
114
 
115
+ You might need Python 3, pip3, and:
71
116
 
117
+ ```bash
118
+ pip3 install requests progressbar2
72
119
  ```
73
- $ ./couchup list # Shows your unmigrated 1.x databases
74
- $ ./couchup replicate -a # Replicates your 1.x DBs to 2.x
75
- $ ./couchup rebuild -a # Optional; starts rebuilding your views
76
- $ ./couchup delete -a # Deletes your 1.x DBs (careful!)
77
- $ ./couchup list # Should show no remaining databases!
120
+
121
+ Move your `.couch` files into the specified `database_dir` in your [Fauxton config](http://127.0.0.1:5984/_utils/#_config/couchdb@localhost), then:
122
+
123
+ ```bash
124
+ ./couchup list # Shows your unmigrated 1.x databases
125
+ ./couchup replicate -a # Replicates your 1.x DBs to 2.x
126
+ ./couchup rebuild -a # Optional; starts rebuilding your views
127
+ ./couchup delete -a # Deletes your 1.x DBs (careful!)
128
+ ./couchup list # Should show no remaining databases!
78
129
  ```
@@ -1,19 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'curb'
4
- require 'oj'
5
3
  require 'cgi'
6
4
  require 'net/http'
7
5
  require 'dolly/request_header'
8
6
  require 'dolly/exceptions'
9
7
  require 'dolly/configuration'
8
+ require 'dolly/curl'
10
9
  require 'refinements/string_refinements'
11
- require 'dolly/framework_helper'
12
10
 
13
11
  module Dolly
14
12
  class Connection
15
13
  include Dolly::Configuration
16
- include Dolly::FrameworkHelper
14
+ include Dolly::Curl::ResponseFormatter
17
15
  attr_reader :db, :app_env
18
16
 
19
17
  DEFAULT_HEADER = { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
@@ -78,7 +76,7 @@ module Dolly
78
76
  body = fetch_body(data)
79
77
  uri = URI("#{base_uri}#{db_resource}")
80
78
 
81
- conn = curl_method_call(method, uri, body) do |curl|
79
+ conn = curl_connection.request(method, uri, body) do |curl|
82
80
  if env['username'] && !env['username'].empty?
83
81
  curl.http_auth_types = :basic
84
82
  curl.username = env['username']
@@ -88,11 +86,20 @@ module Dolly
88
86
  headers.each { |k, v| curl.headers[k] = v } unless !headers || headers.empty?
89
87
  end
90
88
 
89
+ return conn unless conn.is_a?(::Curl::Easy)
90
+
91
91
  response_format(conn, method)
92
92
  end
93
93
 
94
94
  private
95
95
 
96
+ def curl_connection
97
+ @curl_connection ||= Dolly::Curl::Connection.new(
98
+ db_name: db_name,
99
+ reader: Dolly::Curl::Reader.new(self)
100
+ )
101
+ end
102
+
96
103
  def fetch_headers(data)
97
104
  return unless data.is_a?(Hash)
98
105
  Dolly::HeaderRequest.new(data&.delete(:headers))
@@ -105,28 +112,6 @@ module Dolly
105
112
  data&.merge!(data&.delete(:query) || {})
106
113
  end
107
114
 
108
- def curl_method_call(method, uri, data, &block)
109
- return Curl::Easy.http_head(uri.to_s, &block) if method.to_sym == :head
110
- return Curl.delete(uri.to_s, &block) if method.to_sym == :delete
111
- return Curl.send(method, uri, data, &block) if method.to_sym == :get
112
- Curl.send(method, uri.to_s, data.to_json, &block)
113
- end
114
-
115
- def response_format(res, method)
116
- raise Dolly::ResourceNotFound if res.status.to_i == 404
117
- raise Dolly::ServerError.new(res.status.to_i) if (400..600).include? res.status.to_i
118
- return res.header_str if method == :head
119
-
120
- data = Oj.load(res.body_str, symbol_keys: true)
121
- return data unless rails?
122
- return data.with_indifferent_access if data.is_a?(Hash)
123
- data
124
- rescue Oj::ParseError
125
- res.body_str
126
- ensure
127
- GC.start if res&.body_str&.length&.to_i > 250000
128
- end
129
-
130
115
  def values_to_json hash
131
116
  hash.each_with_object({}) { |(k,v), h| h[k] = v.is_a?(Numeric) ? v : v.to_json }
132
117
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curb'
4
+ require 'cgi'
5
+ require 'dolly/curl/stale_connection'
6
+ require 'dolly/curl/write_reconciler'
7
+
8
+ module Dolly
9
+ module Curl
10
+ # Thread-local Curb transport with stale-connection handling.
11
+ #
12
+ # Reuses one ::Curl::Easy handle per thread, retries safe reads once, and
13
+ # delegates ambiguous writes to WriteReconciler.
14
+ #
15
+ # Assumes one in-flight request per thread (not safe under fiber-based servers).
16
+ #
17
+ # +reader+ must respond to:
18
+ # - fetch_document(doc_id) -> Hash or nil
19
+ # - fetch_documents_by_ids(ids) -> Hash keyed by id
20
+ class Connection
21
+ MAX_STALE_RETRIES = 1
22
+ THREAD_STORE_KEY = :dolly_curl_easy
23
+ READ_ONLY_POST_SUFFIXES = %w[/_find /_all_docs].freeze
24
+
25
+ attr_reader :db_name, :reader
26
+
27
+ def initialize(db_name:, reader:, reconciler: nil)
28
+ @db_name = db_name
29
+ @reader = reader
30
+ @reconciler = reconciler
31
+ end
32
+
33
+ def request(method, uri, data = nil, retries = 0, &block)
34
+ perform(method, uri, data, &block)
35
+ rescue *StaleConnection::ERRORS => error
36
+ discard_handle
37
+
38
+ if safe_to_retry?(method, uri)
39
+ raise error if retries >= MAX_STALE_RETRIES
40
+
41
+ return request(method, uri, data, retries + 1, &block)
42
+ end
43
+
44
+ reconciler.reconcile(method, uri, data, error, &block)
45
+ end
46
+
47
+ def perform(method, uri, data = nil, &block)
48
+ handle = prepare_handle(uri, &block)
49
+ dispatch_http(handle, method, data)
50
+ handle
51
+ end
52
+
53
+ def discard_handle
54
+ store = Thread.current[THREAD_STORE_KEY]
55
+ return unless store
56
+
57
+ handle = store.delete(self.class)
58
+ handle&.close
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def thread_local_handle
64
+ store = Thread.current[THREAD_STORE_KEY] ||= {}
65
+ handle = store[self.class]
66
+ return handle unless handle.nil? || mocked_curl_class?(handle)
67
+
68
+ begin
69
+ handle&.close
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ store[self.class] = ::Curl::Easy.new
75
+ end
76
+
77
+ private
78
+
79
+ def mocked_curl_class?(handle)
80
+ !handle.instance_of?(::Curl::Easy)
81
+ end
82
+
83
+ def reconciler
84
+ @reconciler ||= WriteReconciler.new(
85
+ transport: self,
86
+ reader: reader,
87
+ db_name: db_name
88
+ )
89
+ end
90
+
91
+ def prepare_handle(uri)
92
+ handle = thread_local_handle
93
+ handle.reset
94
+ handle.url = uri.to_s
95
+ handle.headers['Content-Type'] = 'application/json'
96
+ handle.headers['Accept'] = 'application/json'
97
+ yield handle if block_given?
98
+ handle
99
+ end
100
+
101
+ def dispatch_http(handle, method, data)
102
+ case method.to_sym
103
+ when :head then handle.http_head
104
+ when :delete then handle.http_delete
105
+ when :get then http_get(handle, data)
106
+ when :post then handle.http_post(payload_json(data))
107
+ when :put then handle.http_put(payload_json(data))
108
+ else
109
+ raise ArgumentError, "unsupported HTTP method: #{method}"
110
+ end
111
+ end
112
+
113
+ def http_get(handle, data)
114
+ apply_get_query!(handle, data)
115
+ handle.http_get
116
+ end
117
+
118
+ def payload_json(data)
119
+ return data if data.is_a?(String)
120
+
121
+ data.to_json
122
+ end
123
+
124
+ def apply_get_query!(handle, data)
125
+ return if data.nil? || data.empty?
126
+
127
+ query = data.map do |key, value|
128
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
129
+ end.join('&')
130
+ separator = handle.url.include?('?') ? '&' : '?'
131
+ handle.url = "#{handle.url}#{separator}#{query}"
132
+ end
133
+
134
+ def safe_to_retry?(method, uri)
135
+ case method.to_sym
136
+ when :get, :head
137
+ true
138
+ when :post
139
+ read_only_post?(uri)
140
+ else
141
+ false
142
+ end
143
+ end
144
+
145
+ def read_only_post?(uri)
146
+ READ_ONLY_POST_SUFFIXES.any? { |suffix| uri.path.end_with?(suffix) }
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'oj'
5
+
6
+ module Dolly
7
+ module Curl
8
+ # Document identity, URI parsing, and equality helpers for write reconciliation.
9
+ class DocumentHelper
10
+ IGNORED_COMPARE_KEYS = %i[_rev].freeze
11
+
12
+ def initialize(db_name:)
13
+ @db_name = db_name
14
+ end
15
+
16
+ def id_from_uri(uri)
17
+ prefix = "/#{@db_name}/"
18
+ path = uri.path
19
+ return nil unless path.start_with?(prefix)
20
+
21
+ rest = path[prefix.length..-1]
22
+ return nil if rest.nil? || rest.empty?
23
+ return nil if rest.start_with?('_')
24
+ return nil if rest.include?('/')
25
+
26
+ CGI.unescape(rest)
27
+ end
28
+
29
+ def rev_from_uri(uri)
30
+ return nil unless uri.query
31
+
32
+ CGI.parse(uri.query)['rev']&.first
33
+ end
34
+
35
+ def normalize(data)
36
+ return data.transform_keys(&:to_sym) if data.is_a?(Hash)
37
+ return nil if data.nil?
38
+
39
+ parsed = data.is_a?(String) ? Oj.load(data, symbol_keys: true) : nil
40
+ parsed.is_a?(Hash) ? parsed : nil
41
+ rescue Oj::ParseError
42
+ nil
43
+ end
44
+
45
+ def matches?(stored, intended)
46
+ intended.each do |key, expected|
47
+ key = key.to_sym
48
+ next if IGNORED_COMPARE_KEYS.include?(key)
49
+ return false unless value(stored, key) == expected
50
+ end
51
+ true
52
+ end
53
+
54
+ def value(doc, key)
55
+ return doc[key] if doc.key?(key)
56
+ return doc[key.to_s] if doc.key?(key.to_s)
57
+
58
+ nil
59
+ end
60
+
61
+ def id(doc)
62
+ value(doc, :_id)
63
+ end
64
+
65
+ def revision(doc)
66
+ value(doc, :_rev)
67
+ end
68
+
69
+ def deleted?(doc)
70
+ value(doc, :_deleted)
71
+ end
72
+
73
+ def docs_from_bulk_payload(data)
74
+ payload = normalize(data)
75
+ return [] unless payload
76
+
77
+ Array(payload[:docs]).map { |doc| normalize(doc) }.compact
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'dolly/exceptions'
5
+
6
+ module Dolly
7
+ module Curl
8
+ # Reads CouchDB state for write reconciliation via Connection's public API.
9
+ class Reader
10
+ def initialize(connection)
11
+ @connection = connection
12
+ end
13
+
14
+ def fetch_document(doc_id)
15
+ @connection.request(:get, CGI.escape(doc_id.to_s))
16
+ rescue Dolly::ResourceNotFound
17
+ nil
18
+ end
19
+
20
+ def fetch_documents_by_ids(ids)
21
+ response = @connection.post('_all_docs', keys: ids, query: { include_docs: true })
22
+ rows = response[:rows] || []
23
+ rows.each_with_object({}) do |row, memo|
24
+ memo[row[:id] || row[:key]] = row
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'dolly/framework_helper'
5
+ require 'dolly/exceptions'
6
+
7
+ module Dolly
8
+ module Curl
9
+ module ResponseFormatter
10
+ include Dolly::FrameworkHelper
11
+
12
+ private
13
+
14
+ def response_format(res, method)
15
+ status = res.status.to_i
16
+ raise Dolly::ResourceNotFound if status == 404
17
+ raise Dolly::ServerError.new(status) if (400..600).include?(status)
18
+ return res.header_str if method == :head
19
+
20
+ data = Oj.load(res.body_str, symbol_keys: true)
21
+ return data unless rails?
22
+ return data.with_indifferent_access if data.is_a?(Hash)
23
+
24
+ data
25
+ rescue Oj::ParseError
26
+ res.body_str
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curb'
4
+
5
+ module Dolly
6
+ module Curl
7
+ module StaleConnection
8
+ ERRORS = [
9
+ ::Curl::Err::GotNothingError,
10
+ ::Curl::Err::RecvError,
11
+ ::Curl::Err::SendError,
12
+ ::Curl::Err::PartialFileError
13
+ ].freeze
14
+ end
15
+ end
16
+ end