neo4j_bolt 0.4.0 → 0.4.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: a0582f714d9d1c8793edb70481db6a2ff5548200b14ad55c1f44b0859270b7d9
4
- data.tar.gz: 73e300f77c941909738411fdbd9ce6b72ea6ef73a72a10184395e2228ce6a52c
3
+ metadata.gz: 8c587989e6936680692675035dada5d220122cc81655f2607b97e72b4725b23b
4
+ data.tar.gz: 6bedef9539b66644474e2d657b1e4454918a7bfe4a7db73cdfc626fb9b150426
5
5
  SHA512:
6
- metadata.gz: 8e5debab26d8b655b1fa3fc1e4c877308e8221345aec76844d2b423ef4fe3061bc535241566f9af63ba889274b944c81efbad9daeadf1cc9b0ed166c82a18417
7
- data.tar.gz: 65ed32647603bdeaf9bbca19335a0ca6fdccf5b17f3aaa11e19ef48868b242731a34b3cba74ff9475dbd5db94e6bf15f44f60110dca5adb8fe83d9709ef45a3d
6
+ metadata.gz: 9640636068e101464e9a92e0ca38e465bc3c847863b457d90ae3e5361c37960359e7fd9c24ec8915f1ad27f90311b0867b95973801e28a17939f2bf70b3bad75
7
+ data.tar.gz: ed3142cd503626d3ba4d04ea86a708426178a070513c14325e46bac405bfa384250f8978d12cf6eb72920bb35ec7170cbb3ecd90500de121f6c650279387313e
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. Terminal progress is updated in place; redirected stderr receives periodic progress lines.
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.1"
3
3
  end
data/lib/neo4j_bolt.rb CHANGED
@@ -9,6 +9,86 @@ 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
+
27
+ def initialize(io)
28
+ @io = io
29
+ @tty = io && io.respond_to?(:tty?) && io.tty? && Neo4jBolt.bolt_verbosity.to_i.zero?
30
+ @last_update_at = nil
31
+ @line_width = 0
32
+ @line_open = false
33
+ end
34
+
35
+ def update(message, force: false)
36
+ return unless @io
37
+
38
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ return if !force && @last_update_at && now - @last_update_at < UPDATE_INTERVAL
40
+
41
+ if @tty
42
+ padding = [@line_width - message.length, 0].max
43
+ @io.print "\r#{message}#{' ' * padding}"
44
+ @io.flush
45
+ @line_width = [@line_width, message.length].max
46
+ @line_open = true
47
+ else
48
+ @io.puts message
49
+ end
50
+ @last_update_at = now
51
+ end
52
+
53
+ def note(message)
54
+ return unless @io
55
+
56
+ clear_tty_line
57
+ @io.puts message
58
+ @last_update_at = nil
59
+ end
60
+
61
+ def finish(message)
62
+ return unless @io
63
+
64
+ update(message, force: true)
65
+ if @tty
66
+ @io.puts
67
+ @line_width = 0
68
+ @line_open = false
69
+ end
70
+ end
71
+
72
+ def close
73
+ return unless @io && @tty && @line_open
74
+
75
+ @io.puts
76
+ @line_width = 0
77
+ @line_open = false
78
+ end
79
+
80
+ private
81
+
82
+ def clear_tty_line
83
+ return unless @tty && @line_open
84
+
85
+ @io.print "\r#{' ' * @line_width}\r"
86
+ @io.flush
87
+ @line_width = 0
88
+ @line_open = false
89
+ end
90
+ end
91
+ private_constant :ProgressReporter
12
92
  MIN_INTEGER = -(2**63)
13
93
  MAX_INTEGER = (2**63) - 1
14
94
  TRANSACTION_CONTEXT_KEY = :neo4j_bolt_transaction_contexts
@@ -319,95 +399,201 @@ module Neo4jBolt
319
399
  nil
320
400
  end
321
401
 
322
- def dump_database(io)
323
- dump_ids = {}
324
- next_dump_id = 0
325
- identity_function = dump_identity_function
402
+ def dump_database(io, progress_io: nil)
403
+ progress = ProgressReporter.new(progress_io)
404
+ dumped_nodes = 0
405
+ dumped_relationships = 0
326
406
 
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
407
+ transaction do
408
+ identity_function = dump_identity_function
409
+ total_nodes = neo4j_query_expect_one("MATCH (n) RETURN count(n) AS count")["count"]
410
+ total_relationships = neo4j_query_expect_one("MATCH ()-[r]->() RETURN count(r) AS count")["count"]
411
+ dump_ids = total_relationships.positive? ? {} : nil
412
+
413
+ progress.update(
414
+ progress_message("Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships),
415
+ force: true
416
+ )
417
+
418
+ neo4j_query(
419
+ "MATCH (n) " \
420
+ "RETURN #{identity_function}(n) AS identity, labels(n) AS labels, properties(n) AS properties " \
421
+ "ORDER BY #{identity_function}(n)"
422
+ ) do |row|
423
+ dump_ids[row["identity"].to_s] = dumped_nodes if dump_ids
424
+ io.puts "n #{JSON.generate(id: dumped_nodes, labels: row["labels"], properties: row["properties"])}"
425
+ dumped_nodes += 1
426
+ if (dumped_nodes % DUMP_PROGRESS_STEP).zero?
427
+ progress.update(progress_message(
428
+ "Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships
429
+ ))
430
+ end
431
+ end
333
432
 
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
- )}"
433
+ if total_relationships.positive?
434
+ neo4j_query(
435
+ "MATCH (from)-[r]->(to) " \
436
+ "RETURN #{identity_function}(from) AS from_identity, " \
437
+ "#{identity_function}(to) AS to_identity, type(r) AS type, properties(r) AS properties " \
438
+ "ORDER BY #{identity_function}(r)"
439
+ ) do |row|
440
+ io.puts "r #{JSON.generate(
441
+ from: dump_ids.fetch(row["from_identity"].to_s),
442
+ to: dump_ids.fetch(row["to_identity"].to_s),
443
+ type: row["type"],
444
+ properties: row["properties"]
445
+ )}"
446
+ dumped_relationships += 1
447
+ if (dumped_relationships % DUMP_PROGRESS_STEP).zero?
448
+ progress.update(progress_message(
449
+ "Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships
450
+ ))
451
+ end
452
+ end
453
+ end
344
454
  end
455
+
456
+ progress.finish("Dumped: #{dumped_nodes} nodes, #{dumped_relationships} relationships")
345
457
  nil
458
+ ensure
459
+ progress&.close
346
460
  end
347
461
 
348
- def load_database_dump(io, force_append: false)
462
+ def load_database_dump(io, force_append: false, progress_io: nil,
463
+ initial_batch_size: LOAD_INITIAL_BATCH_SIZE)
464
+ raise Error, "load_database_dump cannot run inside a transaction" if transaction_context
465
+
466
+ initial_batch_size = Integer(initial_batch_size)
467
+ raise ArgumentError, "initial_batch_size must be positive" unless initial_batch_size.positive?
468
+
349
469
  unless force_append
350
470
  count = neo4j_query_expect_one("MATCH (n) RETURN count(n) AS count")["count"]
351
471
  raise Error, "There are nodes in this database, exiting now." unless count.zero?
352
472
  end
353
473
 
474
+ progress = ProgressReporter.new(progress_io)
354
475
  node_batches = Hash.new { |hash, key| hash[key] = [] }
355
476
  relationship_batches = Hash.new { |hash, key| hash[key] = [] }
356
- parse_dump(io, node_batches, relationship_batches)
477
+ total_nodes, total_relationships = parse_dump(io, node_batches, relationship_batches, progress)
357
478
 
358
- temporary_id_property = "__neo4j_bolt_load_#{SecureRandom.hex(12)}"
479
+ temporary_token = SecureRandom.hex(12)
480
+ temporary_label = "__neo4j_bolt_load_#{temporary_token}"
481
+ temporary_id_property = "__neo4j_bolt_load_id_#{temporary_token}"
482
+ temporary_index = "neo4j_bolt_load_#{temporary_token}"
483
+ quoted_temporary_label = quote_identifier(temporary_label)
359
484
  quoted_temporary_id = quote_identifier(temporary_id_property)
485
+ quoted_temporary_index = quote_identifier(temporary_index)
486
+ needs_relationship_lookup = total_relationships.positive?
360
487
  loaded_nodes = 0
361
488
  loaded_relationships = 0
489
+ index_created = false
490
+
491
+ progress.update(
492
+ progress_message("Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
493
+ batch_size: initial_batch_size),
494
+ force: true
495
+ )
362
496
 
497
+ original_error = nil
498
+ cleanup_error = nil
363
499
  begin
500
+ node_batch_size = initial_batch_size
364
501
  node_batches.each_value do |nodes|
365
- nodes.each_slice(256) do |slice|
502
+ node_batch_size = adaptive_each_slice(nodes, node_batch_size, progress, "node") do |slice, current_batch_size|
366
503
  labels = slice.first.fetch("labels")
367
504
  label_clause = labels.map { |label| ":#{quote_identifier(label)}" }.join
505
+ label_clause += ":#{quoted_temporary_label}" if needs_relationship_lookup
506
+ temporary_assignment = if needs_relationship_lookup
507
+ "SET n.#{quoted_temporary_id} = item.id\n"
508
+ else
509
+ ""
510
+ end
368
511
  count = neo4j_query_expect_one(<<~CYPHER, nodes: slice)["count_n"]
369
512
  UNWIND $nodes AS item
370
513
  CREATE (n#{label_clause})
371
514
  SET n = item.properties
372
- SET n.#{quoted_temporary_id} = item.id
373
- RETURN count(n) AS count_n
515
+ #{temporary_assignment}RETURN count(n) AS count_n
374
516
  CYPHER
375
517
  raise Error, "Expected #{slice.size} nodes, got #{count}." unless count == slice.size
376
518
 
377
519
  loaded_nodes += slice.size
378
- report_load_progress(loaded_nodes, loaded_relationships)
520
+ progress.update(progress_message(
521
+ "Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
522
+ batch_size: current_batch_size
523
+ ))
379
524
  end
380
525
  end
381
526
 
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
527
+ if needs_relationship_lookup
528
+ progress.note("Building temporary relationship lookup index...")
529
+ neo4j_query(
530
+ "CREATE INDEX #{quoted_temporary_index} " \
531
+ "FOR (n:#{quoted_temporary_label}) ON (n.#{quoted_temporary_id})"
532
+ )
533
+ index_created = true
534
+ wait_for_load_index(temporary_index, progress)
535
+ progress.update(
536
+ progress_message("Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
537
+ batch_size: initial_batch_size),
538
+ force: true
539
+ )
393
540
 
394
- loaded_relationships += slice.size
395
- report_load_progress(loaded_nodes, loaded_relationships)
541
+ relationship_batch_size = initial_batch_size
542
+ relationship_batches.each do |type, relationships|
543
+ relationship_batch_size = adaptive_each_slice(
544
+ relationships, relationship_batch_size, progress, "relationship"
545
+ ) do |slice, current_batch_size|
546
+ count = neo4j_query_expect_one(<<~CYPHER, relationships: slice)["count_r"]
547
+ UNWIND $relationships AS item
548
+ MATCH (from:#{quoted_temporary_label} {#{quoted_temporary_id}: item.from})
549
+ MATCH (to:#{quoted_temporary_label} {#{quoted_temporary_id}: item.to})
550
+ CREATE (from)-[r:#{quote_identifier(type)}]->(to)
551
+ SET r = item.properties
552
+ RETURN count(r) AS count_r
553
+ CYPHER
554
+ raise Error, "Expected #{slice.size} relationships, got #{count}." unless count == slice.size
555
+
556
+ loaded_relationships += slice.size
557
+ progress.update(progress_message(
558
+ "Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
559
+ batch_size: current_batch_size
560
+ ))
561
+ end
396
562
  end
397
563
  end
564
+ rescue Exception => error # rubocop:disable Lint/RescueException -- preserve original failure through cleanup
565
+ original_error = error
566
+ raise
398
567
  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?
568
+ if index_created
569
+ begin
570
+ neo4j_query("DROP INDEX #{quoted_temporary_index} IF EXISTS")
571
+ rescue Error => error
572
+ cleanup_error ||= error
573
+ end
404
574
  end
575
+
576
+ if needs_relationship_lookup
577
+ begin
578
+ progress&.note("Cleaning temporary load metadata...")
579
+ cleanup_load_metadata(
580
+ quoted_temporary_label, quoted_temporary_id, initial_batch_size, total_nodes, progress
581
+ )
582
+ rescue Error => error
583
+ cleanup_error ||= error
584
+ end
585
+ end
586
+
587
+ raise cleanup_error if original_error.nil? && cleanup_error
405
588
  end
406
589
 
407
- warn if loaded_nodes.positive? || loaded_relationships.positive?
590
+ progress.finish("Loaded: #{loaded_nodes} nodes, #{loaded_relationships} relationships")
408
591
  nil
592
+ ensure
593
+ progress&.close
409
594
  end
410
595
 
596
+
411
597
  private
412
598
 
413
599
  def run_and_convert(runner, query, data)
@@ -535,7 +721,11 @@ module Neo4jBolt
535
721
  version.to_s.split(".").first.to_i >= 5 ? "elementId" : "id"
536
722
  end
537
723
 
538
- def parse_dump(io, node_batches, relationship_batches)
724
+ def parse_dump(io, node_batches, relationship_batches, progress = nil)
725
+ node_count = 0
726
+ relationship_count = 0
727
+ progress&.update("Reading dump: 0 nodes, 0 relationships", force: true)
728
+
539
729
  io.each_line.with_index(1) do |line, line_number|
540
730
  line = line.strip
541
731
  next if line.empty?
@@ -547,16 +737,106 @@ module Neo4jBolt
547
737
  if kind == "n"
548
738
  labels = value.fetch("labels")
549
739
  node_batches[labels.sort] << value
740
+ node_count += 1
550
741
  else
551
742
  relationship_batches[value.fetch("type")] << value
743
+ relationship_count += 1
744
+ end
745
+ if ((node_count + relationship_count) % LOAD_PROGRESS_STEP).zero?
746
+ progress&.update("Reading dump: #{node_count} nodes, #{relationship_count} relationships")
552
747
  end
553
748
  rescue JSON::ParserError, KeyError => error
554
749
  raise Error, "Invalid dump entry on line #{line_number}: #{error.message}"
555
750
  end
751
+
752
+ progress&.finish("Read dump: #{node_count} nodes, #{relationship_count} relationships")
753
+ [node_count, relationship_count]
754
+ end
755
+
756
+ def adaptive_each_slice(items, batch_size, progress, kind)
757
+ offset = 0
758
+ while offset < items.size
759
+ slice = items.slice(offset, [batch_size, items.size - offset].min)
760
+ begin
761
+ yield slice, batch_size
762
+ offset += slice.size
763
+ rescue Error => error
764
+ raise unless load_batch_memory_error?(error) && slice.size > 1
765
+
766
+ reduced_batch_size = [slice.size / 2, 1].max
767
+ batch_size = [batch_size, reduced_batch_size].min
768
+ progress&.note(
769
+ "Neo4j rejected a #{kind} batch of #{slice.size} for transaction memory; " \
770
+ "retrying with batches of #{batch_size}."
771
+ )
772
+ end
773
+ end
774
+ batch_size
775
+ end
776
+
777
+ def load_batch_memory_error?(error)
778
+ upstream = error.cause
779
+ return false unless upstream
780
+
781
+ LOAD_MEMORY_ERROR_CODES.include?(upstream.respond_to?(:code) ? upstream.code : nil) ||
782
+ LOAD_MEMORY_GQL_STATUSES.include?(upstream.respond_to?(:gql_status) ? upstream.gql_status : nil)
783
+ end
784
+
785
+ def wait_for_load_index(index_name, progress)
786
+ loop do
787
+ row = neo4j_query_expect_one(<<~CYPHER, name: index_name)
788
+ SHOW INDEXES YIELD name, state, populationPercent
789
+ WHERE name = $name
790
+ RETURN state, populationPercent
791
+ CYPHER
792
+ state = row["state"]
793
+ population = row["populationPercent"] || 0
794
+ if state == "ONLINE"
795
+ progress&.finish("Temporary relationship lookup index ready.")
796
+ return
797
+ end
798
+ raise Error, "Temporary load index #{index_name} entered state #{state}." if state == "FAILED"
799
+
800
+ progress&.update("Building temporary relationship lookup index: #{population.to_i}%")
801
+ sleep 0.25
802
+ end
803
+ end
804
+
805
+ def cleanup_load_metadata(quoted_label, quoted_id, batch_size, total_nodes, progress)
806
+ cleaned_nodes = 0
807
+ loop do
808
+ begin
809
+ count = neo4j_query_expect_one(<<~CYPHER, limit: batch_size)["count_n"]
810
+ MATCH (n:#{quoted_label})
811
+ WITH n LIMIT $limit
812
+ REMOVE n.#{quoted_id}
813
+ REMOVE n:#{quoted_label}
814
+ RETURN count(n) AS count_n
815
+ CYPHER
816
+ break if count.zero?
817
+
818
+ cleaned_nodes += count
819
+ progress&.update("Cleaning temporary load metadata: #{cleaned_nodes}/#{total_nodes} nodes")
820
+ rescue Error => error
821
+ raise unless load_batch_memory_error?(error) && batch_size > 1
822
+
823
+ batch_size = [batch_size / 2, 1].max
824
+ progress&.note(
825
+ "Neo4j rejected a cleanup batch for transaction memory; retrying with batches of #{batch_size}."
826
+ )
827
+ end
828
+ end
829
+ progress&.finish("Temporary load metadata removed.")
556
830
  end
557
831
 
558
- def report_load_progress(nodes, relationships)
559
- warn "Loaded #{nodes} nodes, #{relationships} relationships..." if Neo4jBolt.bolt_verbosity.to_i.positive?
832
+ def progress_message(action, nodes, relationships, total_nodes, total_relationships, batch_size: nil)
833
+ total = total_nodes + total_relationships
834
+ completed = nodes + relationships
835
+ percent = total.zero? ? 100 : (completed * 100 / total)
836
+ message = "#{action}: #{nodes}/#{total_nodes} nodes, " \
837
+ "#{relationships}/#{total_relationships} relationships (#{percent}%)"
838
+ message += " [batch #{batch_size}]" if batch_size
839
+ message
560
840
  end
561
841
 
562
842
  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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Specht