neo4j_bolt 0.4.0 → 0.4.2

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: a0582f714d9d1c8793edb70481db6a2ff5548200b14ad55c1f44b0859270b7d9
4
- data.tar.gz: 73e300f77c941909738411fdbd9ce6b72ea6ef73a72a10184395e2228ce6a52c
3
+ metadata.gz: 66da85ac04ffd0ef43fea4fba9a676ea2168770f85825573652a58dbc0a48f2b
4
+ data.tar.gz: 132911b813e88d5a3efb590fe5cd29da0c8adb6663b06b9931bb0a03f4fcae9c
5
5
  SHA512:
6
- metadata.gz: 8e5debab26d8b655b1fa3fc1e4c877308e8221345aec76844d2b423ef4fe3061bc535241566f9af63ba889274b944c81efbad9daeadf1cc9b0ed166c82a18417
7
- data.tar.gz: 65ed32647603bdeaf9bbca19335a0ca6fdccf5b17f3aaa11e19ef48868b242731a34b3cba74ff9475dbd5db94e6bf15f44f60110dca5adb8fe83d9709ef45a3d
6
+ metadata.gz: 37e9205800f8661683bc246c6a962a15254c2d1e4a33d5b948f85d5e59d9ac38911e67c4a2e18bb318bc9f3ceeb3eedb7199004e98b0ce1694ae2e0f16f9ce8e
7
+ data.tar.gz: 3f7066485f251b5939b9a448b441290f16714311ebd7baa2b2baa27d785ae2463902c63b6c14b532df5adfe574242c8e415305967677110d92943a431e4360d3
data/README.md CHANGED
@@ -4,14 +4,14 @@ Neo4jBolt 0.4 is a small compatibility and convenience layer for Ruby applicatio
4
4
 
5
5
  Neo4jBolt no longer implements the Bolt wire protocol itself.
6
6
 
7
- `0.4.0.pre1` requires Ruby 3.4 or newer and pins `neo4j-ruby-driver` to `6.2.1.beta.4`. Applications on older Rubies can remain on the Neo4jBolt 0.3.x line; this prerelease is intentionally not an automatic upgrade for them.
7
+ `0.4.0` requires Ruby 3.4 or newer and pins `neo4j-ruby-driver` to `6.2.1.beta.4`. Applications on older Rubies can remain on the Neo4jBolt 0.3.x line; this prerelease is intentionally not an automatic upgrade for them.
8
8
 
9
9
  ## Installation
10
10
 
11
11
  For this prerelease, specify the version explicitly:
12
12
 
13
13
  ```ruby
14
- gem "neo4j_bolt", "0.4.0.pre1"
14
+ gem "neo4j_bolt", "0.4.0"
15
15
  ```
16
16
 
17
17
  Then run `bundle install`. A running Neo4j database is required.
@@ -151,13 +151,21 @@ r {"from":0,"to":1,"type":"KNOWS","properties":{"since":2024}}
151
151
  ```
152
152
 
153
153
  ```ruby
154
- File.open("database.dump", "w") { |io| dump_database(io) }
155
- File.open("database.dump", "r") { |io| load_database_dump(io) }
154
+ File.open("database.dump", "w") { |io| dump_database(io, progress_io: $stderr) }
155
+ File.open("database.dump", "r") do |io|
156
+ load_database_dump(io, progress_io: $stderr)
157
+ end
156
158
  ```
157
159
 
158
- The `0, 1, 2, ...` IDs are synthetic dump-local IDs. Database-internal IDs and element IDs are never written to the persistent format. The adapter uses driver-provided entity identity while dumping and a random temporary property (removed in `ensure`) while loading, so relationship reconstruction does not depend on deprecated persistent integer IDs. To keep dump numbering deterministic, ordering uses `elementId()` on modern Neo4j and an internal `id()` fallback only on Neo4j 4.4, where `elementId()` does not exist. Old Neo4jBolt dumps remain loadable.
160
+ Passing `progress_io:` is optional for the Ruby API. The CLI always reports dump/load progress on stderr, so dump data written to stdout remains safe to redirect or pipe. In `auto` mode a terminal gets a colored, in-place progress bar while redirected stderr receives periodic plain-text progress lines. Set `NEO4J_BOLT_PROGRESS=pretty` to force the terminal display through a container or other wrapper that hides the TTY, or `NEO4J_BOLT_PROGRESS=plain` to force log-friendly output. `NO_COLOR` disables colors without disabling the in-place display.
161
+
162
+ The `0, 1, 2, ...` IDs are synthetic dump-local IDs. Database-internal IDs and element IDs are never written to the persistent format. Dumping keeps the count, node, and relationship reads in one transaction, so the driver-provided entity identity used to connect the two streamed result sets stays within Neo4j's transaction-scoped identity guarantee. To keep dump numbering deterministic, ordering uses `elementId()` on modern Neo4j and an internal `id()` fallback only on Neo4j 4.4, where `elementId()` does not exist. Old Neo4jBolt dumps remain loadable.
163
+
164
+ For relational loads, the adapter assigns a random temporary label and dump-ID property to imported nodes. After the nodes are committed it builds a temporary `neo4j_bolt_` index on that property, uses indexed lookups while creating relationships, drops the index, and removes the temporary metadata in batches. No database-internal identity is carried from one load transaction to another. Loading a relational dump therefore requires permission to create and drop an index.
159
165
 
160
- Loading requires an empty database unless `force_append: true` is passed.
166
+ Loads start with batches of 5,000 records by default. This is an initial ceiling rather than a claimed optimum: if Neo4j returns a transaction-memory/resource error whose server semantics guarantee rollback, the loader halves the failed batch and retries it, then keeps the smaller size for the rest of that phase. Node and relationship phases adapt independently. Other errors are not retried, because a generic connection failure cannot safely prove that a `CREATE` transaction did not commit. Callers can change the initial ceiling with `initial_batch_size:`; the CLI exposes the same setting as `--batch-size`.
167
+
168
+ Loading requires an empty database unless `force_append: true` is passed. `load_database_dump` owns its batch transactions and therefore rejects being called from inside `transaction`.
161
169
 
162
170
  ## CLI
163
171
 
@@ -168,7 +176,7 @@ The `neo4j_bolt` executable retains these commands:
168
176
  | `neo4j_bolt console` | Open an IRB console with Neo4jBolt loaded |
169
177
  | `neo4j_bolt clear --srsly` | Delete all nodes and relationships |
170
178
  | `neo4j_bolt dump` | Write the textual database dump |
171
- | `neo4j_bolt load [--force] PATH` | Load a textual dump |
179
+ | `neo4j_bolt load [--force] [--batch-size N] PATH` | Load a textual dump |
172
180
  | `neo4j_bolt index ls` | List constraints and indexes |
173
181
  | `neo4j_bolt index rm --force` | Remove all constraints and indexes |
174
182
  | `neo4j_bolt visualize` | Generate a GraphViz document |
@@ -183,7 +191,7 @@ The same complete integration suite is run against these exact Community images:
183
191
  - `neo4j:5.26.28-community`
184
192
  - `neo4j:2026.06.0-community`
185
193
 
186
- No compatibility beyond this matrix is claimed for `0.4.0.pre1`.
194
+ No compatibility beyond this matrix is claimed for `0.4.0`.
187
195
 
188
196
  Run one modern LTS target:
189
197
 
@@ -220,7 +228,7 @@ To point RSpec itself at an already disposable database, set `NEO4J_BOLT_TEST_HO
220
228
  | CLI commands | Preserved |
221
229
  | `BoltSocket`, `BoltBuffer`, protocol markers/state/parser/packer | Intentionally removed private implementation details |
222
230
 
223
- ## 0.4.0.pre1 migration notes
231
+ ## 0.4.0 migration notes
224
232
 
225
233
  - Ruby 3.4 or newer is required; Ruby 2.x/3.0–3.3 applications should stay on 0.3.x until upgraded.
226
234
  - The exact prerelease upstream dependency is pinned while no stable `neo4j-ruby-driver` 6.2.x exists.
@@ -233,3 +241,4 @@ To point RSpec itself at an already disposable database, set `NEO4J_BOLT_TEST_HO
233
241
  Run `bin/setup`, then `bundle exec rake spec` or `bundle exec rake spec:matrix`. Build without publishing with `bundle exec rake build`.
234
242
 
235
243
  Bug reports and pull requests are welcome at <https://github.com/specht/neo4j_bolt>.
244
+
data/bin/neo4j_bolt CHANGED
@@ -49,7 +49,7 @@ class App
49
49
  c.flag [:o, 'out-file'.to_sym], :default_value => '/dev/stdout'
50
50
  c.action do |global_options, options|
51
51
  File.open(options['out-file'.to_sym], 'w') do |f|
52
- dump_database(f)
52
+ dump_database(f, progress_io: $stderr)
53
53
  end
54
54
  end
55
55
  end
@@ -61,11 +61,18 @@ class App
61
61
  command :load do |c|
62
62
  # c.flag [:i, :in_file], :desc => 'input path', :required => true
63
63
  c.switch [:f, :force], :default_value => false, :desc => 'force appending nodes even if the database is not empty'
64
+ c.flag [:b, 'batch-size'.to_sym], :default_value => Neo4jBolt::LOAD_INITIAL_BATCH_SIZE,
65
+ :desc => 'initial batch size; automatically reduced on transaction-memory errors'
64
66
  c.action do |global_options, options, args|
65
67
  help_now!('input path is required') if args.empty?
66
68
  path = args.shift
67
69
  File.open(path, 'r') do |f|
68
- load_database_dump(f, force_append: options[:force])
70
+ load_database_dump(
71
+ f,
72
+ force_append: options[:force],
73
+ progress_io: $stderr,
74
+ initial_batch_size: options['batch-size'.to_sym]
75
+ )
69
76
  end
70
77
  end
71
78
  end
@@ -1,3 +1,3 @@
1
1
  module Neo4jBolt
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
data/lib/neo4j_bolt.rb CHANGED
@@ -9,6 +9,201 @@ require "neo4j_bolt/version"
9
9
 
10
10
  module Neo4jBolt
11
11
  CONSTRAINT_INDEX_PREFIX = "neo4j_bolt_"
12
+ LOAD_INITIAL_BATCH_SIZE = 5_000
13
+ LOAD_MEMORY_ERROR_CODES = %w[
14
+ Neo.ClientError.General.TransactionOutOfMemoryError
15
+ Neo.TransientError.General.MemoryPoolOutOfMemoryError
16
+ Neo.TransientError.General.TransactionMemoryLimit
17
+ ].freeze
18
+ LOAD_MEMORY_GQL_STATUSES = %w[51N72 51N73].freeze
19
+ LOAD_PROGRESS_STEP = 5_000
20
+ DUMP_PROGRESS_STEP = 1_000
21
+ private_constant :LOAD_MEMORY_ERROR_CODES, :LOAD_MEMORY_GQL_STATUSES,
22
+ :LOAD_PROGRESS_STEP, :DUMP_PROGRESS_STEP
23
+
24
+ class ProgressReporter
25
+ UPDATE_INTERVAL = 0.5
26
+ BAR_WIDTH = 24
27
+ SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
28
+ ANSI = {
29
+ reset: "\e[0m",
30
+ bold: "\e[1m",
31
+ dim: "\e[2m",
32
+ cyan: "\e[36m",
33
+ green: "\e[32m",
34
+ yellow: "\e[33m"
35
+ }.freeze
36
+
37
+ def initialize(io)
38
+ @io = io
39
+ mode = ENV.fetch("NEO4J_BOLT_PROGRESS", "auto")
40
+ unless %w[auto pretty plain].include?(mode)
41
+ raise ArgumentError, "NEO4J_BOLT_PROGRESS must be auto, pretty, or plain"
42
+ end
43
+
44
+ detected_tty = io && io.respond_to?(:tty?) && io.tty? && Neo4jBolt.bolt_verbosity.to_i.zero?
45
+ @pretty = io && (mode == "pretty" || (mode == "auto" && detected_tty))
46
+ @color = @pretty && !ENV.key?("NO_COLOR") && (mode == "pretty" || ENV["TERM"] != "dumb")
47
+ @last_update_at = nil
48
+ @line_open = false
49
+ @spinner_index = 0
50
+ end
51
+
52
+ def update(message, force: false, percent: nil)
53
+ return unless @io
54
+
55
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+ return if !force && @last_update_at && now - @last_update_at < UPDATE_INTERVAL
57
+
58
+ if @pretty
59
+ percent ||= extract_percent(message)
60
+ render_line(activity_line(message, percent))
61
+ else
62
+ @io.puts message
63
+ end
64
+ @last_update_at = now
65
+ end
66
+
67
+ def note(message, level: :info)
68
+ return unless @io
69
+
70
+ if @pretty && level == :info
71
+ update(message, force: true)
72
+ else
73
+ clear_line
74
+ if @pretty
75
+ icon = level == :warning ? color(:yellow, "!") : color(:cyan, "•")
76
+ @io.puts "#{icon} #{terminal_text(message)}"
77
+ else
78
+ @io.puts message
79
+ end
80
+ @last_update_at = nil
81
+ end
82
+ end
83
+
84
+ def finish(message)
85
+ return unless @io
86
+
87
+ if @pretty
88
+ clear_line
89
+ label, detail = terminal_parts(message)
90
+ line = "#{color(:green, '✓')} #{color(:bold, label)}"
91
+ line += " #{detail}" unless detail.empty?
92
+ @io.puts line
93
+ @line_open = false
94
+ else
95
+ @io.puts message
96
+ end
97
+ @last_update_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ end
99
+
100
+ def close
101
+ return unless @io && @pretty && @line_open
102
+
103
+ clear_line
104
+ end
105
+
106
+ private
107
+
108
+ def activity_line(message, percent)
109
+ label, detail = terminal_parts(message, strip_percent: !percent.nil?)
110
+ spinner = SPINNER[@spinner_index % SPINNER.length]
111
+ @spinner_index += 1
112
+
113
+ parts = [color(:cyan, spinner), color(:bold, label)]
114
+ if percent
115
+ percent = [[percent.to_i, 0].max, 100].min
116
+ parts << progress_bar(percent)
117
+ parts << color(:bold, format("%3d%%", percent))
118
+ end
119
+ parts << detail unless detail.empty?
120
+ parts.join(" ")
121
+ end
122
+
123
+ def progress_bar(percent)
124
+ filled = (percent * BAR_WIDTH / 100.0).round
125
+ empty = BAR_WIDTH - filled
126
+ "#{color(:cyan, '━' * filled)}#{color(:dim, '─' * empty)}"
127
+ end
128
+
129
+ def terminal_parts(message, strip_percent: false)
130
+ text = message.sub(/\.\.\.\z/, "")
131
+ label, detail = text.split(": ", 2)
132
+ label = pretty_label(label)
133
+ detail ||= ""
134
+ if strip_percent
135
+ detail = detail.sub(/\s+\(\d+%\)/, "")
136
+ detail = "" if detail.match?(/\A\d+%\z/)
137
+ end
138
+ detail = detail.gsub(/ \[batch (\d+)\]/) do
139
+ " #{color(:dim, '·')} batch #{format_count(Regexp.last_match(1))}"
140
+ end
141
+ detail = format_counts(detail)
142
+ detail = detail.gsub(", ", " #{color(:dim, '·')} ")
143
+ [label, detail]
144
+ end
145
+
146
+ def terminal_text(message)
147
+ label, detail = terminal_parts(message)
148
+ detail.empty? ? format_counts(label) : "#{label}: #{detail}"
149
+ end
150
+
151
+ def pretty_label(label)
152
+ case label
153
+ when "Building temporary relationship lookup index"
154
+ "Building lookup index"
155
+ when "Temporary relationship lookup index ready."
156
+ "Lookup index ready"
157
+ when "Cleaning temporary load metadata"
158
+ "Cleaning load metadata"
159
+ when "Temporary load metadata removed."
160
+ "Load metadata removed"
161
+ else
162
+ label
163
+ end
164
+ end
165
+
166
+ def extract_percent(message)
167
+ if (match = message.match(/\((\d+)%\)/)) || (match = message.match(/:\s*(\d+)%\z/))
168
+ return match[1].to_i
169
+ end
170
+
171
+ match = message.match(/:\s*(\d+)\/(\d+) nodes\z/)
172
+ return nil unless match
173
+
174
+ total = match[2].to_i
175
+ total.zero? ? 100 : match[1].to_i * 100 / total
176
+ end
177
+
178
+ def format_counts(text)
179
+ text.gsub(/\b\d{4,}\b/) { |number| format_count(number) }
180
+ end
181
+
182
+ def format_count(number)
183
+ number.to_s.reverse.scan(/.{1,3}/).join(",").reverse
184
+ end
185
+
186
+ def render_line(line)
187
+ @io.print "\r\e[2K#{line}"
188
+ @io.flush
189
+ @line_open = true
190
+ end
191
+
192
+ def clear_line
193
+ return unless @pretty && @line_open
194
+
195
+ @io.print "\r\e[2K"
196
+ @io.flush
197
+ @line_open = false
198
+ end
199
+
200
+ def color(style, text)
201
+ return text unless @color
202
+
203
+ "#{ANSI.fetch(style)}#{text}#{ANSI[:reset]}"
204
+ end
205
+ end
206
+ private_constant :ProgressReporter
12
207
  MIN_INTEGER = -(2**63)
13
208
  MAX_INTEGER = (2**63) - 1
14
209
  TRANSACTION_CONTEXT_KEY = :neo4j_bolt_transaction_contexts
@@ -319,95 +514,201 @@ module Neo4jBolt
319
514
  nil
320
515
  end
321
516
 
322
- def dump_database(io)
323
- dump_ids = {}
324
- next_dump_id = 0
325
- identity_function = dump_identity_function
517
+ def dump_database(io, progress_io: nil)
518
+ progress = ProgressReporter.new(progress_io)
519
+ dumped_nodes = 0
520
+ dumped_relationships = 0
326
521
 
327
- neo4j_query("MATCH (n) RETURN n ORDER BY #{identity_function}(n)") do |row|
328
- node = row["n"]
329
- dump_ids[node.element_id || node.id.to_s] = next_dump_id
330
- io.puts "n #{JSON.generate(id: next_dump_id, labels: node.labels, properties: node)}"
331
- next_dump_id += 1
332
- end
522
+ transaction do
523
+ identity_function = dump_identity_function
524
+ total_nodes = neo4j_query_expect_one("MATCH (n) RETURN count(n) AS count")["count"]
525
+ total_relationships = neo4j_query_expect_one("MATCH ()-[r]->() RETURN count(r) AS count")["count"]
526
+ dump_ids = total_relationships.positive? ? {} : nil
527
+
528
+ progress.update(
529
+ progress_message("Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships),
530
+ force: true
531
+ )
532
+
533
+ neo4j_query(
534
+ "MATCH (n) " \
535
+ "RETURN #{identity_function}(n) AS identity, labels(n) AS labels, properties(n) AS properties " \
536
+ "ORDER BY #{identity_function}(n)"
537
+ ) do |row|
538
+ dump_ids[row["identity"].to_s] = dumped_nodes if dump_ids
539
+ io.puts "n #{JSON.generate(id: dumped_nodes, labels: row["labels"], properties: row["properties"])}"
540
+ dumped_nodes += 1
541
+ if (dumped_nodes % DUMP_PROGRESS_STEP).zero?
542
+ progress.update(progress_message(
543
+ "Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships
544
+ ))
545
+ end
546
+ end
333
547
 
334
- neo4j_query("MATCH ()-[r]->() RETURN r ORDER BY #{identity_function}(r)") do |row|
335
- relationship = row["r"]
336
- from_identity = relationship.start_node_element_id || relationship.start_node_id.to_s
337
- to_identity = relationship.end_node_element_id || relationship.end_node_id.to_s
338
- io.puts "r #{JSON.generate(
339
- from: dump_ids.fetch(from_identity),
340
- to: dump_ids.fetch(to_identity),
341
- type: relationship.type,
342
- properties: relationship
343
- )}"
548
+ if total_relationships.positive?
549
+ neo4j_query(
550
+ "MATCH (from)-[r]->(to) " \
551
+ "RETURN #{identity_function}(from) AS from_identity, " \
552
+ "#{identity_function}(to) AS to_identity, type(r) AS type, properties(r) AS properties " \
553
+ "ORDER BY #{identity_function}(r)"
554
+ ) do |row|
555
+ io.puts "r #{JSON.generate(
556
+ from: dump_ids.fetch(row["from_identity"].to_s),
557
+ to: dump_ids.fetch(row["to_identity"].to_s),
558
+ type: row["type"],
559
+ properties: row["properties"]
560
+ )}"
561
+ dumped_relationships += 1
562
+ if (dumped_relationships % DUMP_PROGRESS_STEP).zero?
563
+ progress.update(progress_message(
564
+ "Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships
565
+ ))
566
+ end
567
+ end
568
+ end
344
569
  end
570
+
571
+ progress.finish("Dumped: #{dumped_nodes} nodes, #{dumped_relationships} relationships")
345
572
  nil
573
+ ensure
574
+ progress&.close
346
575
  end
347
576
 
348
- def load_database_dump(io, force_append: false)
577
+ def load_database_dump(io, force_append: false, progress_io: nil,
578
+ initial_batch_size: LOAD_INITIAL_BATCH_SIZE)
579
+ raise Error, "load_database_dump cannot run inside a transaction" if transaction_context
580
+
581
+ initial_batch_size = Integer(initial_batch_size)
582
+ raise ArgumentError, "initial_batch_size must be positive" unless initial_batch_size.positive?
583
+
349
584
  unless force_append
350
585
  count = neo4j_query_expect_one("MATCH (n) RETURN count(n) AS count")["count"]
351
586
  raise Error, "There are nodes in this database, exiting now." unless count.zero?
352
587
  end
353
588
 
589
+ progress = ProgressReporter.new(progress_io)
354
590
  node_batches = Hash.new { |hash, key| hash[key] = [] }
355
591
  relationship_batches = Hash.new { |hash, key| hash[key] = [] }
356
- parse_dump(io, node_batches, relationship_batches)
592
+ total_nodes, total_relationships = parse_dump(io, node_batches, relationship_batches, progress)
357
593
 
358
- temporary_id_property = "__neo4j_bolt_load_#{SecureRandom.hex(12)}"
594
+ temporary_token = SecureRandom.hex(12)
595
+ temporary_label = "__neo4j_bolt_load_#{temporary_token}"
596
+ temporary_id_property = "__neo4j_bolt_load_id_#{temporary_token}"
597
+ temporary_index = "neo4j_bolt_load_#{temporary_token}"
598
+ quoted_temporary_label = quote_identifier(temporary_label)
359
599
  quoted_temporary_id = quote_identifier(temporary_id_property)
600
+ quoted_temporary_index = quote_identifier(temporary_index)
601
+ needs_relationship_lookup = total_relationships.positive?
360
602
  loaded_nodes = 0
361
603
  loaded_relationships = 0
604
+ index_created = false
362
605
 
606
+ progress.update(
607
+ progress_message("Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
608
+ batch_size: initial_batch_size),
609
+ force: true
610
+ )
611
+
612
+ original_error = nil
613
+ cleanup_error = nil
363
614
  begin
615
+ node_batch_size = initial_batch_size
364
616
  node_batches.each_value do |nodes|
365
- nodes.each_slice(256) do |slice|
617
+ node_batch_size = adaptive_each_slice(nodes, node_batch_size, progress, "node") do |slice, current_batch_size|
366
618
  labels = slice.first.fetch("labels")
367
619
  label_clause = labels.map { |label| ":#{quote_identifier(label)}" }.join
620
+ label_clause += ":#{quoted_temporary_label}" if needs_relationship_lookup
621
+ temporary_assignment = if needs_relationship_lookup
622
+ "SET n.#{quoted_temporary_id} = item.id\n"
623
+ else
624
+ ""
625
+ end
368
626
  count = neo4j_query_expect_one(<<~CYPHER, nodes: slice)["count_n"]
369
627
  UNWIND $nodes AS item
370
628
  CREATE (n#{label_clause})
371
629
  SET n = item.properties
372
- SET n.#{quoted_temporary_id} = item.id
373
- RETURN count(n) AS count_n
630
+ #{temporary_assignment}RETURN count(n) AS count_n
374
631
  CYPHER
375
632
  raise Error, "Expected #{slice.size} nodes, got #{count}." unless count == slice.size
376
633
 
377
634
  loaded_nodes += slice.size
378
- report_load_progress(loaded_nodes, loaded_relationships)
635
+ progress.update(progress_message(
636
+ "Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
637
+ batch_size: current_batch_size
638
+ ))
379
639
  end
380
640
  end
381
641
 
382
- relationship_batches.each do |type, relationships|
383
- relationships.each_slice(256) do |slice|
384
- count = neo4j_query_expect_one(<<~CYPHER, relationships: slice)["count_r"]
385
- UNWIND $relationships AS item
386
- MATCH (from) WHERE from.#{quoted_temporary_id} = item.from
387
- MATCH (to) WHERE to.#{quoted_temporary_id} = item.to
388
- CREATE (from)-[r:#{quote_identifier(type)}]->(to)
389
- SET r = item.properties
390
- RETURN count(r) AS count_r
391
- CYPHER
392
- raise Error, "Expected #{slice.size} relationships, got #{count}." unless count == slice.size
642
+ if needs_relationship_lookup
643
+ progress.note("Building temporary relationship lookup index...")
644
+ neo4j_query(
645
+ "CREATE INDEX #{quoted_temporary_index} " \
646
+ "FOR (n:#{quoted_temporary_label}) ON (n.#{quoted_temporary_id})"
647
+ )
648
+ index_created = true
649
+ wait_for_load_index(temporary_index, progress)
650
+ progress.update(
651
+ progress_message("Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
652
+ batch_size: initial_batch_size),
653
+ force: true
654
+ )
393
655
 
394
- loaded_relationships += slice.size
395
- report_load_progress(loaded_nodes, loaded_relationships)
656
+ relationship_batch_size = initial_batch_size
657
+ relationship_batches.each do |type, relationships|
658
+ relationship_batch_size = adaptive_each_slice(
659
+ relationships, relationship_batch_size, progress, "relationship"
660
+ ) do |slice, current_batch_size|
661
+ count = neo4j_query_expect_one(<<~CYPHER, relationships: slice)["count_r"]
662
+ UNWIND $relationships AS item
663
+ MATCH (from:#{quoted_temporary_label} {#{quoted_temporary_id}: item.from})
664
+ MATCH (to:#{quoted_temporary_label} {#{quoted_temporary_id}: item.to})
665
+ CREATE (from)-[r:#{quote_identifier(type)}]->(to)
666
+ SET r = item.properties
667
+ RETURN count(r) AS count_r
668
+ CYPHER
669
+ raise Error, "Expected #{slice.size} relationships, got #{count}." unless count == slice.size
670
+
671
+ loaded_relationships += slice.size
672
+ progress.update(progress_message(
673
+ "Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
674
+ batch_size: current_batch_size
675
+ ))
676
+ end
396
677
  end
397
678
  end
679
+ rescue Exception => error # rubocop:disable Lint/RescueException -- preserve original failure through cleanup
680
+ original_error = error
681
+ raise
398
682
  ensure
399
- original_error = $!
400
- begin
401
- neo4j_query("MATCH (n) WHERE n.#{quoted_temporary_id} IS NOT NULL REMOVE n.#{quoted_temporary_id}") if loaded_nodes.positive?
402
- rescue Error
403
- raise if original_error.nil?
683
+ if index_created
684
+ begin
685
+ neo4j_query("DROP INDEX #{quoted_temporary_index} IF EXISTS")
686
+ rescue Error => error
687
+ cleanup_error ||= error
688
+ end
689
+ end
690
+
691
+ if needs_relationship_lookup
692
+ begin
693
+ progress&.note("Cleaning temporary load metadata...")
694
+ cleanup_load_metadata(
695
+ quoted_temporary_label, quoted_temporary_id, initial_batch_size, total_nodes, progress
696
+ )
697
+ rescue Error => error
698
+ cleanup_error ||= error
699
+ end
404
700
  end
701
+
702
+ raise cleanup_error if original_error.nil? && cleanup_error
405
703
  end
406
704
 
407
- warn if loaded_nodes.positive? || loaded_relationships.positive?
705
+ progress.finish("Loaded: #{loaded_nodes} nodes, #{loaded_relationships} relationships")
408
706
  nil
707
+ ensure
708
+ progress&.close
409
709
  end
410
710
 
711
+
411
712
  private
412
713
 
413
714
  def run_and_convert(runner, query, data)
@@ -535,7 +836,11 @@ module Neo4jBolt
535
836
  version.to_s.split(".").first.to_i >= 5 ? "elementId" : "id"
536
837
  end
537
838
 
538
- def parse_dump(io, node_batches, relationship_batches)
839
+ def parse_dump(io, node_batches, relationship_batches, progress = nil)
840
+ node_count = 0
841
+ relationship_count = 0
842
+ progress&.update("Reading dump: 0 nodes, 0 relationships", force: true)
843
+
539
844
  io.each_line.with_index(1) do |line, line_number|
540
845
  line = line.strip
541
846
  next if line.empty?
@@ -547,16 +852,108 @@ module Neo4jBolt
547
852
  if kind == "n"
548
853
  labels = value.fetch("labels")
549
854
  node_batches[labels.sort] << value
855
+ node_count += 1
550
856
  else
551
857
  relationship_batches[value.fetch("type")] << value
858
+ relationship_count += 1
859
+ end
860
+ if ((node_count + relationship_count) % LOAD_PROGRESS_STEP).zero?
861
+ progress&.update("Reading dump: #{node_count} nodes, #{relationship_count} relationships")
552
862
  end
553
863
  rescue JSON::ParserError, KeyError => error
554
864
  raise Error, "Invalid dump entry on line #{line_number}: #{error.message}"
555
865
  end
866
+
867
+ progress&.finish("Read dump: #{node_count} nodes, #{relationship_count} relationships")
868
+ [node_count, relationship_count]
869
+ end
870
+
871
+ def adaptive_each_slice(items, batch_size, progress, kind)
872
+ offset = 0
873
+ while offset < items.size
874
+ slice = items.slice(offset, [batch_size, items.size - offset].min)
875
+ begin
876
+ yield slice, batch_size
877
+ offset += slice.size
878
+ rescue Error => error
879
+ raise unless load_batch_memory_error?(error) && slice.size > 1
880
+
881
+ reduced_batch_size = [slice.size / 2, 1].max
882
+ batch_size = [batch_size, reduced_batch_size].min
883
+ progress&.note(
884
+ "Neo4j rejected a #{kind} batch of #{slice.size} for transaction memory; " \
885
+ "retrying with batches of #{batch_size}.",
886
+ level: :warning
887
+ )
888
+ end
889
+ end
890
+ batch_size
891
+ end
892
+
893
+ def load_batch_memory_error?(error)
894
+ upstream = error.cause
895
+ return false unless upstream
896
+
897
+ LOAD_MEMORY_ERROR_CODES.include?(upstream.respond_to?(:code) ? upstream.code : nil) ||
898
+ LOAD_MEMORY_GQL_STATUSES.include?(upstream.respond_to?(:gql_status) ? upstream.gql_status : nil)
899
+ end
900
+
901
+ def wait_for_load_index(index_name, progress)
902
+ loop do
903
+ row = neo4j_query_expect_one(<<~CYPHER, name: index_name)
904
+ SHOW INDEXES YIELD name, state, populationPercent
905
+ WHERE name = $name
906
+ RETURN state, populationPercent
907
+ CYPHER
908
+ state = row["state"]
909
+ population = row["populationPercent"] || 0
910
+ if state == "ONLINE"
911
+ progress&.finish("Temporary relationship lookup index ready.")
912
+ return
913
+ end
914
+ raise Error, "Temporary load index #{index_name} entered state #{state}." if state == "FAILED"
915
+
916
+ progress&.update("Building temporary relationship lookup index: #{population.to_i}%")
917
+ sleep 0.25
918
+ end
919
+ end
920
+
921
+ def cleanup_load_metadata(quoted_label, quoted_id, batch_size, total_nodes, progress)
922
+ cleaned_nodes = 0
923
+ loop do
924
+ begin
925
+ count = neo4j_query_expect_one(<<~CYPHER, limit: batch_size)["count_n"]
926
+ MATCH (n:#{quoted_label})
927
+ WITH n LIMIT $limit
928
+ REMOVE n.#{quoted_id}
929
+ REMOVE n:#{quoted_label}
930
+ RETURN count(n) AS count_n
931
+ CYPHER
932
+ break if count.zero?
933
+
934
+ cleaned_nodes += count
935
+ progress&.update("Cleaning temporary load metadata: #{cleaned_nodes}/#{total_nodes} nodes")
936
+ rescue Error => error
937
+ raise unless load_batch_memory_error?(error) && batch_size > 1
938
+
939
+ batch_size = [batch_size / 2, 1].max
940
+ progress&.note(
941
+ "Neo4j rejected a cleanup batch for transaction memory; retrying with batches of #{batch_size}.",
942
+ level: :warning
943
+ )
944
+ end
945
+ end
946
+ progress&.finish("Temporary load metadata removed.")
556
947
  end
557
948
 
558
- def report_load_progress(nodes, relationships)
559
- warn "Loaded #{nodes} nodes, #{relationships} relationships..." if Neo4jBolt.bolt_verbosity.to_i.positive?
949
+ def progress_message(action, nodes, relationships, total_nodes, total_relationships, batch_size: nil)
950
+ total = total_nodes + total_relationships
951
+ completed = nodes + relationships
952
+ percent = total.zero? ? 100 : (completed * 100 / total)
953
+ message = "#{action}: #{nodes}/#{total_nodes} nodes, " \
954
+ "#{relationships}/#{total_relationships} relationships (#{percent}%)"
955
+ message += " [batch #{batch_size}]" if batch_size
956
+ message
560
957
  end
561
958
 
562
959
  def log_query(query, data)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neo4j_bolt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Specht