neo4j-ruby-driver 6.2.1.beta.3 → 6.2.1

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: b34aa1256ed146cfd2115d67be69a98fa717c1146311d4af7fc9ea933265f583
4
- data.tar.gz: 84f43a6b9004b8ef61bba433dd6e71752567275de5fd472ad4469e673632fd22
3
+ metadata.gz: 701bd350949810304707654ca39de518eda02faeda8b6d1c9575d14a3f5ac703
4
+ data.tar.gz: 7920f8aa3f11ded084dda941bf7de0882e7858c7b99d69168b816e2ab6b65e44
5
5
  SHA512:
6
- metadata.gz: da5a94365c5729cb0c40ba01847a828496174a673bf36ceb2cbcdbb217d59aebad4807dd7198e4179bdbb13b328f885634d5616593a220d6c1f9d450c9b75919
7
- data.tar.gz: bfc2f9e35364e0e4da0b71fca65f99c3589d436db2ae187cbc612205240920ef0e525c18c2c653e51914e69e2b8c8f600a6681bc1f28ace46e66f083733f634e
6
+ metadata.gz: 9afb8c944e1661dcc0f5d6f4573d6e797bc218f8f823ee5304c1e7d4ae01bd85fe6804e055f1dd58b41732cbd852a2bb8fd4128f5127c8a3644c73c705903af3
7
+ data.tar.gz: bfd5dd64dd2cd2709409daeb8f3df67b641119c24683f4e6e2022db3fb3de335bc83c8f3ad04c6cefbcdb701121bc06db6198549a39237346f26fc9939fe2fd5
@@ -203,10 +203,11 @@ module Neo4j
203
203
  result = yield @transaction
204
204
  # Explicit-block transactions default to rollback; user must call
205
205
  # tx.commit to persist changes (matches Java driver semantics).
206
- @transaction.rollback if @transaction.open?
206
+ # #close rolls back iff still open (a no-op once committed).
207
+ @transaction.close
207
208
  result
208
209
  rescue StandardError => e
209
- @transaction.rollback if @transaction.open?
210
+ @transaction.close
210
211
  raise e
211
212
  ensure
212
213
  @transaction = nil
@@ -540,13 +541,22 @@ module Neo4j
540
541
  telemetry_ack: on_telemetry_ack)
541
542
 
542
543
  begin
543
- result = yield @transaction
544
- @transaction.commit if @transaction.open?
544
+ # Managed functions yield a run-only context; the driver owns
545
+ # commit/rollback on the underlying UnmanagedTransaction (@transaction).
546
+ # The block can only #run — it can't close the tx — and any run
547
+ # failure raises (→ rescue), so a clean return always leaves it open:
548
+ # commit unconditionally.
549
+ result = yield Transaction.new(@transaction)
550
+ @transaction.commit
545
551
  result
546
552
  rescue StandardError => e
547
- @transaction.rollback if @transaction.open?
553
+ @transaction.close
548
554
  raise e
549
555
  ensure
556
+ # A non-local exit from the work block (return/throw/break through an
557
+ # outer iterator) skips both the commit and the rescue; #close rolls
558
+ # back the still-open tx so it and its connection lease aren't leaked.
559
+ @transaction.close
550
560
  @transaction = nil
551
561
  end
552
562
  end
@@ -562,15 +572,15 @@ module Neo4j
562
572
  # commit-time update_bookmarks reports it as `previous`).
563
573
  bookmarks = current_bookmarks_for_extra
564
574
  tx_options = tx_options.merge(database: begin_db).compact
565
- Transaction.new(connection, self, bookmarks, tx_options, telemetry_api: telemetry_api,
566
- telemetry_ack: telemetry_ack,
567
- # executeQuery's session reports telemetry api 3 (DRIVER_EXECUTE_QUERY);
568
- # only that path pipelines BEGIN + RUN + PULL (Optimization:ExecuteQueryPipelining).
569
- pipelined: @options[:telemetry_api] == 3,
570
- on_begin: method(:cache_home_db_from),
571
- on_release: lambda {
572
- @connection_provider.release(connection)
573
- })
575
+ UnmanagedTransaction.new(connection, self, bookmarks, tx_options, telemetry_api: telemetry_api,
576
+ telemetry_ack: telemetry_ack,
577
+ # executeQuery's session reports telemetry api 3 (DRIVER_EXECUTE_QUERY);
578
+ # only that path pipelines BEGIN + RUN + PULL (Optimization:ExecuteQueryPipelining).
579
+ pipelined: @options[:telemetry_api] == 3,
580
+ on_begin: method(:cache_home_db_from),
581
+ on_release: lambda {
582
+ @connection_provider.release(connection)
583
+ })
574
584
  end
575
585
  end
576
586
  end
@@ -2,342 +2,23 @@
2
2
 
3
3
  module Neo4j
4
4
  module Driver
5
- # Represents an explicit transaction
5
+ # The transaction context yielded to execute_read / execute_write work
6
+ # blocks. It deliberately exposes only #run: the driver owns the lifecycle
7
+ # of a managed transaction (auto-commit on clean return, rollback + retry
8
+ # on failure), so the block must not commit, roll back, or close it.
9
+ #
10
+ # This is Neo4j::Driver::Transaction on both flavours — the JRuby flavour
11
+ # binds the constant to Java's DelegatingTransactionContext — so downstream
12
+ # code prepending onto Neo4j::Driver::Transaction targets the managed
13
+ # context consistently. Explicit transactions (session.begin_transaction)
14
+ # use UnmanagedTransaction, which keeps commit/rollback/close.
6
15
  class Transaction
7
- attr_reader :connection
8
-
9
- def initialize(connection, session, bookmarks = [], options = {}, telemetry_api: nil, telemetry_ack: nil,
10
- pipelined: false, on_begin: nil, on_release: nil)
11
- @connection = connection
12
- @session = session
13
- @options = options
14
- # executeQuery pipelines BEGIN + RUN + PULL (Optimization:ExecuteQueryPipelining):
15
- # BEGIN's reply is read only after the first RUN+PULL are flushed, not eagerly.
16
- @pipelined = pipelined
17
- @on_begin = on_begin # called with the BEGIN reply (home-db cache update)
18
- @on_release = on_release # called once when the connection is no longer needed
19
- @open = true
20
- @committed = false
21
- @rolled_back = false
22
- @failed = false
23
- @terminating_error = nil # the classified error that terminated this tx (a RUN/commit failure)
24
- @current_result = nil
25
- # Every result opened in this tx, in order. Bolt lets multiple stay open
26
- # and streaming concurrently (qid multiplexing); a new RUN no longer
27
- # force-buffers the previous one, so we track them all here to discard
28
- # any still-open at commit/rollback and to spot a mid-stream failure.
29
- @open_results = []
30
-
31
- # Begin the transaction
32
- # Drop blank values so the serialised BEGIN map matches what
33
- # testkit's stub scripts expect (e.g. `BEGIN {"db": "adb"}`,
34
- # not `BEGIN {"db": "adb", "tx_metadata": {}}`).
35
- begin_extra = {
36
- bookmarks: bookmarks,
37
- db: options[:database],
38
- mode: options[:access_mode],
39
- tx_timeout: options[:timeout],
40
- tx_metadata: options[:metadata],
41
- imp_user: options[:impersonated_user]
42
- }
43
- begin_extra.reject!(&Internal::Extras::BLANK)
44
-
45
- # TELEMETRY (api = 0 managed / 1 explicit / 3 executeQuery) pipelined
46
- # ahead of BEGIN when the server opted in and the driver didn't disable
47
- # it; its SUCCESS is read first (see #ack_begin!).
48
- @telemetry_sent = @connection.telemetry(telemetry_api, disabled: options[:telemetry_disabled])
49
- @telemetry_ack = telemetry_ack
50
- # Session-level NotificationsConfig rides on BEGIN (5.2+); the tx's own
51
- # RUNs carry none. nil / pre-5.2 => no notification keys on the wire.
52
- @connection.send_message(@connection.protocol.build_begin(begin_extra,
53
- notification_config: options[:notification_config]))
54
- @connection.flush
55
-
56
- # Non-pipelined: read BEGIN's reply now, a plain round-trip (unchanged).
57
- # Pipelined (executeQuery): defer it so the first #run can flush RUN+PULL
58
- # before we block — a pipelining server withholds BEGIN's SUCCESS until it
59
- # has all three. #ack_begin! drains it after that flush.
60
- @begin_acked = false
61
- ack_begin! unless @pipelined
62
- rescue Exceptions::Neo4jException => e
63
- # Classify first so the auth-token manager is notified and the
64
- # connection is flagged for discard on an auth failure (the server
65
- # closes it). BEGIN failed → server is in FAILED state; RESET to
66
- # make it reusable, unless it's being discarded (a security
67
- # failure: the server closes it and RESET would just error).
68
- classified = @connection.classify_failure(e)
69
- @connection.reset! unless @connection.auth_failed
70
- @open = false
71
- release_connection
72
- raise classified
73
- rescue StandardError
74
- # Transport-level failure (IO/socket). RESET will likely fail
75
- # too on a dead connection, but release the lease so it doesn't
76
- # leak; pool reuse will surface the breakage to the next caller.
77
- @open = false
78
- release_connection
79
- raise
16
+ def initialize(transaction)
17
+ @transaction = transaction
80
18
  end
81
19
 
82
20
  def run(query, **parameters)
83
- Internal::Validator.require_query_text!(query)
84
- # A server failure terminated this transaction; further work is
85
- # rejected locally (no wire traffic) as a TransactionTerminatedException
86
- # — a ClientException subclass, matching the Java driver. Covers both a
87
- # failed RUN (sets @failed) and a result that failed mid-stream during
88
- # the user's own iteration (@current_result.failed?). Message wording
89
- # stays "rolled back" to match the Java flavor (a shared integration spec
90
- # asserts it on both impls); only the exception class becomes specific.
91
- if terminated?
92
- raise Exceptions::TransactionTerminatedException,
93
- 'Cannot run more queries in this transaction, it has been rolled back'
94
- end
95
- unless @open
96
- # Mirror the Java/JRuby messages so the closed-state reason
97
- # (committed vs rolled back) is reported the same on both impls.
98
- raise Exceptions::ClientException,
99
- "Cannot run more queries in this transaction, it has been #{@committed ? 'committed' : 'rolled back'}"
100
- end
101
-
102
- # A new query becomes current; the previous result must now name its qid
103
- # explicitly on further PULL/DISCARD (the server defaults them to the last
104
- # opened query). We keep it open and streaming rather than buffering it —
105
- # that's the qid multiplexing the nested-result tests exercise.
106
- @current_result&.demote!
107
-
108
- fetch_size = effective_fetch_size
109
-
110
- # send/flush are inside the begin block so a transport-level
111
- # failure surfacing on fetch_response still sets @failed and
112
- # goes through classify_failure. Connection#send_message and
113
- # #flush both defer peer-closed errors so a buffered server
114
- # FAILURE is read before fetch_response can raise its own
115
- # EOF-driven ServiceUnavailableException — JRuby surfaces
116
- # EPIPE eagerly, MRI tends to defer it naturally. Without
117
- # this rescue placement, session.close's subsequent rollback
118
- # path takes the ROLLBACK-message branch (rather than
119
- # rollback_via_reset) and re-fails on the dead connection.
120
- buffer = Bolt::RecordBuffer.new(fetch_size: fetch_size)
121
- handler = Bolt::StreamHandler.new(buffer)
122
- run_response =
123
- begin
124
- @connection.send_message(@connection.protocol.build_run(query, parameters, {}))
125
- @connection.send_message(@connection.protocol.build_pull(n: fetch_size), handler)
126
- @connection.flush
127
- # Drain the pipelined BEGIN (+telemetry) reply now that RUN+PULL are on
128
- # the wire — no-op unless this is the pipelined first run. A BEGIN that
129
- # failed surfaces here and is handled as a run failure below; the tx
130
- # then rolls back on its way out, resetting the connection.
131
- ack_begin!
132
- @connection.fetch_response.assert_success!
133
- rescue Exceptions::Neo4jException => e
134
- @failed = true
135
- # Remember the terminating error: sibling results still open must
136
- # raise it (not pull) once this RUN fails — the connection is FAILED.
137
- raise(@terminating_error = @connection.classify_failure(e))
138
- end
139
-
140
- keys = (run_response.metadata[:fields] || run_response.metadata['fields'] || []).map(&:to_sym)
141
-
142
- @current_result = Result.new(@connection, keys, buffer: buffer, handler: handler,
143
- query_text: query, parameters: parameters,
144
- run_metadata: run_response.metadata, fetch_size: fetch_size,
145
- qid: run_response.metadata[:qid],
146
- terminated_error: method(:terminating_error))
147
- @open_results << @current_result
148
- @current_result
149
- end
150
-
151
- def commit
152
- # Java/JRuby-aligned messages for the already-closed states.
153
- raise Exceptions::ClientException, 'Can\'t commit, transaction has been committed' if @committed
154
- raise Exceptions::ClientException, 'Can\'t commit, transaction has been rolled back' if @rolled_back
155
- raise Exceptions::ClientException, 'Transaction is already closed' unless @open
156
-
157
- if terminated?
158
- rollback_via_reset
159
- raise Exceptions::TransactionTerminatedException,
160
- "Transaction can't be committed. It has been rolled back"
161
- end
162
-
163
- begin
164
- discard_open_results
165
- rescue Exceptions::Neo4jException
166
- rollback_via_reset
167
- raise
168
- end
169
-
170
- # send/flush inside the begin block — see Transaction#run for
171
- # the rationale on JRuby vs MRI socket-write timing.
172
- response =
173
- begin
174
- @connection.send_message(Bolt::Message.commit)
175
- @connection.flush
176
- @connection.fetch_response.assert_success!
177
- rescue Exceptions::Neo4jException => e
178
- @failed = true
179
- # Classify first so a security failure flags the connection
180
- # for discard before rollback_via_reset releases it.
181
- classified = @connection.classify_failure(e)
182
- rollback_via_reset
183
- raise classified
184
- end
185
-
186
- @committed = true
187
- @open = false
188
-
189
- bookmarks = response.metadata[:bookmark]
190
- @session.update_bookmarks(bookmarks) if bookmarks
191
- release_connection
192
- end
193
-
194
- def rollback
195
- raise Exceptions::ClientException, 'Transaction is already closed' unless @open
196
-
197
- # A pipelined executeQuery tx whose query never ran (e.g. local validation
198
- # failed before RUN/PULL were sent) left BEGIN's reply unread — and a
199
- # pipelining server may withhold it until RUN/PULL arrive, which now never
200
- # will. RESET rolls the tx back and drains any pending reply without a
201
- # blocking read that could deadlock; there are no open results to discard.
202
- return rollback_via_reset unless @begin_acked
203
-
204
- # A terminated tx left the connection FAILED: don't drain open results
205
- # (that would send PULL/DISCARD the server rejects) — just RESET.
206
- if terminated?
207
- rollback_via_reset
208
- return
209
- end
210
-
211
- begin
212
- discard_open_results
213
- rescue Exceptions::Neo4jException
214
- # Failures surfaced while draining a pending result are expected
215
- # during rollback — the tx is being discarded anyway. @failed is
216
- # set; RESET path below will clean up the connection.
217
- end
218
-
219
- if @failed
220
- rollback_via_reset
221
- return
222
- end
223
-
224
- begin
225
- @connection.send_message(Bolt::Message.rollback)
226
- @connection.flush
227
- @connection.fetch_response.assert_success!
228
- rescue Exceptions::ServiceUnavailableException, Exceptions::SessionExpiredException
229
- # Rolling back on a broken/dead connection is a no-op — the
230
- # server discards the tx when the link dies. Swallow these so
231
- # session.close's rollback path stays clean.
232
- rescue Exceptions::Neo4jException => e
233
- # A server FAILURE on ROLLBACK (e.g. DatabaseUnavailable) is a
234
- # real error: the connection is now in FAILED state. RESET it
235
- # back to READY, then surface the failure through the routing
236
- # classifier — same as commit/run — so routing side effects
237
- # (e.g. deactivate on DatabaseUnavailable) fire and the surfaced
238
- # type is consistent. No-op for direct connections.
239
- @connection.reset!
240
- raise @connection.classify_failure(e)
241
- ensure
242
- @rolled_back = true
243
- @open = false
244
- release_connection
245
- end
246
- end
247
-
248
- def close
249
- rollback if @open && !@committed
250
- end
251
-
252
- def open?
253
- @open
254
- end
255
-
256
- def failed?
257
- @failed
258
- end
259
-
260
- private
261
-
262
- # Read the deferred BEGIN acknowledgement (and the telemetry SUCCESS
263
- # pipelined ahead of it). Idempotent: called once — eagerly in #initialize
264
- # for a normal tx, or after the first RUN+PULL flush for a pipelined
265
- # executeQuery (and defensively before ROLLBACK if that query never ran).
266
- def ack_begin!
267
- return if @begin_acked
268
-
269
- @begin_acked = true
270
-
271
- if @telemetry_sent
272
- @connection.fetch_response.assert_success!
273
- # The server acknowledged telemetry; a managed-tx retry won't re-send it.
274
- @telemetry_ack&.call
275
- end
276
- begin_response = @connection.fetch_response.assert_success!
277
- # A home-db BEGIN that sent db=nil comes back with the resolved name.
278
- @on_begin&.call(begin_response)
279
- begin_response
280
- end
281
-
282
- # The transaction is terminated once a server failure has hit it — either
283
- # a tx method caught it (@failed) or any open result failed during the
284
- # user's own iteration (its failure hasn't passed through a tx method).
285
- def terminated? = @failed || @open_results.any?(&:failed?)
286
-
287
- # The error that terminated this tx, or nil. A RUN/commit failure records
288
- # it directly; a result that failed mid-iteration (the user's own PULL)
289
- # carries it on the result. Passed to each result as its terminated_error
290
- # so a sibling raises it instead of pulling on a FAILED connection.
291
- def terminating_error = @terminating_error || @open_results.find(&:failed?)&.failure
292
-
293
- # See Session#effective_fetch_size. Transactions inherit the session
294
- # options at open, so the same default rules apply.
295
- def effective_fetch_size
296
- size = @options[:fetch_size]
297
- size.nil? ? 1000 : size
298
- end
299
-
300
- # At tx end (commit/rollback) every still-open result goes out of scope,
301
- # so discard each — DISCARD abandons remaining records (essential when a
302
- # result is unbounded) rather than streaming them into memory, and leaves
303
- # each raising ResultConsumedException on later access. Demoted results
304
- # DISCARD by their qid; the current one omits it (targets the last query).
305
- def discard_open_results
306
- @open_results.each do |result|
307
- begin
308
- result.consume
309
- rescue Exceptions::Neo4jException => e
310
- @failed = true
311
- # A wire error during PULL streaming (e.g. a reader connection
312
- # interrupted mid-stream) raises ServiceUnavailable straight from
313
- # fetch_response, not via Result#on_failure — so it never saw the
314
- # routing classifier. Run it through here so a routed connection
315
- # failure surfaces as SessionExpired (idempotent if already classified).
316
- raise @connection.classify_failure(e)
317
- end
318
-
319
- # consume is a no-op when the result was already drained by the user;
320
- # surface any stored failure so callers can react.
321
- @failed = true if result.failed?
322
- end
323
- end
324
-
325
- # Recover a failed transaction by asking the server to RESET the
326
- # connection. RESET transitions the server from FAILED back to READY
327
- # and implicitly rolls back the open transaction.
328
- def rollback_via_reset
329
- # Skip RESET on a connection being discarded (auth failure: the
330
- # server closes it, RESET would just error); release then honors
331
- # the discard flag so it isn't pooled.
332
- @connection.reset! unless @connection.auth_failed
333
- @rolled_back = true
334
- @open = false
335
- release_connection
336
- end
337
-
338
- def release_connection
339
- @on_release&.call
340
- @on_release = nil # idempotent
21
+ @transaction.run(query, **parameters)
341
22
  end
342
23
  end
343
24
  end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Neo4j
4
+ module Driver
5
+ # An explicit (unmanaged) transaction: the full lifecycle — run, commit,
6
+ # rollback, close. This is what session.begin_transaction yields/returns.
7
+ # Managed functions (execute_read/execute_write) instead yield a
8
+ # Transaction, a thin context that exposes only #run and delegates to an
9
+ # UnmanagedTransaction the driver owns.
10
+ class UnmanagedTransaction
11
+ attr_reader :connection
12
+
13
+ def initialize(connection, session, bookmarks = [], options = {}, telemetry_api: nil, telemetry_ack: nil,
14
+ pipelined: false, on_begin: nil, on_release: nil)
15
+ @connection = connection
16
+ @session = session
17
+ @options = options
18
+ # executeQuery pipelines BEGIN + RUN + PULL (Optimization:ExecuteQueryPipelining):
19
+ # BEGIN's reply is read only after the first RUN+PULL are flushed, not eagerly.
20
+ @pipelined = pipelined
21
+ @on_begin = on_begin # called with the BEGIN reply (home-db cache update)
22
+ @on_release = on_release # called once when the connection is no longer needed
23
+ @open = true
24
+ @committed = false
25
+ @rolled_back = false
26
+ @failed = false
27
+ @terminating_error = nil # the classified error that terminated this tx (a RUN/commit failure)
28
+ @current_result = nil
29
+ # Every result opened in this tx, in order. Bolt lets multiple stay open
30
+ # and streaming concurrently (qid multiplexing); a new RUN no longer
31
+ # force-buffers the previous one, so we track them all here to discard
32
+ # any still-open at commit/rollback and to spot a mid-stream failure.
33
+ @open_results = []
34
+
35
+ # Begin the transaction
36
+ # Drop blank values so the serialised BEGIN map matches what
37
+ # testkit's stub scripts expect (e.g. `BEGIN {"db": "adb"}`,
38
+ # not `BEGIN {"db": "adb", "tx_metadata": {}}`).
39
+ begin_extra = {
40
+ bookmarks: bookmarks,
41
+ db: options[:database],
42
+ mode: options[:access_mode],
43
+ tx_timeout: options[:timeout],
44
+ tx_metadata: options[:metadata],
45
+ imp_user: options[:impersonated_user]
46
+ }
47
+ begin_extra.reject!(&Internal::Extras::BLANK)
48
+
49
+ # TELEMETRY (api = 0 managed / 1 explicit / 3 executeQuery) pipelined
50
+ # ahead of BEGIN when the server opted in and the driver didn't disable
51
+ # it; its SUCCESS is read first (see #ack_begin!).
52
+ @telemetry_sent = @connection.telemetry(telemetry_api, disabled: options[:telemetry_disabled])
53
+ @telemetry_ack = telemetry_ack
54
+ # Session-level NotificationsConfig rides on BEGIN (5.2+); the tx's own
55
+ # RUNs carry none. nil / pre-5.2 => no notification keys on the wire.
56
+ @connection.send_message(@connection.protocol.build_begin(begin_extra,
57
+ notification_config: options[:notification_config]))
58
+ @connection.flush
59
+
60
+ # Non-pipelined: read BEGIN's reply now, a plain round-trip (unchanged).
61
+ # Pipelined (executeQuery): defer it so the first #run can flush RUN+PULL
62
+ # before we block — a pipelining server withholds BEGIN's SUCCESS until it
63
+ # has all three. #ack_begin! drains it after that flush.
64
+ @begin_acked = false
65
+ ack_begin! unless @pipelined
66
+ rescue Exceptions::Neo4jException => e
67
+ # Classify first so the auth-token manager is notified and the
68
+ # connection is flagged for discard on an auth failure (the server
69
+ # closes it). BEGIN failed → server is in FAILED state; RESET to
70
+ # make it reusable, unless it's being discarded (a security
71
+ # failure: the server closes it and RESET would just error).
72
+ classified = @connection.classify_failure(e)
73
+ @connection.reset! unless @connection.auth_failed
74
+ @open = false
75
+ release_connection
76
+ raise classified
77
+ rescue StandardError
78
+ # Transport-level failure (IO/socket). RESET will likely fail
79
+ # too on a dead connection, but release the lease so it doesn't
80
+ # leak; pool reuse will surface the breakage to the next caller.
81
+ @open = false
82
+ release_connection
83
+ raise
84
+ end
85
+
86
+ def run(query, **parameters)
87
+ Internal::Validator.require_query_text!(query)
88
+ # A server failure terminated this transaction; further work is
89
+ # rejected locally (no wire traffic) as a TransactionTerminatedException
90
+ # — a ClientException subclass, matching the Java driver. Covers both a
91
+ # failed RUN (sets @failed) and a result that failed mid-stream during
92
+ # the user's own iteration (@current_result.failed?). Message wording
93
+ # stays "rolled back" to match the Java flavor (a shared integration spec
94
+ # asserts it on both impls); only the exception class becomes specific.
95
+ if terminated?
96
+ raise Exceptions::TransactionTerminatedException,
97
+ 'Cannot run more queries in this transaction, it has been rolled back'
98
+ end
99
+ unless @open
100
+ # Mirror the Java/JRuby messages so the closed-state reason
101
+ # (committed vs rolled back) is reported the same on both impls.
102
+ raise Exceptions::ClientException,
103
+ "Cannot run more queries in this transaction, it has been #{@committed ? 'committed' : 'rolled back'}"
104
+ end
105
+
106
+ # A new query becomes current; the previous result must now name its qid
107
+ # explicitly on further PULL/DISCARD (the server defaults them to the last
108
+ # opened query). We keep it open and streaming rather than buffering it —
109
+ # that's the qid multiplexing the nested-result tests exercise.
110
+ @current_result&.demote!
111
+
112
+ fetch_size = effective_fetch_size
113
+
114
+ # send/flush are inside the begin block so a transport-level
115
+ # failure surfacing on fetch_response still sets @failed and
116
+ # goes through classify_failure. Connection#send_message and
117
+ # #flush both defer peer-closed errors so a buffered server
118
+ # FAILURE is read before fetch_response can raise its own
119
+ # EOF-driven ServiceUnavailableException — JRuby surfaces
120
+ # EPIPE eagerly, MRI tends to defer it naturally. Without
121
+ # this rescue placement, session.close's subsequent rollback
122
+ # path takes the ROLLBACK-message branch (rather than
123
+ # rollback_via_reset) and re-fails on the dead connection.
124
+ buffer = Bolt::RecordBuffer.new(fetch_size: fetch_size)
125
+ handler = Bolt::StreamHandler.new(buffer)
126
+ run_response =
127
+ begin
128
+ @connection.send_message(@connection.protocol.build_run(query, parameters, {}))
129
+ @connection.send_message(@connection.protocol.build_pull(n: fetch_size), handler)
130
+ @connection.flush
131
+ # Drain the pipelined BEGIN (+telemetry) reply now that RUN+PULL are on
132
+ # the wire — no-op unless this is the pipelined first run. A BEGIN that
133
+ # failed surfaces here and is handled as a run failure below; the tx
134
+ # then rolls back on its way out, resetting the connection.
135
+ ack_begin!
136
+ @connection.fetch_response.assert_success!
137
+ rescue Exceptions::Neo4jException => e
138
+ @failed = true
139
+ # Remember the terminating error: sibling results still open must
140
+ # raise it (not pull) once this RUN fails — the connection is FAILED.
141
+ raise(@terminating_error = @connection.classify_failure(e))
142
+ end
143
+
144
+ keys = (run_response.metadata[:fields] || run_response.metadata['fields'] || []).map(&:to_sym)
145
+
146
+ @current_result = Result.new(@connection, keys, buffer: buffer, handler: handler,
147
+ query_text: query, parameters: parameters,
148
+ run_metadata: run_response.metadata, fetch_size: fetch_size,
149
+ qid: run_response.metadata[:qid],
150
+ terminated_error: method(:terminating_error))
151
+ @open_results << @current_result
152
+ @current_result
153
+ end
154
+
155
+ def commit
156
+ # Java/JRuby-aligned messages for the already-closed states.
157
+ raise Exceptions::ClientException, 'Can\'t commit, transaction has been committed' if @committed
158
+ raise Exceptions::ClientException, 'Can\'t commit, transaction has been rolled back' if @rolled_back
159
+ raise Exceptions::ClientException, 'Transaction is already closed' unless @open
160
+
161
+ if terminated?
162
+ rollback_via_reset
163
+ raise Exceptions::TransactionTerminatedException,
164
+ "Transaction can't be committed. It has been rolled back"
165
+ end
166
+
167
+ begin
168
+ discard_open_results
169
+ rescue Exceptions::Neo4jException
170
+ rollback_via_reset
171
+ raise
172
+ end
173
+
174
+ # send/flush inside the begin block — see Transaction#run for
175
+ # the rationale on JRuby vs MRI socket-write timing.
176
+ response =
177
+ begin
178
+ @connection.send_message(Bolt::Message.commit)
179
+ @connection.flush
180
+ @connection.fetch_response.assert_success!
181
+ rescue Exceptions::Neo4jException => e
182
+ @failed = true
183
+ # Classify first so a security failure flags the connection
184
+ # for discard before rollback_via_reset releases it.
185
+ classified = @connection.classify_failure(e)
186
+ rollback_via_reset
187
+ raise classified
188
+ end
189
+
190
+ @committed = true
191
+ @open = false
192
+
193
+ bookmarks = response.metadata[:bookmark]
194
+ @session.update_bookmarks(bookmarks) if bookmarks
195
+ release_connection
196
+ end
197
+
198
+ def rollback
199
+ raise Exceptions::ClientException, 'Transaction is already closed' unless @open
200
+
201
+ # A pipelined executeQuery tx whose query never ran (e.g. local validation
202
+ # failed before RUN/PULL were sent) left BEGIN's reply unread — and a
203
+ # pipelining server may withhold it until RUN/PULL arrive, which now never
204
+ # will. RESET rolls the tx back and drains any pending reply without a
205
+ # blocking read that could deadlock; there are no open results to discard.
206
+ return rollback_via_reset unless @begin_acked
207
+
208
+ # A terminated tx left the connection FAILED: don't drain open results
209
+ # (that would send PULL/DISCARD the server rejects) — just RESET.
210
+ if terminated?
211
+ rollback_via_reset
212
+ return
213
+ end
214
+
215
+ begin
216
+ discard_open_results
217
+ rescue Exceptions::Neo4jException
218
+ # Failures surfaced while draining a pending result are expected
219
+ # during rollback — the tx is being discarded anyway. @failed is
220
+ # set; RESET path below will clean up the connection.
221
+ end
222
+
223
+ if @failed
224
+ rollback_via_reset
225
+ return
226
+ end
227
+
228
+ begin
229
+ @connection.send_message(Bolt::Message.rollback)
230
+ @connection.flush
231
+ @connection.fetch_response.assert_success!
232
+ rescue Exceptions::ServiceUnavailableException, Exceptions::SessionExpiredException
233
+ # Rolling back on a broken/dead connection is a no-op — the
234
+ # server discards the tx when the link dies. Swallow these so
235
+ # session.close's rollback path stays clean.
236
+ rescue Exceptions::Neo4jException => e
237
+ # A server FAILURE on ROLLBACK (e.g. DatabaseUnavailable) is a
238
+ # real error: the connection is now in FAILED state. RESET it
239
+ # back to READY, then surface the failure through the routing
240
+ # classifier — same as commit/run — so routing side effects
241
+ # (e.g. deactivate on DatabaseUnavailable) fire and the surfaced
242
+ # type is consistent. No-op for direct connections.
243
+ @connection.reset!
244
+ raise @connection.classify_failure(e)
245
+ ensure
246
+ @rolled_back = true
247
+ @open = false
248
+ release_connection
249
+ end
250
+ end
251
+
252
+ def close
253
+ rollback if @open && !@committed
254
+ end
255
+
256
+ def open?
257
+ @open
258
+ end
259
+
260
+ def failed?
261
+ @failed
262
+ end
263
+
264
+ private
265
+
266
+ # Read the deferred BEGIN acknowledgement (and the telemetry SUCCESS
267
+ # pipelined ahead of it). Idempotent: called once — eagerly in #initialize
268
+ # for a normal tx, or after the first RUN+PULL flush for a pipelined
269
+ # executeQuery (and defensively before ROLLBACK if that query never ran).
270
+ def ack_begin!
271
+ return if @begin_acked
272
+
273
+ @begin_acked = true
274
+
275
+ if @telemetry_sent
276
+ @connection.fetch_response.assert_success!
277
+ # The server acknowledged telemetry; a managed-tx retry won't re-send it.
278
+ @telemetry_ack&.call
279
+ end
280
+ begin_response = @connection.fetch_response.assert_success!
281
+ # A home-db BEGIN that sent db=nil comes back with the resolved name.
282
+ @on_begin&.call(begin_response)
283
+ begin_response
284
+ end
285
+
286
+ # The transaction is terminated once a server failure has hit it — either
287
+ # a tx method caught it (@failed) or any open result failed during the
288
+ # user's own iteration (its failure hasn't passed through a tx method).
289
+ def terminated? = @failed || @open_results.any?(&:failed?)
290
+
291
+ # The error that terminated this tx, or nil. A RUN/commit failure records
292
+ # it directly; a result that failed mid-iteration (the user's own PULL)
293
+ # carries it on the result. Passed to each result as its terminated_error
294
+ # so a sibling raises it instead of pulling on a FAILED connection.
295
+ def terminating_error = @terminating_error || @open_results.find(&:failed?)&.failure
296
+
297
+ # See Session#effective_fetch_size. Transactions inherit the session
298
+ # options at open, so the same default rules apply.
299
+ def effective_fetch_size
300
+ size = @options[:fetch_size]
301
+ size.nil? ? 1000 : size
302
+ end
303
+
304
+ # At tx end (commit/rollback) every still-open result goes out of scope,
305
+ # so discard each — DISCARD abandons remaining records (essential when a
306
+ # result is unbounded) rather than streaming them into memory, and leaves
307
+ # each raising ResultConsumedException on later access. Demoted results
308
+ # DISCARD by their qid; the current one omits it (targets the last query).
309
+ def discard_open_results
310
+ @open_results.each do |result|
311
+ begin
312
+ result.consume
313
+ rescue Exceptions::Neo4jException => e
314
+ @failed = true
315
+ # A wire error during PULL streaming (e.g. a reader connection
316
+ # interrupted mid-stream) raises ServiceUnavailable straight from
317
+ # fetch_response, not via Result#on_failure — so it never saw the
318
+ # routing classifier. Run it through here so a routed connection
319
+ # failure surfaces as SessionExpired (idempotent if already classified).
320
+ raise @connection.classify_failure(e)
321
+ end
322
+
323
+ # consume is a no-op when the result was already drained by the user;
324
+ # surface any stored failure so callers can react.
325
+ @failed = true if result.failed?
326
+ end
327
+ end
328
+
329
+ # Recover a failed transaction by asking the server to RESET the
330
+ # connection. RESET transitions the server from FAILED back to READY
331
+ # and implicitly rolls back the open transaction.
332
+ def rollback_via_reset
333
+ # Skip RESET on a connection being discarded (auth failure: the
334
+ # server closes it, RESET would just error); release then honors
335
+ # the discard flag so it isn't pooled.
336
+ @connection.reset! unless @connection.auth_failed
337
+ @rolled_back = true
338
+ @open = false
339
+ release_connection
340
+ end
341
+
342
+ def release_connection
343
+ @on_release&.call
344
+ @on_release = nil # idempotent
345
+ end
346
+ end
347
+ end
348
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Neo4j
4
4
  module Driver
5
- VERSION = '6.2.1.beta.3'
5
+ VERSION = '6.2.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neo4j-ruby-driver
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.2.1.beta.3
4
+ version: 6.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neo4j Driver Team
@@ -188,6 +188,7 @@ files:
188
188
  - lib/neo4j/driver/types/unresolvable_zoned_date_time.rb
189
189
  - lib/neo4j/driver/types/unsupported_type.rb
190
190
  - lib/neo4j/driver/types/uuid.rb
191
+ - lib/neo4j/driver/unmanaged_transaction.rb
191
192
  - lib/neo4j/driver/version.rb
192
193
  - lib/neo4j_ruby_driver.rb
193
194
  homepage: https://github.com/neo4jrb/neo4j-ruby-driver