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 +4 -4
- data/README.md +18 -9
- data/bin/neo4j_bolt +9 -2
- data/lib/neo4j_bolt/version.rb +1 -1
- data/lib/neo4j_bolt.rb +329 -49
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c587989e6936680692675035dada5d220122cc81655f2607b97e72b4725b23b
|
|
4
|
+
data.tar.gz: 6bedef9539b66644474e2d657b1e4454918a7bfe4a7db73cdfc626fb9b150426
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
data/lib/neo4j_bolt/version.rb
CHANGED
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
|
559
|
-
|
|
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)
|