activecypher 0.15.4 → 0.15.5

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: 4b0f2c80d0e44691d856e7e00f6fc34c51dc5e5661c47355ac66a3053896520f
4
- data.tar.gz: 124e0126aa949bf7b506b22e364aba9b999eddbdca3f6209dd8fda6bb624314f
3
+ metadata.gz: bd37ad6691e4ec34e421cde2caa055e8379d9d15e0b6df558b60fdcc76f6bd6f
4
+ data.tar.gz: 7284b02415749cab48ec434dd689d7a4c9bec23a3bdf62f6636e32f0bb99501b
5
5
  SHA512:
6
- metadata.gz: 636e42013b5f0f1e4e49da16ee1d49a116713c04a81aaa4d7007c6df481f9ed915a68108539d52de651076fe454b50528d11aa6e4673c8f61356a876c33b71f6
7
- data.tar.gz: ac34eb11a6545dc2fddaa15437959c08164aef4ae0063332686a8ccea41357159977c5868938b276bb34563b5af4ace5a24a361802e4c5e165b287e3ac37f55c
6
+ metadata.gz: befe8d23ced99f81ab0d01195000aa22e1c9e35f10a0268de8a3627010721720b223d3ad02b1116d9487196cdec2408a7e4079325284630afec1fb684f42a295
7
+ data.tar.gz: 53e90b852ecd80cf949c6df0d0c5ed5589e52ba043326f84e2f52206ee7f22de637522eb5770d1168396a34d3d9679db3abea3462bf2395ac185f2b81e6c53b2
@@ -77,7 +77,7 @@ module ActiveCypher
77
77
  @state = :failed
78
78
  code = msg.metadata['code']
79
79
  message = msg.metadata['message']
80
- raise QueryError, "Query execution failed: #{code} - #{message}"
80
+ raise query_error_for(code), "Query execution failed: #{code} - #{message}"
81
81
  else
82
82
  raise ProtocolError, "Unexpected message type: #{msg.class}"
83
83
  end
@@ -90,7 +90,7 @@ module ActiveCypher
90
90
  @state = :failed
91
91
  code = response.metadata['code']
92
92
  message = response.metadata['message']
93
- raise QueryError, "Query execution failed: #{code} - #{message}"
93
+ raise query_error_for(code), "Query execution failed: #{code} - #{message}"
94
94
  else
95
95
  raise ProtocolError, "Unexpected response to RUN: #{response.class}"
96
96
  end
@@ -204,6 +204,18 @@ module ActiveCypher
204
204
  def failed?
205
205
  @state == :failed
206
206
  end
207
+
208
+ private
209
+
210
+ # Server error code -> exception class. Substring match covers both
211
+ # Neo4j and Memgraph spellings of the same codes.
212
+ def query_error_for(code)
213
+ c = code.to_s
214
+ return ConstraintError if c.include?('Constraint')
215
+ return TransientError if c.include?('TransientError')
216
+
217
+ QueryError
218
+ end
207
219
  end
208
220
  end
209
221
  end
@@ -112,6 +112,8 @@ module ActiveCypher
112
112
  end
113
113
  end
114
114
 
115
+ TRANSIENT_RETRIES = 3
116
+
115
117
  # Override run to execute queries using auto-commit mode.
116
118
  # Memgraph auto-commits each query, so we send RUN + PULL directly
117
119
  # without BEGIN/COMMIT wrapper. This avoids transaction state issues.
@@ -120,7 +122,26 @@ module ActiveCypher
120
122
  logger.debug { "[#{context}] #{cypher} #{params.inspect}" }
121
123
 
122
124
  instrument_query(cypher, params, context: context, metadata: { db: db, access_mode: access_mode }) do
123
- run_auto_commit(cypher, prepare_params(params))
125
+ with_transient_retry(context) { run_auto_commit(cypher, prepare_params(params)) }
126
+ end
127
+ end
128
+
129
+ # Retry write/write conflicts the server flagged as transient. Safe only
130
+ # because this path is auto-commit (the whole query rolled back); do NOT
131
+ # lift into the explicit-transaction path, where earlier statements may
132
+ # already have applied.
133
+ def with_transient_retry(context)
134
+ attempts = 0
135
+ begin
136
+ yield
137
+ rescue ActiveCypher::TransientError => e
138
+ attempts += 1
139
+ raise if attempts > TRANSIENT_RETRIES
140
+
141
+ # Full jitter, so conflicting writers don't back off in step.
142
+ sleep(rand * 0.05 * (2**(attempts - 1)))
143
+ logger.debug { "[#{context}] transient conflict, retry #{attempts}/#{TRANSIENT_RETRIES}: #{e.message}" }
144
+ retry
124
145
  end
125
146
  end
126
147
 
@@ -25,27 +25,40 @@ module ActiveCypher
25
25
  @spec = resolved_config.merge(@spec.except(:url))
26
26
  end
27
27
 
28
- @conn_ref = nil # holds the adapter instance
29
- @creation_mutex = Mutex.new # prevents multiple threads from creating connections simultaneously
28
+ # One connection per thread: Bolt is a stateful, ordered protocol, so
29
+ # sharing a socket across threads corrupts the stream. @connections
30
+ # exists only so disconnect can close them all; the thread-local is
31
+ # what the hot path reads.
32
+ @connections = {}
33
+ @creation_mutex = Mutex.new
30
34
  end
31
35
 
32
- # Returns a live adapter, initializing it once in a thread‑safe way.
36
+ # Returns a live adapter belonging to the calling thread.
33
37
  def connection
34
- # Fast path — already connected and alive
35
- conn = @conn_ref
38
+ conn = Thread.current[thread_key]
36
39
  return conn if conn&.active?
37
40
 
38
- # Use mutex for the slow path to prevent thundering herd
39
- @creation_mutex.synchronize do
40
- # Check again inside the mutex in case another thread created it
41
- conn = @conn_ref
42
- return conn if conn&.active?
41
+ # Built outside the mutex: connecting does network IO.
42
+ new_conn = build_connection
43
+ Thread.current[thread_key] = new_conn
44
+
45
+ # Reap dead threads' connections, or each short-lived thread leaks a
46
+ # socket the server counts against max_connections.
47
+ orphaned = @creation_mutex.synchronize do
48
+ dead = @connections.reject { |t, _| t.alive? }
49
+ dead.each_key { |t| @connections.delete(t) }
50
+ @connections[Thread.current] = new_conn
51
+ dead.values
52
+ end
43
53
 
44
- # Create a new connection
45
- new_conn = build_connection
46
- @conn_ref = new_conn
47
- return new_conn
54
+ # Closed outside the mutex: disconnecting does IO too.
55
+ orphaned.each do |conn|
56
+ conn.disconnect
57
+ rescue StandardError => e
58
+ puts "Warning: Error disconnecting orphaned connection: #{e.message}" if ENV['DEBUG']
48
59
  end
60
+
61
+ new_conn
49
62
  end
50
63
  alias checkout connection
51
64
 
@@ -54,23 +67,31 @@ module ActiveCypher
54
67
  @retry_count >= @spec[:max_retries]
55
68
  end
56
69
 
57
- # Explicitly close and reset the connection
70
+ # Explicitly close every connection this pool handed out.
58
71
  def disconnect
59
- conn = @conn_ref
60
- return unless conn
72
+ conns = @creation_mutex.synchronize do
73
+ taken = @connections.values
74
+ @connections.clear
75
+ taken
76
+ end
61
77
 
62
- begin
78
+ conns.each do |conn|
63
79
  conn.disconnect
64
80
  rescue StandardError => e
65
81
  # Log but don't raise to ensure cleanup continues
66
82
  puts "Warning: Error disconnecting: #{e.message}" if ENV['DEBUG']
67
- ensure
68
- @conn_ref = nil
69
83
  end
84
+
85
+ Thread.current[thread_key] = nil
70
86
  end
71
87
 
72
88
  private
73
89
 
90
+ # Namespaced per pool instance so multiple databases don't share.
91
+ def thread_key
92
+ @thread_key ||= :"active_cypher_connection_#{object_id}"
93
+ end
94
+
74
95
  def build_connection
75
96
  adapter_name = @spec[:adapter]
76
97
  raise ArgumentError, 'Missing adapter name in connection specification' unless adapter_name
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.15.4'
4
+ VERSION = '0.15.5'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
data/lib/activecypher.rb CHANGED
@@ -52,6 +52,14 @@ module ActiveCypher
52
52
  # Could be you. Could be Cypher. Could be fate.
53
53
  class QueryError < Error; end
54
54
 
55
+ # A uniqueness or existence constraint said no.
56
+ # Rescue this to ignore duplicates without swallowing every QueryError.
57
+ class ConstraintError < QueryError; end
58
+
59
+ # Two writers touched the same node. Nothing is wrong with your query.
60
+ # The server is politely asking you to try again.
61
+ class TransientError < QueryError; end
62
+
55
63
  # Your Cypher syntax is... interpretive.
56
64
  # Unfortunately, the parser isn’t in the mood for interpretive dance.
57
65
  class CypherSyntaxError < QueryError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activecypher
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.4
4
+ version: 0.15.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -293,7 +293,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
293
293
  - !ruby/object:Gem::Version
294
294
  version: '0'
295
295
  requirements: []
296
- rubygems_version: 4.0.6
296
+ rubygems_version: 4.0.10
297
297
  specification_version: 4
298
298
  summary: OpenCypher Adapter ala ActiveRecord
299
299
  test_files: []